资讯动态

C#上位机TCP通信:TcpListener与TcpClient双向消息收发实现

发布时间:2026/9/2 12:02:05 来源:尧图企业网站定制
简介这是一份基于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、服务端回SYNACK、客户端再发ACK。虽然我们用封装类感知不到这些细节但理解握手过程有助于排查连接超时和端口不通的问题。这个项目里核心需求是两端可以互相发消息所以服务端不能只是被动接收还要维护每个客户端的连接并具备主动下发消息的能力。这就意味着服务端在Accept之后不能把客户端对象扔了必须保存到一个连接池里。我在设计时用ConcurrentDictionarystring, TcpClient来管理key是客户端ID可以用GUID也可以用IP端口组合value是对应的TcpClient和NetworkStream。1.2 数据流动的核心NetworkStreamTcpClient封装了连接但真正收发数据要走GetStream()拿到的NetworkStream。NetworkStream是一个流式读写对象读用ReadAsync()写用WriteAsync()和文件流的用法很像。我把NetworkStream孤立的理解成一根水管一端往里面灌水另一端接水。但这里有个所有人都会踩的坑——TCP是流协议没有消息边界你发10个字节对端可能一次收到10个也可能分3次收到这个后面专门讲。2. 服务端实现监听、接入、收发与清理服务端的完整链路我理解为四步启动监听、接受连接、收发消息、清理断开连接。第一步和第二步代码量很少重点是第三步收发逻辑的严谨性以及第四步不能漏。2.1 监听端口与接受客户端连接我习惯把服务端封装成一个类方便复用。启动监听的核心逻辑大概是这样的public class TcpServer { private TcpListener _listener; private CancellationTokenSource _cts; private ConcurrentDictionarystring, 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 Taskbyte[] 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 Taskbyte[] 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才会真正变成能上线的通信模块。希望这篇文章能帮你少走几步弯路有细节问题也欢迎在评论区交流。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价