
简介压缩包内是一套完整的异步TCP聊天程序项目覆盖服务器端与客户端实现并包含界面布局、消息收发逻辑以及项目配置文件适合正在学习C#网络编程、希望掌握异步Socket高并发处理的开发者。包内共四十六个文件以C#源码、可执行程序、工程与资源文件等类型为主另附说明文档方便快速了解项目结构与使用方式整个压缩包仅86KB体积小但内容完整。目前已有168人学习下载。借助这套代码可以直观理解TCP三次握手与可靠传输机制在真实聊天场景中的落地方式观察异步编程如何通过事件驱动与回调避免线程阻塞同时学习客户端与服务端界面更新与网络通信解耦的设计手法是网络编程入门及中小型通信项目开发的实用参考。1. 拿到这套 TCP异步源码后先回答三个问题再动手TCP异步编程最常被误会的一点是以为这样写程序就能跑得更快。实际上异步解决的是“别傻等”——网络 IO 没准备好时线程先干别的数据到了再通过回调捡起来。拿到 TCP.rar_TCP异步 这套压缩包解压后你会发现内容非常收敛一个服务端工程、一个客户端工程和一份 readme.txt典型的小型异步 TCP 聊天程序结构。我要提醒的是别急着双击工程文件先回答三个问题它跟同步阻塞版比多出来的复杂度落在哪服务器和客户端各自要维护哪些连接状态readme 里约定的消息格式是什么。TCP 连接要三次握手建立、用滑动窗口和确认应答保证有序不丢比 UDP 重得多所以每个连接都要维护状态异步模型的价值就在这里十几个连接两三个线程就能扛住界面还不会因为收发而卡死。这套源码适合写 C# 上位机、做局域网聊天工具、或者入门 Socket 异步模型的开发者。把这三件事想清楚再往下拆包会顺很多。2. 解压与工程定位AsyncTcpServer、AsyncTcpClient 和 readme.txt 各管哪一块2.1 先看压缩包三个条目对应两套可运行工程压缩包内容不多但主次关系很明确。整理成下面这张表对应关系一眼就能看完压缩包条目工程角色主要职责AsyncTcpServer服务端工程用 TcpListener 监听端口、异步接受客户端连接、接收消息并广播给所有在线客户端AsyncTcpClient客户端工程用 TcpClient 主动连接服务器、发送聊天内容、异步接收服务端转发回来的消息readme.txt说明文档记录编译顺序、默认端口、消息格式约定以及原作者在调试时踩过的坑解压后常见的目录结构长这样TCP.rar ├─ AsyncTcpServer # 服务端工程 ├─ AsyncTcpClient # 客户端工程 └─ readme.txt # 说明文档真正决定你能否跑起来的不是这两个工程而是那几行 readme。很多资源“跑不起来”不是代码有问题是 readme 里把端口号、消息协议和运行顺序一起规定好了你没按它说的做自然连不上。所以第一步不是开 VS 编译而是先打开 readme.txt把三样东西找出来服务器监听的端口号、消息用什么编码、客户端连接的是哪个地址。这三个信息缺一个程序就只能在本地自说自话。我一般会顺手在源码里搜几个关键符号确认这份代码用的是哪一代异步写法后面调试时思路完全不同搜索关键词定位内容TcpListener / Bind / Listen监听初始化确认端口和监听地址BeginAccept / EndAccept异步接受连接老式 IAsyncResult 写法async / await新式异步写法依赖 .NET Framework 4.5 以上BeginRead / EndRead 或 ReadAsync异步接收数据的位置Invoke / BeginInvoke服务端或客户端把数据回发到 UI 控件的入口搜完你就知道这份代码里没有出现 async/await说明它用的是 Begin/End 回调模型缓冲区、回调委托、异常处理都要自己兜住如果搜到了 async/await那 UI 和网络可以写得比较省心但要小心上下文捕获的问题。两种写法在后面的章节都会用到建议都留着。2.2 编译前检查目标框架、端口和防火墙三件事在动手改代码之前先做三分钟环境检查能省下后面一整晚的排错时间。第一目标框架。代码里如果出现 async、Task、await 这三个关键字工程目标框架至少是 .NET Framework 4.5 或 .NET Core 2.0如果只有 Begin/End 回调那么老的 .NET Framework 3.5 也能编过。拿到源码第一步就是右键工程看“目标框架”别用新框架环境硬编译老代码经常会出现一堆“缺少引用”的假错误实际是框架版本不匹配。第二端口。这类聊天程序几乎不会用 80、443 这种知名端口常见做法是分配在 10000 以上的私有端口区间有些作者习惯用 45678 这种不好猜的数字也有些直接写死 8888。跑之前先确认端口没被占用netstat -ano | findstr 45678 tasklist /FI PID eq pid第一条命令看某个端口是否被监听第二条通过 PID 反查是哪个进程占着。如果端口被别的服务占了要么改源码里的端口常量要么在 readme 约定范围内换一个。第三防火墙。局域网调试时候最大的拦路虎就是 Windows 防火墙。如果程序只在本机回环地址上通信防火墙一般不管但只要换成局域网 IPWindows 默认就会拦入站连接。手动放行一个端口用这条命令netsh advfirewall firewall add rule nameTCPDemo dirin actionallow protocolTCP localport45678这条规则只放行 TCP 45678 端口的入站连接。建议把端口固定下来再放行不要整夜开着“程序允许所有入站”不然过两天你自己都忘了机器上开了哪些口。两台机器测试时先 ping 一下确认在同一网段再跑客户端。2.3 把 readme.txt 当协议起点来读readme.txt 在这类资源里不是摆设它就是这份代码的“通信协议草案”。注意我说的是草案因为很多业务逻辑是后来改的readme 可能没跟上。正确读法是先看协议再看代码最后拿代码反推协议。聊天这类程序消息用什么编码、有没有消息边界是两件天差地别的事。readme 里如果写了“消息以 \r\n 结尾”那说明它用的是行协议如果写了“前四个字节是消息长度”那就是长度前缀协议。这两种读法对应完全不同的解析代码。我见过有人不看 readme直接拿接收缓冲区的字节数去解析消息结果客户端和服务端各自用一套规则看似都在收发实则鸡同鸭讲。readme 里通常还会写四次挥手的处理方式。TCP 断开连接时主动关闭方进入 TIME_WAIT 状态对端会看到接收返回 0。聊天程序的客户端退出、服务器踢人、服务端重启都会触发这个 0。代码里有没有对这个返回值做判断直接决定了后续的断线重连能不能做干净。所以读 readme 时请顺手在接收回调里找找有没有if (n 0)这类判断没有的话重连功能基本是缺失的后面要自己补。3. AsyncTcpServer 实战改造异步接受连接、消息广播与 UI 回发3.1 异步接受连接的完整链路BeginAccept 到 EndAccept 配对服务器端最核心的逻辑不是收发消息而是“一边处理已有客户端一边还能接新客户端”。新手最容易写错的地方是把处理客户端消息和接受新连接写成了串行接一个连接、处理完、再接下一条。这样一旦某个客户端不按规矩收发消息整个服务器就堵在那个客户端上新连接全部积压。我一般会这样组织异步接受循环public void Start(int port) { _listener new TcpListener(IPAddress.Any, port); _listener.Start(100); // backlog挂起连接队列最大长度 _listener.BeginAcceptTcpClient(OnAcceptTcpClient, null); Log(服务已启动端口 port); } private void OnAcceptTcpClient(IAsyncResult ar) { TcpClient client; try { client _listener.EndAcceptTcpClient(ar); } catch (ObjectDisposedException) { return; // 服务停止时 EndAccept 会抛异常这里直接退出 } // 关键先发起下一次监听再处理当前客户端 _listener.BeginAcceptTcpClient(OnAcceptTcpClient, null); HandleClient(client); }这段代码有两个必须遵守的规则。第一BeginAcceptTcpClient和EndAcceptTcpClient必须成对出现漏掉任何一边都会导致异步操作泄漏。第二下一次BeginAcceptTcpClient必须在处理当前客户端之前发起否则客户端一个接一个地连进来回调就会排队积压表现就是“能连上但反应慢”。IPAddress.Any和127.0.0.1的差别在这里非常重要。前者监听本机所有网卡地址局域网内其他机器才能连进来后者只监听回环地址只能本机自己连自己。很多“本机跑得好好的一上局域网就超时”的情况八成是这里写成了 127.0.0.1。Start(100)里的 backlog 不是“最多允许 100 个客户端”而是“还没有被 Accept 的挂起连接最多 100 个”。它像店门口的排队区排队的超过 100 人后面的连接就会被内核直接拒绝。聊天程序一般 100 够用上位机如果会有大量设备同时握手可以调大到 200但不建议无脑调大因为每个挂起连接都占内核资源。3.2 消息边界聊天帧的分包和粘包别让 Receive 背锅进入实际收发后第一个让你怀疑人生的就是消息乱串。TCP 是字节流协议它只保证字节按顺序到达不保证你一次Send的内容能被对端一次Receive完整拿到。一次 Send 的数据可能被拆成两次 Receive这叫半包两次 Send 的数据也可能在一次 Receive 里同时到达这叫粘包。UDP 是按数据报发送的没有这个问题TCP 的可靠性全是靠序列号和确认应答撑起来的它根本不认识你的消息边界。所以聊天程序必须自定帧格式。最简单的方案是“4 字节长度前缀 消息体”/// summary /// 帧格式前 4 字节为消息体长度网络字节序随后是 UTF-8 编码的消息体 /// /summary private byte[] EncodeFrame(string message) { byte[] body Encoding.UTF8.GetBytes(message); byte[] header BitConverter.GetBytes(body.Length); Array.Reverse(header); // C# 是小端转成网络序大端 return header.Concat(body).ToArray(); }接收端不能直接按收到的字节数截取消息要有一个缓冲区攒数据攒到长度够了再解析private readonly MemoryStream _buffer new MemoryStream(); private void AppendAndParse(byte[] data, int count) { _buffer.Write(data, 0, count); while (true) { byte[] all _buffer.ToArray(); if (all.Length 4) { break; // 长度头还没收全继续等 } int payloadLen BitConverter.ToInt32(all, 0); // 取长度字段 if (all.Length 4 payloadLen) { break; // 半包等后续数据到达 } // 这里取一条完整的消息出来处理 string message Encoding.UTF8.GetString(all, 4, payloadLen); ProcessMessage(message); // 关键清掉已消费的字节保留剩余数据继续循环 byte[] rest all.Skip(4 payloadLen).ToArray(); _buffer.SetLength(0); _buffer.Write(rest, 0, rest.Length); } }这段代码里while (true)是必须的因为一次 Receive 的字节里可能包含多条完整消息处理完第一条要马上处理第二条。MemoryStream.ToArray()每次都会复制全量数据消息量大时性能不好但聊天程序里完全可以接受真到每秒几千条消息的规模再换成环形缓冲区。协议格式定下来后客户端和服务端必须用同一套编码规则。常见翻车现场是服务端用长度前缀客户端用换行符结尾两边各自编解码结果数据永远对不上。所以我习惯把EncodeFrame和ParseBuffer这两个方法做成两个工程共用的一个静态类谁改谁负责。3.3 多客户端广播与跨线程刷新界面聊天室服务端拿到一条消息后工作还没结束要把这条消息转给所有在线客户端。这里有一个隐蔽的坑你不能在接收回调的循环里直接遍历发送因为某个客户端可能已经断开向断开 socket 发送会抛异常也不能不加锁地遍历集合因为另一个线程可能同时有新客户端加入。我一般用ConcurrentDictionaryTcpClient, byte来保存在线客户端遍历时配合 try/catchprivate void BroadcastMessage(string sender, string message) { string output sender : message; foreach (var kv in _clients) { TcpClient client kv.Key; try { byte[] frame EncodeFrame(output); client.GetStream().BeginWrite(frame, 0, frame.Length, null, null); } catch (Exception) { // 这个客户端大概率已经断开交给空闲扫描去清理 _deadClients.Add(client); } } foreach (var dead in _deadClients) { _clients.TryRemove(dead, out _); dead.Close(); } _deadClients.Clear(); }写这段代码时有个经验之谈不要在接收回调里同步调用Write更不要假设GetStream()总是可用的。用一个_deadClients临时列表记录异常客户端广播结束再集中清理这样不会因为一个断开的连接拖累整次广播。另外这里用了BeginWrite而不是Write是为了避免发送缓冲区满时当前线程被阻塞异步的本质就是把这类 IO 等待让出去。再把消息显示到界面上时跨线程的坎就来了。接收回调跑在线程池线程直接txtLog.AppendText会报“线程间操作无效”。标准做法是判断InvokeRequired再通过委托跳回 UI 线程private void AppendMessage(string text) { if (InvokeRequired) { // 异步回发不阻塞调用线程 BeginInvoke(new Actionstring(AppendMessage), text); return; } txtLog.AppendText(text Environment.NewLine); }很多刚接触异步的人以为BeginInvoke是玄学其实它只是把委托交给 UI 线程的消息循环去执行。注意我们用的是BeginInvoke而不是Invoke因为Invoke是同步的会阻塞当前接收线程直到 UI 处理完成高并发下UI 一旦卡顿接收线程全部堵在Invoke上跟同步阻塞的后果一样。BeginInvoke丢进去就返回UI 线程排队处理这才是异步该有的样子。这章的改动做完服务端已经能接多个客户端、正确拆帧、广播、刷新界面了。别急着加功能先把服务端放在本地跑起来用客户端连一次确认消息不串、界面不卡再往下改客户端。4. AsyncTcpClient 实战改造连接状态、断线重连与界面联动4.1 客户端异步连接与接收用状态机表达连接生命周期客户端看起来比服务端简单但要做得能扛事其实是在跟一堆状态打交道未连接、连接中、已连接、重连等待。很多异步 TCP 客户端写得乱就是因为没有状态管理收到数据就处理断了就报错也不管自己现在处于哪个阶段。我习惯先把状态定义成枚举public enum ClientState { Disconnected, // 未连接或已断开 Connecting, // 正在连接 Connected, // 连接建立且可以收发 Reconnecting // 断线后等待重连 }然后写一个连接入口public async Task ConnectAsync(string host, int port) { _state ClientState.Connecting; _client new TcpClient(); try { await _client.ConnectAsync(host, port); _state ClientState.Connected; AppendMessage(已连接到 host : port); NetworkStream stream _client.GetStream(); _ ReceiveLoopAsync(stream); // 启动接收循环不等待 } catch (SocketException ex) { _state ClientState.Disconnected; AppendMessage(连接失败: ex.SocketErrorCode); ScheduleReconnect(1); } }注意ReceiveLoopAsync前面加了下划线丢弃 Task这叫“后台点火”接收循环在后台跑UI 线程不用等它。如果你在这里写await ReceiveLoopAsync(stream)整个界面就会停在“已连接”这一步消息收发虽然正常但用户以为程序卡死了。这个细节很容易被忽略。接收循环本身不长但断线判断全在里头private async Task ReceiveLoopAsync(NetworkStream stream) { byte[] buffer new byte[4096]; while (_state ClientState.Connected) { int n await stream.ReadAsync(buffer, 0, buffer.Length); if (n 0) { // 对端关闭连接四次挥手完成Recv 返回 0 break; } // 注意这里收到的是裸字节还要走帧解析不能直接转字符串 AppendAndParse(buffer, n); } OnDisconnected(); }await ReadAsync在数据没到的时候会把线程让出去这就是异步客户端不卡界面的根本原因。但有个细节必须讲清楚在 WinForms 里await后面默认会回到调用它的那个同步上下文也就是 UI 线程所以AppendAndParse里可以直接更新控件如果你手贱加了.ConfigureAwait(false)后续代码就跑到线程池了那时你再碰控件又得绕一圈BeginInvoke。代码库里没有特殊理由别加 ConfigureAwait(false)。4.2 断线重连与心跳服务器重启后客户端能自己回来ReadAsync返回 0 之后不能停在那里要进入重连流程。重连最土但最可靠的做法是指数退避第一次等 2 秒第二次 4 秒第三次 8 秒最多 30 秒封顶。别用固定 1 秒服务器重启那十来秒客户端会像机关枪一样反复触发连接失败日志刷满屏幕。private int _reconnectAttempt; private void ScheduleReconnect(int attempt) { int delay Math.Min(30, 2 attempt); // 2, 4, 8, 16, 30 _reconnectAttempt attempt; _state ClientState.Reconnecting; AppendMessage($第 {attempt} 次重连{delay} 秒后开始...); _reconnectTimer.Interval delay * 1000; _reconnectTimer.Start(); } private void OnReconnectTick(object sender, EventArgs e) { _reconnectTimer.Stop(); _ ConnectAsync(_host, _port); // 重新发起连接 }这里有一个很多文档不讲的坑重连时不要复用原来的TcpClient和NetworkStream一定要new TcpClient()。因为旧的 socket 断开后本地端口可能还处于 TIME_WAIT 状态直接拿旧 socket 去连内核会报“地址已在使用”。这个错误在 Java 里叫 Address already in use在 C# 里表现为SocketException原因是同一个 socket 绑定的本地端口还没释放干净。正确姿势就是每次重连都新建 socket让系统重新分配本地端口。心跳又是另一回事。聊天程序里如果你只是坐着不说话服务器无法从“没收到数据”判断你是活着还是断网了。断网时 TCP 可能要等很久才感知到所以客户端要定时发心跳包。常见做法是用一个 30 秒的定时器每 30 秒发送一个约定好的心跳帧服务器只要收到任意数据就认为连接健康private void SendHeartbeat() { if (_state ! ClientState.Connected) { return; } try { byte[] frame EncodeFrame({\type\:\ping\}); _client.GetStream().BeginWrite(frame, 0, frame.Length, null, null); } catch (Exception) { // 发送失败说明连接已经废了走重连 OnDisconnected(); } }心跳帧和聊天消息必须能区分开否则服务端会把 ping 当聊天内容广播出去。帧格式里可以加一个字段标记消息类型或者干脆用 JSON{type:ping}毕竟好扩展。注意BeginWrite的回调里有个隐藏坑写入失败时异常是在回调里抛的不是你调用BeginWrite的那一行。所以要么回调里 try/catch要么在这个try里包住整个发送再在外层补一个回调异常处理双保险。4.3 界面响应性验证把网络操作从 UI 线程摘干净客户端改完最容易出现的问题是“界面不知道什么时候就卡了”。我给自己定过两条硬指标每次改完客户端代码都用它验收UI 线程上不出现任何阻塞调用。Read、Write、Connect、Task.Wait()这一类一个都不能出现在按钮点击事件里。接收回调里不直接操作控件统一走BeginInvoke。这两条要做到得从根源上管住async void。WinForms 里的按钮事件天然是async void这意味着异常一旦抛出整个进程可能崩掉连 try/catch 都救不回来。我一般这样写private async void btnSend_Click(object sender, EventArgs e) { try { await SendMessageAsync(txtInput.Text); } catch (Exception ex) { AppendMessage(发送失败: ex.Message); } }await SendMessageAsync让 UI 线程在等待发送时保持响应异常又会被 try/catch 兜住不会炸进程。反过来如果你在点击事件里写_client.GetStream().Write(...)还包了一个大 try/catch界面照样会卡因为Write是同步阻塞的发送缓冲区一满UI 线程就跟网卡较上劲了。另外收工之前建议开两个客户端实测一个正常用另一个故意断网、关服务端、再恢复。观察第一个客户端会不会被第二个客户端的异常殃及。异步编程最恶心的一点是异常在线程之间乱传一个断开的 socket 如果在接收线程里没人处理可能把整个进程带走。所以接收循环里每处可能抛异常的地方都别忘了兜底宁可多写一个空 catch 注释清楚也别让异常裸奔到 UI 线程。5. 避坑记录把这套异步 TCP 源码跑起来时最容易翻车的五个地方5.1 启动与关闭阶段的坑坑一关掉窗体进程还在后台跑端口被占着不放。现象调试时关闭聊天窗体再重新启动服务端代码里明明修改过可是运行直接报“端口已被占用”有时候还连不上。原因窗体关闭只是表单结束了监听 socket 和那些还在异步回调里的对象不会自动释放。TcpListener一旦Start会一直监听BeginAccept的回调还挂在操作系统里进程没退端口就不会释放。加上很多回调方法里只是个空 catch 或者根本没处理ObjectDisposedException异常被吞了socket 就留在那。解决在FormClosing事件里做显式的关闭动作顺序很关键——先把_listener.Stop()停掉监听再逐个关闭在线客户端的 socket最后把回调里的标记位翻掉。这样EndAccept才能收到异常并安全退出protected override void OnFormClosing(FormClosingEventArgs e) { _listener.Stop(); foreach (var kv in _clients) { kv.Key.Close(); } _clients.Clear(); base.OnFormClosing(e); }从那以后我每次写异步服务器都会在关闭逻辑里补一句注释先停接口再断连接最后清集合。顺序反了就会出现连接清了、监听还是活的僵尸进程。坑二启动时故意绑定同一个端口第二次调用 Start 不报错但连接全部超时。现象代码里_listener.Start()连续执行两次第一次明明已经起来第二次没有异常但客户端就是连不上。原因Start的二次调用不会抛错但底层 socket 状态已经混乱监听可能失效或者 backlog 被覆盖。这种错误特别隐蔽因为它不报异常只看现象根本定位不到。解决Start之前用netstat -ano | findstr 端口检查一次端口占用代码里也可以用一条判断兜底如果_listener.Server.IsBound为真先Stop()再重新Start()。端口是稀缺资源谁先用谁占别跟系统抢。5.2 收发、跨线程与重连阶段的坑坑三客户端一上线服务器界面直接卡死鼠标转圈。现象本地调试客户端连上后开始发消息服务器窗体标题栏显示“未响应”过几秒才恢复。原因接收回调跑在线程池线程上回调里直接写了txtLog.AppendText(...)。UI 控件不是线程安全的跨线程操作轻则报异常重则让消息循环卡住。解决把 3.3 节那段InvokeRequired检查原封不动搬到服务器和客户端的消息显示方法里。一旦发现InvokeRequired为真就BeginInvoke回发。注意不要图省事用Control.CheckForIllegalCrossThreadCalls false那是把 UI 的线程安全机制关掉等于蒙眼开车后续界面偶发崩溃更难查。坑四消息变成一坨或者被拆成两半显示出来后甚至会乱码。现象客户端一句话分两次发送服务器收到后把两句话拼在一起或者一句话太长被拆成两次显示第一次还缺字。原因之前说过的 TCP 字节流特性Receive 一次拿到多少字节完全取决于网络状况。如果代码里直接根据count来GetString数据量大时必然错位。解决把 3.2 节的AppendAndParse这一整套帧解析补上。记住协议只在内存里定义没用的客户端EncodeFrame、服务端AppendAndParse两端必须一致。还要考虑极端情况比如消息内容本身超过 64KB要拆成多个帧发或者心跳和消息混在一个接收事件里解析循环必须处理干净再退出。坑五客户端断开后马上重连报“地址已在使用”卡在原地连不上。现象服务器重启客户端第一次断开然后几秒后自动重连控制台抛出SocketException错误码看不懂后续重连照样失败。原因客户端旧 socket 断线后本地端口处于 TIME_WAIT 状态。TCP 四次挥手之后主动关闭方要保持这个状态一段时间确保网络上残留的包不会串到新连接。如果重连时复用旧 socket本地端口就是旧的绑定端口内核直接拒绝绑定。解决每次重连都new TcpClient()让系统分配一个新的临时端口。如果业务场景特殊必须复用固定本地端口可以尝试设置client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true)但聊天程序没必要这么做重连换端口是标准解法。6. 验证收尾与进阶把聊天程序当上线代码跑一遍6.1 五步验证清单代码改完不能只在本地“看着能跑”就收工。我给自己定过一张验证清单每次接手这类异步 TCP 资源都会照着走一遍验证项标准操作通过标准本地回环同一台机器先启服务端再启客户端发一条消息两边的聊天记录窗口都显示界面无卡顿局域网互连客户端把连接地址从 127.0.0.1 改成服务器内网 IP能正常收发连不上先查防火墙和监听地址多客户端广播开三个客户端任选一个发消息其余两个都收到发消息的那个自己也显示断线重连服务端关掉再启动客户端保持不退出客户端在几十秒内自动重连并恢复收发粘包压力测试客户端循环发送 1000 条短消息比如“1”到“1000”服务端按帧完整解析顺序不乱、无乱码、无丢失最后一项粘包压力测试最容易翻车。把循环次数加大到一万条如果解析逻辑里缓冲区处理不到位不是丢消息就是吃内存。跑压力测试时我习惯开一个抓包工具盯着看确认字节流确实被拆包粘包再检查自己的解析代码是否每次都收敛。这里有个土办法在AppendAndParse里的while循环加个计数器一帧数据解析超过一万次就打个日志防止死循环把消息处理线程拖死。6.2 两个值得做的进阶改造验证通过后这套基础聊天程序就已经能在小范围内用了。要做更深一层我建议先改协议再改性能。协议上给帧格式加类型字段。当前长度前缀方案只有“一条消息”改成“类型 长度 内容”之后0x01 表示文本聊天、0x02 表示心跳、0x03 表示系统通知将来文件传输、远程指令下发都往这个帧里加类型就行不用再推倒重做。注意类型字段和长度字段都要统一字节序不然又是解码错乱。性能上把BeginAcceptTcpClient和BeginRead换成SocketAsyncEventArgs对象池。Begin/End 模型每次收发都要分配 IAsyncResult 对象高并发时 GC 压力不小SocketAsyncEventArgs配合对象池复用可以显著降低分配量。但聊天程序如果只是几十个连接根本到不了这个瓶颈不用为了炫技去改。判断标准很简单看空闲时任务管理器里进程 CPU 是否持续超过 10% 而不干活或者抓包发现重传率异常再考虑换模型。拿到这套 TCP.rar_TCP异步 源码后我建议你即使不改任何业务也先按照上面的清单跑通一遍把断线重连和粘包这两个环节单独做一次压力测试。从那以后我每次接手别人的异步 TCP 项目都强制先做这一件事画状态图、定义消息帧、再读收发逻辑。顺序对了坑就少了。希望帮到你。本文还有配套的精品资源点击获取