资讯详情

资讯详情

C#异步TCP通讯程序实战:Socket编程、粘包处理与断线重连

简介这是一份基于C#的异步TCP通信示例源码覆盖服务器端与客户端完整交互流程适合.NET网络编程学习者理解非阻塞通信、并发连接及async/await等机制。压缩包共62个文件大小约165KB以16个cs源码文件为核心附带sln/csproj工程文件、exe可执行程序、dll库、config配置、dbml数据模型及resx资源文件等便于直接查看工程结构并编译运行。目前已有129人学习下载可作为C#网络编程的入门参考。通过学习其中TcpListener与TcpClient的异步调用、NetworkStream读写以及Task并行处理读者能掌握从建立连接、收发数据到并发管理的关键写法并理解Begin/End模式与async/await在实际工程中的取舍对开发聊天程序、数据采集等场景具有直接的迁移价值。1. 异步TCP通讯程序一份能直接跑通的C#网络编程样板做上位机、写服务端中间件、给设备调通讯协议的人大概率都有过这种经历抱着《TCP/IP协议》啃完三次握手和滑动窗口真到自己写Socket时UI卡死、收包粘成一团、连接假死一个比一个玄学。这份「异步TCP通讯程序.zip」就是干这个用的——一个用C#在.NET环境下实现的异步TCP通信完整示例服务端在AsynchronousServerForm工程里跑监听和并发处理客户端在frmClient工程里做连接与收发用的Socket API就是System.Net.Sockets下的那套标准库。它不是教学PPT是打开Visual Studio就能编译的源码工程适合两类人想搞懂异步TCP怎么写才不阻塞的初学者以及需要一份能改能抄的通讯框架、不想从零摸Socket的从业者。下面我把这个工程的代码逻辑拆开讲清楚顺带把异步模型选型、粘包处理和断线重连这些坑一并交代。2. 异步模型先选对Begin/End与async/await的取舍2.1 为什么同步TCP在真实网络里撑不住先说结论同步TCP程序不是不能跑是扛不住真实场景。你写一个上位机要同时跟三台PLC通讯用TcpClient阻塞式Receive一个连接读到一半没数据整个线程就挂在那里等。UI线程一旦同步接收窗口直接假死拖都拖不动。这就是为什么这份资源里强调「异步」这两个字。TCP本身是面向连接的字节流协议三次握手建立连接后通信双方通过Socket收发数据。问题在于网络数据到达时间不可预测同步调用Receive时线程会阻塞直到内核缓冲区有数据才返回。如果对端一直不发数据这个线程就白等。异步编程的核心价值就在这儿发出接收请求后立刻返回数据到了由底层IOCP输入输出完成端口通知回调执行线程不闲着。读这份工程的时候先看它用的是哪种异步模型。从.v11.suo这个后缀判断工程是Visual Studio 2012时代建的那个年代C#异步主力是Begin/End方法对。这类老示例的典型结构是服务端BeginAcceptTcpClient接受连接客户端BeginReceive收数据BeginSend发数据。理解这套模型再迁移到async/await就顺了。2.2 Begin/End模型的回调状态机老工程里最常看到的就是这种写法一个Receive回调里再发起下一个BeginReceive形成一条回调链。先看一段典型的异步接收代码private void OnReceive(IAsyncResult ar) { // 从异步状态对象里取回之前传进去的Socket和缓冲区 StateObject state (StateObject)ar.AsyncState; Socket handler state.workSocket; try { // EndReceive返回本次实际收到的字节数 int bytesRead handler.EndReceive(ar); if (bytesRead 0) { // 把本次收到的数据追加到消息缓冲区 state.sb.Append(Encoding.UTF8.GetString(state.buffer, 0, bytesRead)); // 继续发起下一次接收注意这里又传入同一个StateObject handler.BeginReceive(state.buffer, 0, StateObject.BufferSize, 0, new AsyncCallback(OnReceive), state); } else { // 返回0代表对端关闭了连接 handler.Close(); } } catch (SocketException ex) { // 连接被重置等情况在这里捕获 Console.WriteLine($接收异常: {ex.SocketErrorCode}); } }这段代码有三个关键点。第一BeginReceive传进去的最后一个参数state是异步状态对象回调执行时靠ar.AsyncState把它取回来Socket实例和累积缓冲区都得靠它传递不能只用闭包变量。第二EndReceive必须和BeginReceive配对调用否则资源泄漏。第三bytesRead 0是对端关闭连接的信号一定要处理不然会陷入死循环或无限等待。这套模型的问题也明显回调嵌套回调业务逻辑被拆散状态对象里塞满了临时数据。第三个字节还没读完可能第二个连接已经进来了你得在回调里自己维护「哪个Socket对应哪个缓冲区」的映射关系特别容易出错。2.3 async/await重构后代码长什么样Begin/End模型在新代码里已经很少手写了async/await把状态机封装进了编译器代码读起来像同步逻辑跑起来还是异步的。同一段接收逻辑用现代写法是这样public async Task ReceiveLoopAsync(Socket socket, CancellationToken ct) { byte[] buffer new byte[8192]; try { while (!ct.IsCancellationRequested) { // ReadAsync是真正的异步I/O不会阻塞当前线程 int bytesRead await socket.ReceiveAsync(buffer, SocketFlags.None, ct); if (bytesRead 0) { // 对端正常关闭 break; } // 这里处理收到的数据包 await ProcessReceivedDataAsync(buffer, bytesRead); } } catch (OperationCanceledException) { // 主动取消属于正常退出路径 } catch (SocketException ex) when (ex.SocketErrorCode SocketError.ConnectionReset) { // 对端异常断开 Console.WriteLine(连接被重置); } }注意ReceiveAsync这个重载是.NET Framework 4.5之后提供的传SocketFlags.None表示常规读取CancellationToken用于超时或服务端停机时中断循环。await之后的代码会在IO完成后的线程池线程上继续执行并不意味着回到UI线程WinForms里更新控件还得靠Invoke。新旧模型的选择我给一个实在的建议如果是维护老工程必须读懂Begin/End因为代码就是那么写的如果是从零新写别犹豫直接上async/await配合NetworkStream.ReadAsync或Socket.ReceiveAsync维护成本低一个量级。这份工程的老代码价值在于让你看到异步回调的完整生命周期照着改一遍成async版本比看十篇博客都管用。3. 客户端落地连接建立、数据收发与断线重连3.1 从TcpClient到NetworkStream的连接链路客户端工程frmClient核心工作有这么几件根据用户输入的IP和端口号建立连接拿到网络流之后做数据交换UI上要实时反映连接状态。连接这步的代码通常长这样private async void btnConnect_Click(object sender, EventArgs e) { // 端口号是TCP层的寻址入口范围0-655351024以下多为系统保留 int port int.Parse(txtPort.Text); string ip txtIp.Text.Trim(); // 创建TcpClient实例此时并未建立真正的连接 tcpClient new TcpClient(); try { // ConnectAsync发起三次握手握手完成前方法已返回 await tcpClient.ConnectAsync(IPAddress.Parse(ip), port); networkStream tcpClient.GetStream(); // 连接成功后启动后台接收循环避免UI线程阻塞 _ ReceiveLoopAsync(networkStream); UpdateStatus(已连接); } catch (SocketException ex) { // 连接失败常见10061目标主机拒绝或10060超时 UpdateStatus($连接失败: {ex.SocketErrorCode}); } }TcpClient是Socket的高层封装内部还是走TCP协议栈的三次握手过程——SYN、SYN-ACK、ACK。ConnectAsync返回后并不意味着立刻能收发数据握手刚完成时本地缓冲区可能还没就绪稳妥做法是连接后先发个握手消息或等对端主动发数据。之前遇到过一个翻车现场客户端连上就发一帧数据偶尔丢第一条排查半天是服务器端监听Socket还没从Accept流程完全走出来客户端消息已经到了。后来都在Accept完成后加一个确认信号才继续业务。3.2 发送数据与接收循环的搭配连接建立只是开始客户端真正难写的部分在持续接收。TCP是字节流协议没有消息边界ReadAsync返回的字节数也不保证等于你期望的数据长度。一个实用的接收循环配合发送方法如下private async Task ReceiveLoopAsync(NetworkStream ns) { byte[] headerBuf new byte[4]; // 假设协议头用4字节标识消息长度 try { while (true) { // 第一步完整读取4字节长度头 int bytesRead await ReadExactlyAsync(ns, headerBuf, 4); if (bytesRead 0) break; int payloadLength BitConverter.ToInt32(headerBuf, 0); byte[] payloadBuf new byte[payloadLength]; // 第二步按长度读取消息体不能假定一次ReadAsync拿全 await ReadExactlyAsync(ns, payloadBuf, payloadLength); // 第三步缓冲区分帧完成交给UI线程处理 string msg Encoding.UTF8.GetString(payloadBuf); BeginInvoke(new Action(() listMsgs.AddItem(msg))); } } catch (Exception ex) { // 网络异常或对端关闭都在这里收敛 UpdateStatus($连接中断: {ex.Message}); } } private async Taskint ReadExactlyAsync(NetworkStream ns, byte[] buffer, int count) { int offset 0; while (offset count) { // 循环读直到凑满count个字节 int n await ns.ReadAsync(buffer, offset, count - offset); if (n 0) return 0; // 对端关闭 offset n; } return offset; }发送端对应的方法需要封装长度前缀不然接收端没法分帧public async Task SendMessageAsync(string text) { byte[] payload Encoding.UTF8.GetBytes(text); byte[] header BitConverter.GetBytes(payload.Length); // 先写头再写体两次写合并成一个缓冲区减少TCP分片 byte[] packet new byte[4 payload.Length]; Buffer.BlockCopy(header, 0, packet, 0, 4); Buffer.BlockCopy(payload, 0, packet, 4, payload.Length); // NetworkStream.WriteAsync内部最终落到Socket.Send await networkStream.WriteAsync(packet, 0, packet.Length); }ReadExactlyAsync这个辅助方法就是解决TCP字节流粘包问题的关键。底层ReadAsync什么数据量都可能返回真实网络里一个数据包被拆成两次达到是常态只有循环读取才能凑齐一帧完整消息。3.3 断线检测与重连的常见做法真实项目中TCP连接假死比直接断线更膈应人。物理断开还有SocketException可捕获但路由故障、对端断电这种场景你的客户端根本收不到通知连接就挂在那。检测方式常规做法有两种一是应用层心跳每隔几秒发一个约定的心跳包对端在N秒内没回应就判定死亡二是Socket层面查询Connected属性但这只能反映上次I/O操作时的状态不能真正探测当前链路。这份工程里大概率没有完整的心跳逻辑下载后建议自己补上。重连这一块我的做法是捕获到异常或心跳超时后不要立刻重连先做指数退避——第一次等1秒第二次2秒第三次4秒封顶30秒避免服务端恢复期间客户端疯狂握手把协议栈打满。另外重连之前务必把旧连接彻底释放包括Close()掉TcpClient和NetworkStream否则本地端口和句柄泄漏重连几次后报10048端口被占用那味道可太冲了。4. 服务端落地TcpListener与并发连接管理4.1 监听、绑定与异步接受连接服务端工程AsynchronousServerForm的核心是TcpListener。初始化流程有固定三件套绑定地址和端口、启动监听、开始异步接受客户端。代码骨架如下private TcpListener listener; private ConcurrentDictionarystring, ClientSession sessions new ConcurrentDictionarystring, ClientSession(); private void StartServer(int port) { // IPAddress.Any监听本机所有网卡地址0.0.0.0 listener new TcpListener(IPAddress.Any, port); // 监听队列长度超过这个数的新连接将被拒绝 listener.Start(100); // 启动一个后台任务循环接受连接而不是用BeginAccept回调 _ AcceptLoopAsync(); UpdateStatus($服务端已启动监听端口 {port}); } private async Task AcceptLoopAsync() { while (true) { try { // 每个客户端连接对应一个TCP连接三次握手完成后返回 TcpClient client await listener.AcceptTcpClientAsync(); // 为每个连接创建一个独立会话任务 ClientSession session new ClientSession(client); sessions[session.Id] session; _ session.HandleAsync(); } catch (Exception ex) { // 服务端停止时Accept会抛异常这里做退出处理 break; } } }注意TcpListener.Start的参数是backlog即等待接受的连接队列长度。局域网内几十个客户端的话默认值足够但如果是公网服务或大量短连接场景建议显式设置。否则客户端连接频繁时会出现连接被丢弃的现象表现就是客户端能ping通靶机IPtelnet端口却报10061原因不在防火墙而是服务端Accept速度跟不上队列溢出。4.2 每个连接一个Task会话状态管理服务端的并发模型核心是「一个连接一个Task」。每个Accept出来的TcpClient被包装成一个会话对象丢进线程池线程处理。这样做的优势是代码隔离每个会话里的读写、解析、心跳都是自己的状态机不需要锁。关键代码public class ClientSession { private readonly TcpClient client; private readonly NetworkStream stream; private readonly string sessionId; private readonly byte[] buffer new byte[4096]; public async Task HandleAsync() { try { // 区分不同客户端为后续广播或定向发送留标识 string remoteEnd client.Client.RemoteEndPoint.ToString(); UpdateStatus($客户端接入: {remoteEnd}); while (true) { // 非阻塞读取这里是并发安全的每个session独享自己的stream int bytesRead await stream.ReadAsync(buffer, 0, buffer.Length); if (bytesRead 0) { // 客户端正常断开 break; } // 这里做业务处理解析消息、分发到其他会话等 await ProcessMessageAsync(buffer, bytesRead); } } catch (IOException ex) { // 连接异常断开常见对端强制关闭或网络超时 } finally { // 从会话表中移除自己 sessions.TryRemove(sessionId, out _); stream.Dispose(); client.Close(); } } }会话集合用ConcurrentDictionary而不是普通Dictionary因为不同客户端Task可能同时往里添加和移除条目。TcpClient是IDisposable但它不如Socket那样有丰富的关闭选项Close()时会发送FIN并释放托管资源。注意ReadAsync返回0的处理必须在循环判空之前不然会死循环空转吃满一个CPU核心。4.3 连接清理与半开连接的注意点服务端最常见的内存隐忧是「死连接没清干净」。TCP协议里客户端异常断电时不会发FIN包服务端的ReadAsync永远不会返回这个会话Task就挂在内存里。解决方案有两个层面第一层是Socket层面的keepalive开关。在TcpClient的内部Socket上启用client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);但这只保证TCP协议栈定期探测对端是否存活默认探测间隔是2小时太长实战中不够用。第二层是应用层心跳超时与服务端配合每次收到任何数据都刷新该会话的最后活跃时间另有一个后台扫描任务把超过N秒没活跃的会话主动踢掉。我一般设置成3个心跳周期没数据就算死连接直接Close()并移除会话。另外注意TcpListener.Stop()和TcpListener.Close()的区别Stop()停止监听但已接受的现存连接保持打开Close()则杀光一切。服务端停机时要先主动关闭所有会话再Stop监听不然客户端那边会经历漫长超时而不是立即报错。5. 异步TCP通讯程序的避坑手册五个必踩的坑5.1 粘包与拆包收到的数据对不上号现象客户端一次性发了100字节服务端一次ReadAsync收到300字节里面包含了下一次发送的内容或反之发100字节服务端分两次各收50字节。原因TCP是字节流协议不保证消息边界。操作系统内核缓冲区积累多久、多少数据才交给应用层由底层协议栈决定应用层看到的只是连续的字节流。解决必须在应用层定义消息边界。最通用的方案是「长度前缀法」——每条消息开头用固定4字节表示后续载荷长度接收时先完整读4字节再按长度读体。这个方案在这份工程的改造建议里我已经写过核心就是ReadExactlyAsync那个循环一步都不能省。5.2 异步回调里直接更新UI跨线程异常成灾现象程序跑起来数据收得到但窗体偶发闪退抛InvalidOperationException提示线程间操作无效。原因异步回调执行在线程池线程上不是创建控件的UI线程。WinForms控件不是线程安全的直接跨线程操作控件会被.NET检测并拒绝。解决回调里把数据封装好用Control.BeginInvoke切回UI线程再更新控件。注意Invoke是同步等待UI线程回调线程会卡住BeginInvoke是异步投递回调线程不阻塞通讯场景一律用BeginInvoke。如果数据量大考虑合并更新——攒一批再一次性刷新UI不然高频收发时UI线程被投递的消息淹没界面照样卡。5.3 SocketException 10054对端强制关闭连接现象服务端日志里频繁出现SocketException: ConnectionReset错误码10054程序以为是严重故障。原因这是对端进程Close掉了Socket但没走规范的FIN四次挥手。比如客户端点击关闭窗体时直接Process.Kill或者底层网络返回RST包本地ReadAsync/WriteAsync就会抛这个错。解决把10054当成正常断开处理。在SocketException的catch分支里判断SocketErrorCode是ConnectionReset或ConnectionAborted就直接清理会话退出不要向上抛异常。血泪经验是之前没处理这个分支服务端每被强杀一个客户端就多一条红日志攒多了还以为程序出了大Bug。5.4 端口被占用重启服务端报10048现象服务端程序改动代码后重新运行提示绑定端口失败SocketException: Address already in use。原因上次进程结束时Socket进入了TIME_WAIT状态。TCP协议为了保证最后一个ACK能重发主动关闭方要等待2MSL最大报文存活时间约4分钟才能释放端口。快速重启服务端就会撞上。解决在绑定前设置端口复用TcpListener listener new TcpListener(IPAddress.Any, port); // 设置在TIME_WAIT状态下允许重新绑定 listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); listener.Start();注意ReuseAddress要在Start()之前设置。还有个别场景是你程序里上一份实例没杀干净ReuseAddress也救不了先查任务管理器找僵尸进程。5.5 字节序与缓冲区大小数值读到全反了现象自定义协议里发一个整数0x0102对端读出来是0x0201字符串正常但数值型字段全乱。原因网络字节序是大端Big-Endian而C#在Windows x86/ARM平台上默认小端。BitConverter.ToInt32直接按本机字节序解析两端机器不同就出错了。解决网络协议里数值字段统一用大端序传输。发送前用IPAddress.HostToNetworkOrder转换接收后NetworkToHostOrder转回来。缓冲区大小也一样TCP报文最大64KBReceive缓冲设小了大块数据传输会被反复拆包设大了浪费内存。4096字节对常规上位机通讯够用传大文件时按预期最大报文加一个协商字段动态分配别写死。6. 不写总结写个让这套异步TCP程序更耐用的技巧给消息加个帧头协议如果你把这份工程跑通了接下来最有价值的改动就是设计一套轻量帧协议统一客户端和服务端的编解码逻辑。我的习惯是用「魔数长度载荷校验」四段式开头2字节魔数0x5A 0xA5用于帧同步和异常数据快速丢弃中间4字节长度头随后是载荷字节流结尾1字节异或校验。private static byte[] PackFrame(byte[] payload) { // 完整帧 2字节魔数 4字节长度 载荷 1字节校验 byte[] frame new byte[7 payload.Length]; frame[0] 0x5A; frame[1] 0xA5; // 长度字段使用大端序跨平台一致 byte[] lenBytes BitConverter.GetBytes(payload.Length); frame[2] lenBytes[3]; frame[3] lenBytes[2]; frame[4] lenBytes[1]; frame[5] lenBytes[0]; Buffer.BlockCopy(payload, 0, frame, 6, payload.Length); // 异或校验覆盖魔数之后的所有字节 byte checksum 0; for (int i 2; i frame.Length - 1; i) { checksum ^ frame[i]; } frame[frame.Length - 1] checksum; return frame; }接收端解包时先扫魔数找到0x5A 0xA5后读4字节长度再等完整载荷、算校验和。校验失败说明数据损坏或帧同步丢失直接丢弃并重新扫下一帧。这个设计解决了一个实际问题网络流里一旦出现半个字节的错位没有魔数你根本不知道从哪开始解析。从那以后我每次写TCP程序都强制先定义帧格式再写收发逻辑绝不直接裸收发字节流这个习惯帮我省下的排查时间至少以周计。这套工程的原始代码是个很好的起点照着本文把粘包处理、断线重连和帧协议补上能省掉不少弯路希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →