简介:这是一份基于C#的TCP通信示例工程,实现服务器与客户端之间的双向消息收发,并支持在客户端界面点击按钮直接弹出服务器界面,非常适合作网络编程入门练习,也可作为快速搭建通信原型的参考代码。RAR压缩包中共有118个文件,整体约183KB,其中包含11个C#源程序、14个示例文件、sln解决方案、exe可执行程序、config与json配置文件、PDB调试文件等,文件组织符合标准Visual Studio工程结构,便于读者直接打开运行或按模块阅读。目前已有710人学习下载。借助该工程,读者可以直观了解TcpListener/TcpClient与Socket的配合方式、连接建立与数据收发流程、异常处理及资源释放的常见写法,并在此基础上扩展出聊天室、远程控制、即时通讯等更复杂的网络应用。 做C#上位机这些年,TCP通信是我绕不开的坎。无论是连PLC、接扫码枪、采集设备数据,还是写一个内部调试工具,最后都会收敛到同一个需求:一个Server挂在那边等连接,一个Client主动连上来,两边能互相发消息。标题里这个项目看起来简单,但它是C#网络通信最典型的底座架构——只要把TcpListener和TcpClient这套收发模型吃透了,后面再碰Modbus TCP、自定义协议、WebSocket都会顺手很多。这篇文章我就把服务端和客户端完整拆开讲,从方案选型到封帧拆包,再到我实际踩过的坑,适合刚接触C#网络编程的人,也适合想快速搭一套稳定通信框架的上位机工程师。
1. 为什么选TcpListener/TcpClient:动手前先想清楚
C#里做TCP有很多种姿势:直接用Socket、用TcpListener/TcpClient封装类、或者上SuperSocket这类第三方通信框架。我自己的经验是:做通用业务系统,选TcpListener/TcpClient就够了,别一上来就用裸Socket;做大规模高并发网关,再考虑框架或原生Socket。
TcpListener/TcpClient是.NET对Socket的二次封装,底层还是走Winsock,但它把Bind、Listen、Accept、Connect这些流程全部收敛成了几个简单方法,读写的核心是NetworkStream,语义上就是一端监听、一端连接、中间拿流来读。对绝大多数上位机、桌面工具、中小规模服务端来说,这个抽象层恰到好处:代码量少、结构清晰、出问题好排查。如果你拿裸Socket写,Listen回调、Accept循环、状态轮询全都要自己维护,代码会膨胀不少,而且不一定比封装类快多少。
1.1 服务端和客户端的角色划分
TCP通信里一定要分清楚谁是被动方。服务端执行TcpListener.Start()之后,调用AcceptTcpClientAsync()阻塞等待;客户端执行TcpClient.ConnectAsync()主动发起连接。这个过程对应的是TCP三次握手:客户端发SYN、服务端回SYN+ACK、客户端再发ACK。虽然我们用封装类感知不到这些细节,但理解握手过程有助于排查连接超时和端口不通的问题。
这个项目里核心需求是"两端可以互相发消息",所以服务端不能只是被动接收,还要维护每个客户端的连接,并具备主动下发消息的能力。这就意味着服务端在Accept之后不能把客户端对象扔了,必须保存到一个连接池里。我在设计时用ConcurrentDictionary<string, TcpClient>来管理,key是客户端ID(可以用GUID,也可以用IP+端口组合),value是对应的TcpClient和NetworkStream。
1.2 数据流动的核心:NetworkStream
TcpClient封装了连接,但真正收发数据要走GetStream()拿到的NetworkStream。NetworkStream是一个流式读写对象,读用ReadAsync(),写用WriteAsync(),和文件流的用法很像。我把NetworkStream孤立的理解成一根水管:一端往里面灌水,另一端接水。但这里有个所有人都会踩的坑——TCP是流协议,没有"消息边界",你发10个字节,对端可能一次收到10个,也可能分3次收到,这个后面专门讲。
2. 服务端实现:监听、接入、收发与清理
服务端的完整链路我理解为四步:启动监听、接受连接、收发消息、清理断开连接。第一步和第二步代码量很少,重点是第三步收发逻辑的严谨性,以及第四步不能漏。
2.1 监听端口与接受客户端连接
我习惯把服务端封装成一个类,方便复用。启动监听的核心逻辑大概是这样的:
public class TcpServer { private TcpListener _listener; private CancellationTokenSource _cts; private ConcurrentDictionary<string, ClientInfo> _clients = new(); public async Task StartAsync(int port) { _listener = new TcpListener(IPAddress.Any, port); _listener.Start(); _cts = new CancellationTokenSource(); while (!_cts.IsCancellationRequested) { TcpClient tcpClient = await _listener.AcceptTcpClientAsync(); _ = HandleClientAsync(tcpClient); } } }关键点有两个。第一,AcceptTcpClientAsync()会一直阻塞,直到有客户端连上来才返回一个TcpClient实例;第二,每个客户端连接都用Task.Run或_ = HandleClientAsync(...)单独处理,不能串行等待上一个客户端处理完再接下一个,否则只有一个客户端能连上,其他人全堵在Accept队列里。这里我用_ =开火后不管,让每个连接走独立的异步处理流程。
2.2 单个客户端的收发循环与断线清理
接入客户端后,要给它分配ID、保存连接、然后进入读取循环。这个循环要一直读到客户端断开为止,读取过程中如果抛异常,就代表连接异常,需要走清理逻辑:
private async Task HandleClientAsync(TcpClient tcpClient) { string clientId = Guid.NewGuid().ToString("N"); NetworkStream stream = tcpClient.GetStream(); _clients[clientId] = new ClientInfo { TcpClient = tcpClient, Stream = stream }; byte[] buffer = new byte[4096]; try { while (_cts != null && !_cts.IsCancellationRequested) { int readCount = await stream.ReadAsync(buffer, 0, buffer.Length); if (readCount == 0) break; // 客户端正常关闭 string msg = Encoding.UTF8.GetString(buffer, 0, readCount); OnMessageReceived?.Invoke(clientId, msg); } } catch (Exception ex) { // 连接中断或IO异常 } finally { _clients.TryRemove(clientId, out _); tcpClient.Close(); } }ReadAsync返回0表示对端已经优雅关闭,这是TCP的FIN包语义,也是唯一能确认"对端主动断开了"的正常信号。所以不要只用TcpClient.Connected属性判断连接状态,那个属性在TCP底层断开之后经常还是true,很不靠谱。清理这一步也很关键:从字典里移除连接、关闭TcpClient、释放NetworkStream,漏了任何一步都会造成句柄泄漏,长时间运行后端口会被占满。
2.3 服务端主动发消息:定向发送和广播
服务端不能只做一个接收器,"能主动发给客户端"才是满足标题需求的关键。我留了两个对外方法:
public async Task SendToClientAsync(string clientId, string message) { if (_clients.TryGetValue(clientId, out var clientInfo)) { byte[] data = Encoding.UTF8.GetBytes(message); await clientInfo.Stream.WriteAsync(data, 0, data.Length); await clientInfo.Stream.FlushAsync(); } } public async Task BroadcastAsync(string message) { byte[] data = Encoding.UTF8.GetBytes(message); foreach (var kvp in _clients) { await kvp.Value.Stream.WriteAsync(data, 0, data.Length); await kvp.Value.Stream.FlushAsync(); } }这里要注意一个问题:NetworkStream的写操作不是线程安全的,如果多个线程同时调用WriteAsync,数据会交叉写入,对端收到一堆乱序混拼字节。所以正式项目里发送端要加锁,或者用队列串行化写入,我在第4节会细讲。
3. 客户端实现:连接、心跳与自动重连
客户端相对简单,核心就是:Connect、循环收、随时发。但它有一个服务端体会不到的痛——服务端一直在等,断开后重新Accept就行;客户端一旦断了,必须自动重连,否则界面就变成僵尸守护着死连接。
3.1 初始连接与消息接收
客户端的骨架代码我一直保持得很薄:
public class TcpClientSession { private System.Net.Sockets.TcpClient _tcpClient; private NetworkStream _stream; private CancellationTokenSource _cts; public async Task ConnectAsync(string ip, int port) { _tcpClient = new System.Net.Sockets.TcpClient(); await _tcpClient.ConnectAsync(ip, port); _stream = _tcpClient.GetStream(); _cts = new CancellationTokenSource(); _ = ReceiveLoopAsync(); } private async Task ReceiveLoopAsync() { byte[] buffer = new byte[4096]; try { while (!_cts.IsCancellationRequested) { int readCount = await _stream.ReadAsync(buffer, 0, buffer.Length); if (readCount == 0) break; OnDataReceived?.Invoke(Encoding.UTF8.GetString(buffer, 0, readCount)); } } catch (Exception ex) { /* 连接断开 */ } } public async Task SendAsync(string message) { byte[] data = Encoding.UTF8.GetBytes(message); await _stream.WriteAsync(data, 0, data.Length); await _stream.FlushAsync(); } }所有的重点都在那个永不退出的ReceiveLoopAsync。你只需要在界面上调用一次ConnectAsync,之后消息会自动通过事件回调给UI;如果要发消息,直接调用SendAsync就行,不用再关心底层连接状态。
3.2 跨线程更新UI的一个正确姿势
客户端连上后,接收循环跑在后台Task里,消息回调也是后台线程,这时候你要往TextBox/ListBox里加文本,直接赋值会抛"跨线程操作无效"异常。正确的做法是用SynchronizationContext或控件的Invoke。我习惯在窗体里这样封装:
private void AppendMessage(string message) { if (txtLog.InvokeRequired) { txtLog.Invoke(new Action(() => AppendMessage(message))); return; } txtLog.AppendText($"[{DateTime.Now:HH:mm:ss}] {message}{Environment.NewLine}"); }InvokeRequired会判断当前线程是否是创建控件的线程,不是的话就"寄存"到UI线程上去执行。这样无论回调在哪个线程,最终都安全。
3.3 心跳保活与断线重连
不加心跳的TCP连接,很多时候断了本地感知不到。比如网线被拔了、对端设备被强制断电,TCP栈可能几十分钟后才报错。所以我习惯在客户端启一个每5秒发一次心跳的定时器,服务端如果连续几次没收到心跳,就判定连接失效并清理。
重连逻辑也很成熟了,核心是:捕获到异常或Read返回0后,进入重连循环,每隔一段时间再试一次,直到成功:
private async Task ReconnectLoopAsync() { while (!_cts.IsCancellationRequested) { try { var tcp = new System.Net.Sockets.TcpClient(); await tcp.ConnectAsync(_ip, _port); _tcpClient = tcp; _stream = tcp.GetStream(); _ = ReceiveLoopAsync(); return; } catch { await Task.Delay(3000); } } }重连间隔我现在一般配置成3秒,设置成固定值对多数场景都够用。重连时注意先释放旧连接资源,避免句柄堆积。
4. 双向通信绕不开的硬骨头:消息边界与线程安全
标题虽然只是"互相发消息",但从Demo到能用,中间隔着两个大坑:消息边界和并发写。不解决这两个问题,多跑一会儿就会出现乱码、粘包、消息内容错乱。
4.1 TCP是"流"不是"消息",边界必须自己切
举个例子:客户端连续发两条消息"你好"和"世界",服务端收到的情况可能是:
- 一次收到"你好世界"(粘包)
- 先收到"你",再收到"好世界"(半包)
- 一次收到"你好",下次收到"世界"(正好,但这是运气)
本质原因是TCP在传输层不维护应用消息的边界,它只保证字节流能按顺序到达。解决思路,就是双方约定一种"打包格式",一般有三种主流做法:固定长度包、分隔符包(比如以\n结尾)、长度前缀包。长消息用长度前缀最可靠,因为分隔符万一出现在业务数据里会出事,固定长度则浪费带宽。
4.2 用"4字节长度头+消息体"解决粘包半包
我的封帧方案是:每个消息前先写4字节的消息体长度(用BitConverter转int),再写消息体本身。
发送端代码:
public async Task SendFrameAsync(NetworkStream stream, byte[] payload) { byte[] lengthBytes = BitConverter.GetBytes(payload.Length); await stream.WriteAsync(lengthBytes, 0, 4); await stream.WriteAsync(payload, 0, payload.Length); await stream.FlushAsync(); }接收端不能只读一次。必须先读够4字节得到长度,再按该长度继续读消息体,如果一次Read拿不到全部,要继续读。这个"读满指定字节数"的逻辑是拆包的核心:
private async Task<byte[]> ReadExactlyAsync(NetworkStream stream, int length) { byte[] buffer = new byte[length]; int offset = 0; while (offset < length) { int read = await stream.ReadAsync(buffer, offset, length - offset); if (read == 0) throw new IOException("连接关闭"); offset += read; } return buffer; } public async Task<byte[]> ReceiveFrameAsync(NetworkStream stream) { byte[] lengthBytes = await ReadExactlyAsync(stream, 4); int length = BitConverter.ToInt32(lengthBytes, 0); return await ReadExactlyAsync(stream, length); }因为ReadExactlyAsync把每次Read的结果累积到buffer的偏移量里,所以无论对端怎么拆包,最终都能拼出一个完整的消息体。实际测试中,这个方案能稳定应对粘包和半包,消息稍微大一点也没问题。如果后续要做性能优化,可以引入MemoryStream缓冲、改用PipeReader,但对大多数场景上面的代码足够干净。
4.3 并发发送时加锁:防止多线程写串数据
服务端广播、客户端多线程发送,都可能导致多个Task同时调用WriteAsync。NetworkStream内部对线程安全没有做额外保护,两个WriteAsync同时执行,字节流可能交叉。我踩过的坑是:心跳线程和业务发送线程同时写,对端收到的数据变成了"心跳业务心跳业务"的交叉拼接。
解决方案是在发送方法上包一层锁:
private readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1); public async Task SendAsync(byte[] payload) { await _sendLock.WaitAsync(); try { byte[] lengthBytes = BitConverter.GetBytes(payload.Length); await _stream.WriteAsync(lengthBytes, 0, 4); await _stream.WriteAsync(payload, 0, payload.Length); await _stream.FlushAsync(); } finally { _sendLock.Release(); } }SemaphoreSlim比lock关键字更适合异步方法,因为它不会阻塞线程,WaitAsync等待期间让出线程。记住锁的范围要覆盖"整数帧",而不是只锁单次Write,否则依然会拆散帧。
5. 常见问题与排查技巧实录
做TCP通信,不可能不踩坑。下面这个表格是我在多个项目里反复遇到的真实问题,按出现频率排序:
| 问题现象 | 常见原因 | 解决方案 |
|---|---|---|
| 服务端启动报"地址已在使用" | 端口被占用,或上一个进程未完全释放 | 检查监听端口,杀掉占用进程,或服务端退出时主动Close |
| 客户端连接超时 | IP/端口不对,防火墙拦截,服务端未启动 | 先ping通IP,再telnet端口测试是否通 |
| 客户端能连上,但收不到消息 | 没有调用ReceiveLoopAsync,或服务端没Flush | 确认客户端启动接收循环,发送后执行FlushAsync |
| 收到乱码 | 编码不一致,或粘包导致截断 | 统一使用UTF-8,换用长度前缀封帧 |
| 程序运行一段时间后连不上 | 大量TIME_WAIT连接,句柄泄漏 | 确认连接关闭完整,做清理并查看句柄数 |
| 服务端发消息给所有客户端,部分客户端掉线 | 某个客户端连接已断但未清理 | 发送时捕获异常并移除失效连接 |
5.1 只调用Close还不够:注意资源的释放顺序
很多人以为TcpClient.Close()就把事情干完了,其实不完全是。Close之前应该先停止接收循环、取消CancellationToken、关闭NetworkStream,最后才Close TcpClient。顺序反了,可能会在关闭过程中撞上尚未退出的ReadAsync,抛ObjectDisposedException。我的习惯是直接用一个Dispose方法串起来:
public void Disconnect() { _cts?.Cancel(); _stream?.Close(); _tcpClient?.Close(); _tcpClient?.Dispose(); }5.2 防火墙问题不要只看本机
客户端连服务端连不上时,先分情况测试。如果在同一台机器上跑Client和Server,通常没有防火墙问题;如果跨机器,第一件事就是telnet服务端IP端口,看通不通。很多上位机部署到客户现场连不上,最后查出来全是Windows防火墙没有放行端口。我一般会让服务端做日志输出,监听和连接成功都打一句,能快速定位是哪一层没通。
6. 从Demo到能上线的通信模块,还差这几步
标题里的项目把核心收发跑通之后,真正要接到项目里,我建议继续做四件事。
第一,协议升级。通信内容别直接塞字符串,定义一个结构,比如[消息类型(2字节)][消息长度(4字节)][消息体],类型用来区分心跳、业务数据、响应等。第二,加日志。所有收发的原始报文都记录到日志文件里,尤其是设备对接时,没有日志就相当于闭着眼睛调试。第三,做消息队列。如果业务处理慢,接收循环里频繁做耗时操作会拖累接收,应该先入队,再异步处理。第四,考虑断线缓存。客户端重连成功后,把断线期间的缓存消息补发过去,避免数据丢失。
最后说一点我做通信模块的真实体会:C#的TCP双向通信工程上真正难的从来不是"连通",而是"断了之后怎么办、乱序之后怎么处理、多人同时操作怎么兼容"。把这些边界条件一个个处理干净,一个看起来简单的Demo才会真正变成能上线的通信模块。希望这篇文章能帮你少走几步弯路,有细节问题也欢迎在评论区交流。
本文还有配套的精品资源,点击获取