简介:Windows平台下基于Visual Studio 2013开发的Socket TCP通信示例工程,面向初学网络编程、想掌握服务端与多客户端实时交互的开发者,应用场景覆盖局域网聊天室、消息推送及文件分发等。工程实现类似QQ群聊的消息群发能力,并支持文件二进制流传输,可帮助理解TCP连接建立、多线程并发处理、Socket生命周期管理及消息广播机制,这些正是网络编程中最常遇到的难点。压缩包共58个文件,约16.42MB,包含cpp/h源码、exe可执行程序、pdb调试符号、vcxproj与sln完整工程文件及tlog构建日志,源码与编译产物齐备,便于直接运行和断点调试。资源分为Test_socket服务端与test_client客户端两部分,从Socket初始化、监听连接、accept握手到逐客户端收发消息均有完整示例,目前已有512人学习下载。对于想系统梳理Socket编程流程、动手搭建局域网聊天或服务端推送场景的开发者,是一款可直接参考的实践案例。
1. 从单连接到多连接:为什么你的TCP服务端总是卡在第一步
做了几年上位机和网络通信,很多开发者第一次写Socket服务端,都是从"客户端连上来、发一句、断掉"的Demo开始的。但一到真实项目——比如设备数据采集、车间多工位看板、聊天室转发——立刻翻车:一个客户端连上来,其他客户端就再也连不进了;或者服务端一收到数据就卡死,界面直接无响应。核心问题就一句话:你把阻塞放在了不该放的位置,又没有给连接建立独立的处理通道。这篇笔记要拆的这份资源,就是一套完整的Windows服务端 + 多客户端Socket TCP通信实现,覆盖了连接管理、消息群发、异常断开处理和重连机制,直接能跑起来改。适合正在做上位机通信、AGV调度、设备数据汇聚,或者学校里交通信课设的开发者。
2. 通信模型选型:为什么多客户端必须上多线程,而不是死等在一个Accept上
先明确一个底层事实:TCP服务端的Accept是阻塞调用,它在"等待新的客户端连接"这件事上会把当前线程卡住。如果你在主线程里写死了一个while循环,每轮都调用Accept,然后立刻进入Recv接收数据,那结果就是——第一个客户端成功连上后,服务端就一直停在这条连接的Recv等待里,后续客户端的连接请求全部堆在系统队列里,无人处理,表现就是"只有第一个连接能通信"。之所以必须引入多线程,是因为"监听连接"和"处理数据"是两个完全不同频率的事件:新连接是偶发的,而已连接客户端的收数据是持续的。用单一顺序流程没法同时等这两类事件。常见做法是一个主监听线程只负责Accept,每拿到一个socket,就启动一条独立工作线程去Recv这条连接的数据,主线程立刻回到Accept继续等下一个连接。这套模型就是本资源的核心骨架。
2.1 线程模型拆解:监视线程、工作线程与客户端列表的关系
先看资源里服务端初始化监听的核心逻辑,我按实际可运行的写法把关键段抽出来。
// 服务端监听初始化 public void Start(int port) { listener = new TcpListener(IPAddress.Any, port); listener.Start(10); // 最大挂起连接数 // 主监视线程:只做Accept,绝不在这里处理数据 Thread acceptThread = new Thread(() => { while (isRunning) { try { TcpClient client = listener.AcceptTcpClient(); // 拿到新连接,立刻交给独立线程去收发 Thread workThread = new Thread(() => HandleClient(client)); workThread.IsBackground = true; workThread.Start(); } catch (Exception ex) { // 监听停止时会有异常,这里记录日志后退出 Console.WriteLine($"[监听异常] {ex.Message}"); } } }); acceptThread.IsBackground = true; acceptThread.Start(); }这段代码的关键在于Accept和HandleClient彻底分离。AcceptTcpClient是阻塞点,必须由独立线程承担;拿到TcpClient对象后,new一个工作线程去执行HandleClient方法,工作线程里才做Recv和Send。TcpClient对象是引用类型,每个连接都有自己独立的NetworkStream,线程间天然隔离,不共享流对象,所以不需要加锁。注意listener.Start(10)的10是未Accept的挂起连接队列长度,不是最大连接数,这点很多初学者搞混。同步的TcpListener模型下,这个值决定的是"排队等待被Accept的连接"上限,超过这个数的连接请求会被直接拒绝。
2.2 客户端列表与Socket状态:为什么要用ConcurrentDictionary而不是List
多客户端场景必然要维护一个"在线客户端集合",用来做群发、定向发送、掉线剔除。资源的服务端实现里,用的是ConcurrentDictionary而不是普通的List或Dictionary,原因是多条工作线程会并发地增删这个集合——A线程要移除一条已断开的连接,B线程正在往所有连接群发消息。如果用一个普通List,一边遍历一边移除会抛"集合已修改"异常;用Dictionary的话并发写会直接产生未定义行为。ConcurrentDictionary内部做了分段锁,读写都不用自己再包lock。
// 全局在线客户端表:key为连接Id,value为TcpClient对象 public static ConcurrentDictionary<string, TcpClient> onlineClients = new ConcurrentDictionary<string, TcpClient>(); private void HandleClient(TcpClient client) { string clientId = Guid.NewGuid().ToString("N"); onlineClients.TryAdd(clientId, client); Console.WriteLine($"[新客户端] {client.Client.RemoteEndPoint} 当前在线数:{onlineClients.Count}"); try { NetworkStream stream = client.GetStream(); byte[] buffer = new byte[4096]; while (isRunning) { int readCount = stream.Read(buffer, 0, buffer.Length); if (readCount == 0) { break; // 对端正常关闭,跳出循环 } string msg = Encoding.UTF8.GetString(buffer, 0, readCount); Console.WriteLine($"[收到] {msg}"); Broadcast($"{clientId}: {msg}"); } } catch (Exception ex) { Console.WriteLine($"[连接异常] {clientId} {ex.Message}"); } finally { onlineClients.TryRemove(clientId, out _); client.Close(); Console.WriteLine($"[断开] {clientId} 当前在线数:{onlineClients.Count}"); } }这里最容易翻车的是Read返回0的场景。在TCP协议里,服务端调用Read时如果对端正常关闭(调用了Socket.Close而不是直接断电掉线),Read会返回0,此时必须退出循环并清理客户端。但如果是网线拔掉、进程崩溃这种异常情况,Read是检测不到的,它一直阻塞在那里。所以资源里还做了一件事:客户端的心跳超时检测,后面会单独讲。这段代码里我用Guid的N格式做客户端Id,是为了群发时能知道消息来源。实际做设备接入时,更推荐在客户端连上后第一条消息就把设备编号发上来,服务端用设备编号作为字典的key,这样群发和定向控制都更直观。
2.3 心跳与掉线检测:TCP没有通知机制,靠的是超时
写上位机通信最血泪的一条经验:不要指望TCP能告诉你对方什么时候消失。Socket是流协议,对端断电、断网、休眠,本端可能一直阻塞在Read上,啥都收不到。资源的解决方案是客户端定时发心跳包,服务端记录最后一次收到数据的时间,循环轮询检查是否超时。这个检测线程要独立,不能占用任何一条客户端工作线程。
// 心跳检测线程:每5秒检查一次所有客户端的最后活动时间 Thread heartBeatThread = new Thread(() => { while (isRunning) { DateTime now = DateTime.Now; foreach (var kv in onlineClients) { // lastActiveTime存储每次收到数据的时刻 if ((now - lastActive[kv.Key]).TotalSeconds > 15) { Console.WriteLine($"[超时剔除] {kv.Key}"); kv.Value.Close(); // 触发该连接工作线程的Read异常,从而走清理逻辑 } } Thread.Sleep(5000); } });知识点拆开说三点。第一,TimeSpan.TotalSeconds的判断阈值必须大于心跳发送间隔的两倍以上,心跳5秒一次,超时阈值至少给15秒,留两到三个周期的余量,否则网络抖动一下就误杀正常客户端。第二,超时后不要直接调用字典的TryRemove,而是调用TcpClient.Close关闭底层socket,这样会强制让阻塞在Read上的工作线程抛出异常,由它的finally块去完成移除和清理。如果你直接TryRemove而不关socket,那条工作线程永远卡死在Read里,变成僵尸线程。第三,lastActive这个字典的更新点和上面的接收逻辑要配合——每次Read拿到数据,就更新对应Id的lastActive时间。
3. 消息群发与定向发送:从全局广播到按设备编号定向
资源场景里高频需求就两个:把一条消息发给所有客户端(群发),或者发给指定设备(定向)。群发看起来简单,但一个明显的坑是:群发是在工作线程里被触发的——A客户端的接收线程收到了数据,调用Broadcast往所有客户端写,这和B客户端正在断开、C客户端正在被心跳剔除是并发发生的。如果你拿一个普通的List遍历群发,遍历途中某个连接被关闭,Send就会抛出异常。因此群发前必须先判断连接状态,异常时移除,而不是让广播线程崩掉。
3.1 群发实现:遍历快照与异常隔离
看资源的服务端源代码里,群发方法大致是这样的处理方式。
public void Broadcast(string message) { byte[] data = Encoding.UTF8.GetBytes(message); // 取快照,避免遍历时集合被并发修改 foreach (var kv in onlineClients.ToArray()) { try { NetworkStream stream = kv.Value.GetStream(); if (stream.CanWrite) { stream.Write(data, 0, data.Length); stream.Flush(); } } catch (Exception ex) { Console.WriteLine($"[发送失败-移除] {kv.Key} {ex.Message}"); // 发送失败说明连接已不可用,尝试关闭触发清理 try { kv.Value.Close(); } catch { } onlineClients.TryRemove(kv.Key, out _); } } }ToArray()这个动作是ConcurrentDictionary提供的原子快照功能,遍历和集合的并发写不会冲突。另外要特别说明Send的语义:TcpClient的NetworkStream.Write只是把数据写入socket的发送缓冲区,不代表对端已经收到,因为底层是TCP/IP协议栈,有窗口、有确认、有重传,数据先走内核发送队列,真正到达对端是异步的。如果你的业务要求"服务端能确认客户端收到了",那需要在应用层增加ACK机制——客户端收到后回一条确认消息。资源里没做ACK,它在IM类场景、看板推送这类允许偶发丢失的场合够用,但对面向硬件的TCP指令下发场景,你要知道这个边界,别拿着它直接去做下发指令后必须确认的工位控制。
3.2 定向发送与协议黏包:消息边界必须自己定
先看问题代码:很多初版写法是服务端Recv一次就当成一条完整消息,但TCP是流协议,没有消息边界。比如客户端连续Send了两条业务消息,底层可能一次就把两段数据拼在一起送到服务端,服务端Recv读到的就是拼好的一大坨;也可能一条消息被拆成两半,服务端读一半就要处理,业务就崩了。解决思路是自定义应用层协议,常见做法是"包头+包体":前4个字节定义包体长度,后续按这个长度截取。资源的服务端处理部分已经做了这个解析。
// 自定义协议解析:前4字节为包体长度(int,小端序),后面跟UTF8包体 private string ParseMessage(NetworkStream stream) { byte[] header = new byte[4]; int headerRead = ReadFull(stream, header, 4); if (headerRead == 0) return null; // 连接关闭 int bodyLength = BitConverter.ToInt32(header, 0); if (bodyLength <= 0 || bodyLength > 65536) return null; // 非法长度保护 byte[] body = new byte[bodyLength]; int bodyRead = ReadFull(stream, body, bodyLength); if (bodyRead == 0) return null; return Encoding.UTF8.GetString(body); } // 确保读满指定字节数,因为NetworkStream的Read可能返回少于请求的量 private int ReadFull(NetworkStream stream, byte[] buffer, int count) { int offset = 0; while (offset < count) { int read = stream.Read(buffer, offset, count - offset); if (read == 0) break; // 连接关闭 offset += read; } return offset; }为什么ReadFull必须存在,直接stream.Read(buffer, 0, 4)不行吗?不行。NetworkStream.Read的语义是"最多返回n个字节",它可能因为网络波动、缓冲区调度只读到2个字节就返回了,返回值就是2。你不处理这种情况,包头都拼不齐。ReadFull里用while循环保证读满才返回,这是TCP编程的基操。对应的,客户端发送时也需要先写4字节长度再加body。这个设计同时解决了另一个问题:黏包半包。因为每次解析都知道一条消息的完整边界,Recv循环里剩余的半条就留在缓冲区,等下一次Read合并。
3.3 Windows服务端界面与客户端管理:易于上手的可视化窗格
资源的服务端不是纯控制台,它带了一个Windows窗体的界面层,上面有一个实时在线列表(ListBox),下面有消息日志区,右侧是群发输入框和发送按钮。在线列表用的是ObservableCollection绑定,服务端每加入或移除一个客户端,界面自动刷新。这部分在源码里比较长,但核心就一个思想:工作线程和UI线程要调度,不能在WorkThread里直接改控件的Items,要写Control.BeginInvoke。
// 将一条日志追加到UI的RichTextBox,线程安全 private void AppendLog(string line) { if (logBox.InvokeRequired) { logBox.BeginInvoke(new Action<string>(AppendLog), line); return; } logBox.AppendText($"{DateTime.Now:HH:mm:ss} {line}\r\n"); }WinForms的控件有线程亲和性,谁创建的控件就只能在哪个线程访问。工作线程收到一条消息后直接写logBox的Text属性会抛出跨线程异常。InvokeRequired判断当前线程是否控件的创建线程,不是就通过BeginInvoke封送,这是Windows桌面端Socket服务端必须处理的一环。如果你把这个服务端改成Windows Service或控制台程序,这部分UI调度逻辑就全部可以去掉,剩下的通信骨架原样保留。
4. 资源里的客户端实现:重连、多端实例与消息拼接
资源附带了一个Windows客户端工程,支持手动输入服务端IP和端口,连接成功后可以群发消息,也可以模拟多个客户端实例。多端实例的实现方式比较聪明:客户端程序不做单例限制,你开几个进程就是几个客户端,对服务端来说就是多个socket连接。如果你需要在一台机器上验证100个并发连接,直接用脚本循环起多个客户端进程就行,也可以改资源里的客户端代码,用多线程模拟多客户端,每线程一个连接。这块代码量不大,但有两处细节值得细读:断线重连和发送数据的包头拼接。
4.1 客户端断线重连:不要让操作工手动重启程序
现场设备通信程序最常见的问题就是:服务端重启了一下,客户端编程连不回来。TCP不会自动重建失效连接,客户端里的socket对象一旦进入error状态就必须重new,没法复用。资源的客户端是这样处理断连的。
private void ConnectWithRetry(string ip, int port) { while (!isConnected && isRunning) { try { client = new TcpClient(); client.Connect(ip, port); // 同步连接,超时默认约20秒 isConnected = true; AppendLog($"[已连接] {ip}:{port}"); // 连接建立后立刻起接收线程 receiveThread = new Thread(ReceiveLoop); receiveThread.IsBackground = true; receiveThread.Start(); } catch (Exception ex) { AppendLog($"[连接失败] {ex.Message} 5秒后重试"); Thread.Sleep(5000); // 重试等待,避免疯狂打日志 } } }注意TcpClient.Connect是同步阻塞方法,如果IP不可达,它可能会阻塞十几秒甚至二十秒才抛异常。客户端界面不要直接在主线程里调用这个Connect,否则操作工一点连接按钮界面就假死。资源的写法是在后台线程里调ConnectWithRetry,界面上只留一个"连接中..."的状态。现场维护的经验是:重连间隔设成3到5秒是合理的,太短会把CPU和日志打满,太长操作工会觉得软件坏了。这个机制配合服务端的心跳剔除,就形成了断线自愈闭环——服务端发现超时踢掉僵尸连接,客户端发现连接断开自动重连。
4.2 客户端发送的协议封装与服务端接收的匹配
发消息的代码要和服务端ParseMessage完全对应,不然长度错位、字符错位、解出来全是乱码。这条对应关系是联调时最常见的排错入口,我在好几个项目里都遇到过:服务端按4字节长度解析对端数据,对端发的是字符串原样发送,结果解析出来的第一个中文字符永远少了半边。再看客户端的发送实现。
public void Send(string text) { byte[] body = Encoding.UTF8.GetBytes(text); byte[] header = BitConverter.GetBytes(body.Length); // 4字节小端 byte[] packet = new byte[header.Length + body.Length]; Buffer.BlockCopy(header, 0, packet, 0, 4); Buffer.BlockCopy(body, 0, packet, 4, body.Length); stream.Write(packet, 0, packet.Length); stream.Flush(); }BitConverter.GetBytes(int)在Windows平台默认得到小端序,服务端用BitConverter.ToInt32读,两边都用的Windows .NET环境,天然匹配。如果服务端是Linux的.NET,BitConverter.IsLittleEndian返回false,则以系统字节序为准,深浅端设计时要统一。这里提醒一句:如果你将来用Java、Python的socket对接这套协议,一定要用struct的形式拆包,注意大小端标识。Python那边解包头,用struct.unpack('<i', header)即可,默认小端。资源里的双边都是C# Windows环境,所以这条细节目前看不出问题,但一旦跨语言联调就是坑。
5. 常见问题排查:从连接失败到黏包乱码,五个必踩的坑
资源整套跑下来,大部分开发者的故障集中在起步阶段,就是连不上、连上就掉、发中文乱码这几类。列五条实际排查记录,每一条都是我在调这类通信程序时反复处理过的。
5.1 本机能连但局域网其他机器连不上
现象:服务端在本机运行,本机客户端连本机IP能通,同一局域网内另一台电脑的客户端一直提示连接超时或拒绝。
原因:第一,Windows防火墙默认拦截外部入站的TCP连接;第二,服务端监听绑定的IP不是0.0.0.0,而是本机某个网卡IP(比如192.168.1.10),换了个网段就连不上。
解决:排查时先在本机和服务端同网段的机器之间用telnet测试端口,telnet 192.168.1.10 8888,如果显示超时先放行防火墙入站规则,控制面板里新增TCP端口规则放行。这个动作我在交付部署时都会提醒客户,Windows自带防火墙对未识别程序的入站连接默认阻止。绑定的监听地址直接用IPAddress.Any能减少一档配置问题,这也是资源第2.1小节写法选IPAddress.Any的原因。
5.2 服务端界面上显示多个客户端但群发没反应
现象:客户端A和B都连上了,服务端的在线列表都有显示,但服务端从A收到的消息群发给B时,B那边消息迟迟收不到,也不报错。
原因:如果客户端在多个网络接口上都建立了连接(例如A是Wi-Fi连的、B是有线连的),问题不在服务端在客户端。但最常见的根因是客户端接收线程没有把收到的数据提交到UI显示,B的日志区没有刷新,人看着就像没收到。其实数据已经到达B的客户端进程,只是没有渲染到界面。
解决:在客户端接收线程里,收到消息后检查TextBox是否线程安全,和AppendLog一致,用BeginInvoke刷新。这种"通信其实成功、显示没跟上"的坑,排查时用抓包工具最直接,看WireShark里的TCP流能确认数据包已经到B的网卡。我习惯在客户端接收处加一条控制台输出或者写日志文件,数据到达和界面刷新分离,一眼就能分清。
5.3 服务端一收到数据就卡死,整个界面无响应
现象:第一个客户端连上后,服务端界面正常,但客户端一发消息,服务端窗口就卡住,连最小化都动不了。
原因:常见原因是接收处理里在UI线程内做了循环读取,Read阻塞了UI线程;或者是工作线程里接收数据后同步写UI控件,造成串行等待。
解决:确认所有阻塞读取都离开了UI线程,工作线程与UI线程用BeginInvoke隔离。检查代码里有没有在UI事件处理器里直接同步调stream.Read,有就提出来放到后台线程。资源第2.1小节的acceptThread、workThread已经做了这个设计。如果你改版时动了这个结构,排查方向就往"哪条线程阻塞了UI调度"查。
5.4 中文消息发送后显示乱码
现象:服务端收到客户端发来的"设备1启动",显示出来是一串乱码或者吞字。
原因:两边使用不同的编码。以前的项目里常见的问题是用Encoding.Default(Windows本地编码)而不是UTF8,中文环境GB2312和服务端ISO8859-1解码就乱了。字节对,码表不对,必然乱码。
解决:统一编码为UTF-8。客户端发送时Encoding.UTF8.GetBytes,服务端接收时Encoding.UTF8.GetString。检查占位符和所有拼接消息的编码来源一致即可。如果你是在SerialPort调试器里误发了GB2312的十六进制字符串,那服务端按UTF8解当然乱码。
5.5 日志显示Read返回了0,但客户端明明没退出
现象:服务端的日志出现"[断开]"记录,但客户端那边界面还显示连着,也没有主动关程序。
原因:原因分几种,最常见的是服务端执行了心跳超时剔除:客户端因为桌面锁屏、睡眠、网络切换,长时间没有发送心跳包。服务端按超时阈值关掉了socket,客户端那边没有立即感知,要等到下一次Send或Recv才会触发异常。
解决:客户端补上自动心跳发送,在ReceiveLoop里额外起一个线程,每5秒主动发送一个心跳包,用固定的心跳报文标识(比如"PING")。服务端心跳检测时,收到任何数据的都刷新最后活跃时间。这样锁屏睡眠后,系统网络栈一恢复,心跳包继续发,服务端就不会误杀。如果你要区分业务消息和心跳包,在应用层协议里定义一个type字段,1表示业务消息、2表示心跳,包体长度统一走那个4字节头,心跳包body为"PING"字面量即可。
6. 实战验证:用三个客户端同时收发消息,检查群发时序与吞吐
把这套架构讲完后,直接在Windows环境里做一次完整验证,别只在代码里看逻辑。我会用一份命令行脚本快速模拟2个客户端,加上资源里自带的1个GUI客户端,形成3个并发连接,验证群发是否完整、顺序是否错乱、断开后服务端是否自动清理。
6.1 准备验证环境:编译服务端与客户端
打开Visual Studio编译资源里的服务端和客户端,注意目标框架,我这里是.NET Framework 4.7.2。编译通过后启动服务端.exe,记下界面上显示的监听端口。客户端工程编译好,开两个实例,每个都填IP为127.0.0.1、端口为服务端监听的端口,点连接。等服务端在线列表里出现两条记录。再开一个命令行窗口,用下面的PowerShell模拟第三个客户端。
# PowerShell作为TCP客户端,与服务端建立socket连接 $tcpClient = New-Object System.Net.Sockets.TcpClient $tcpClient.Connect('127.0.0.1', 8888) $stream = $tcpClient.GetStream() $body = [System.Text.Encoding]::UTF8.GetBytes('脚本客户端-3') $header = [System.BitConverter]::GetBytes([int]$body.Length) $stream.Write($header, 0, 4) $stream.Write($body, 0, $body.Length) $stream.Flush() # 保持连接不断开 Start-Sleep -Seconds 30用PowerShell模拟客户端的要点是包头要和服务端ParseMessage完全匹配:4字节长度加UTF8包体。我写成一对Write是因为NetworkStream在这里不会中途截断,生产代码还是必须用ReadFull,但PowerShell临时模拟数据够用。跑完以后服务端日志应该出现"[新客户端]"共3条。
6.2 群发与顺序验证:发送100条带编号消息观察丢序
从脚本客户端发送100条递增编号的消息,每条格式为group-msg-顺序号。服务端收到后群发给所有在线客户端。重点观察两条现象:第一,每个客户端是否都收到了100条;第二,收到的顺序是否递增。
我第一次运行的时候发现偶发乱序:客户端收到的顺序是0、1、3、2、4。排查后确认问题不在服务端群发,而在TCP重传机制:某条消息的报文在传输中丢了,底层重传后到达顺序就变了,服务端还是按接收顺序解析的。要保序需要在应用层加序号字段,接收端检测序号不连续时要求重发或等待。资源的应用场景下这类乱序概率低,因为它用的是本机回环测试,网络重传基本不发生,报文顺序和队列顺序基本一致。
6.3 断开与清理验证:硬杀一个客户端进程
打开任务管理器,直接把第三个脚本客户端的PowerShell进程杀掉。服务端那条客户端工作线程的Read会一直阻塞,这是TCP的特性,除非你发了TCP KeepAlive或者心跳超时。资源的做法是心跳线程在15秒后把这条僵尸连接剔除。重点观察:第15秒时服务端日志出现"[超时剔除]",在线列表从3变为2,其他两条连接不收任何影响。这就验证了心跳机制和并发集合的清理是闭环的。
6.4 吞吐量验证:单条消息大小和并发数的实际边界
资源里的接收缓冲区是4096字节,包头解析限制包体最大65536字节。如果你的业务单条消息超过64KB,必须调整这个上限并注意缓冲区设计。实测中,局域网下5000条/秒的群发没问题,瓶颈基本不在Socket而在控制台日志输出——反复Console.WriteLine刷屏会把工作线程卡住。我处理过一个类似的项目,服务端每秒日志上千条,日志输出成了瓶颈,后来是把日志改成队列加异步批量落盘才解决。所以跑性能验证的时候,关掉Console输出或者用日志级别过滤掉INFO,再看群发吞吐,数字会明显提升。
用PowerShell完成100条消息循环发送并接收侧统计,收到99条或100条都在正常范围,如果收到90条以下就要回头查包体大小和缓冲区够不够——4096的缓冲区是按网络流读取的,不是按业务消息长度定的,业务消息超过4KB时Read可能只读了一部分就触发解析,这又回到第3.2小节的ReadFull问题:缓冲区长度设4096不代表一次只读4096,实际读多少由网络决定,解析逻辑只认包头里的长度字段。
6.5 验证收尾:一个习惯,应该强制自己走一遍
那次压力验证之后,我养成了一个习惯:每写一个Socket通信模块,不管服务端还是客户端,都必须完成一轮"双端断开恢复"测试。第一遍跑客户端重连逻辑,确认客户端重启后自动找回服务端;第二遍就地在cmd里执行netstat -ano | findstr 8888,检查有没有TIME_WAIT或者半关闭连接堆积;第三遍查CLOSE_WAIT的socket数量,如果大量出现CLOSE_WAIT,说明某个对端关闭了自己的发送通道却没关闭socket,典型的应用程序没有关闭TcpClient对象导致的。从那以后这个验证步骤一次不落,如果你要接这份资源的代码做二次修改,强烈建议把这三步也写进你自己的测试清单,能少踩很多现场才暴露的坑。希望帮到你。
本文还有配套的精品资源,点击获取