ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C#手搓TCP通信:从心跳包到群聊室,彻底搞懂Socket底层

C#手搓TCP通信:从心跳包到群聊室,彻底搞懂Socket底层 你是不是也遇到过这种场景两个程序明明用的是TCP连接日志里也没有任何报错但消息就是发不出去等你想起来去查的时候连接已经不知道什么时候“死”掉了。更头疼的是这种假死连接甚至对端已经断电拔网线了你这边的程序仍然傻傻地认为一切正常。这就是C#网络通信里最经典的坑——TCP不会主动告诉你对端已经走了它只在发送的时候悄悄失败一次然后就没有然后了。我当初在做一个工业上位机项目时被这个问题折磨了两天后来一咬牙决定把整个通信层重写从最原始的心跳包开始一路做到了一个能用的群聊室整个过程反而把网络编程的底层逻辑彻底搞通了。这篇文章就是那次重写的完整记录。我会从为什么不用SignalR这类现成框架讲起然后把TCP编程里最容易翻车的消息边界问题拆开揉碎再用心跳包解决“连接假死”这个老大难最后实现一个支持多人上线、广播消息、私聊的群聊室。如果你也是做上位机、写局域网工具、或者单纯想搞明白C#的Socket通信到底是怎么回事这篇文章应该能帮你少走很多弯路。我会尽量把代码控制在能直接抄作业的粒度同时把每个设计决定背后的理由说明白。1. 为什么要手搓在选SignalR之前先问自己三个问题我知道很多人看到这个标题的第一反应是C#做实时通信不是有SignalR吗WebSocket加一个中间件就能跑起来为什么还要手搓TCP这个问题我在动手之前也问过自己。SignalR确实是好东西自动处理连接管理、消息分发、重连这些脏活拿来写一个网页版的聊天室可能只需要半天。但我这次的需求场景比较特殊也推荐你在决定用不用现成框架之前先对照下面三个问题做个判断别盲目追新。第一个问题是运行环境。我做的是工业上位机方向的通信模块现场的设备往往部署在内网隔离区有些客户甚至不允许安装额外的运行时组件。SignalR跑在ASP.NET Core上光是治理依赖、配置中间件和跨域策略就要多出一堆东西在某些精简环境里根本推不动。而一个纯TCP服务端只要目标机器有.NET运行时就能裸跑连配置文件都可以不要对于“程序只要能跑就行”的现场需求来说非常省事。第二个问题是可控性。SignalR帮你封装好了连接生命周期可一旦出了问题排查链路会拉得非常长。你可能要先学习它的Hub协议、理解客户端和服务端之间的协商流程然后才能定位到到底是网络层的问题还是应用层的问题。而我手搓TCP的时候每一帧报文都是自己定义的每一段流的边界都是自己处理的出了问题直接在抓包软件里对着十六进制看就行排查路径短、定位速度快。第三个问题是教学价值。说句不好听的如果你连粘包拆包、心跳保活、连接池管理这些底层逻辑都没有亲手写过一遍那么用SignalR遇到问题你连提问都不知道怎么问。群里经常有人发“SignalR突然断线了怎么办”这种问题看似是框架用法问题本质上往往是没搞懂TCP的假死机制。把底层补上上层框架反而用得更顺。我不是劝你永远别用SignalR。恰恰相反等你自己手搓过一遍再回头看SignalR你会更理解它帮你省掉的到底是一堆无关紧要的模板代码还是真正复杂的边界场景。而且手搓出来的这个通信层并没有浪费后面如果真的要上生产级功能完全可以在它的基础上套壳改造或者直接把它当成分层架构里的传输层。所以结论是如果你时间紧张、目标是快速交付一个网页版聊天室请直接选SignalR如果你想要一个自己能完全掌控一切、还能揣进U盘到处跑的通信模块那手搓TCP这条路是值得走的。2. 地基TCP不是“发消息”而是“输水管”——流式协议的边界之痛在动手写代码之前有一个概念必须反复强调因为它是你后面所有Bug的根源TCP不是消息协议它把你写的所有字节都当成一个连续的流。你可以想象一根水管你往里面灌水对端就从另一边接水至于水到了那边怎么切分成一杯一杯的TCP自己根本不管。这也是“粘包”和“半包”问题产生的本质原因。很多人第一次写C#的Socket通信都会犯这个错误客户端调用一次Write发送了一个字符串就理所当然地认为服务端调用一次Read就能把整个字符串读回来。结果要么一次读到了两次发送的数据要么只读到了半截。这不是你的代码写得不对而是TCP的设计就是这样——它只负责把字节从左端运到右端并且保证顺序不乱但字节的边界完全由你自己定义。这种设计的好处是传输效率很高。如果TCP每次发送都严格按应用层的消息分帧它就没法做Nagle算法合并、也没法利用大包减少网络开销了。但代价就是应用层协议必须自己解决分帧问题。这也是我在这个项目里第一个动手要做的部件。// 最基础的服务端骨架先把TCP监听和接收跑起来 TcpListener listener new TcpListener(IPAddress.Any, 9000); listener.Start(); Console.WriteLine(Listening on 9000...); while (true) { TcpClient client await listener.AcceptTcpClientAsync(); _ Task.Run(() HandleClientAsync(client)); // 每个连接一个Task }这段代码本身没什么好说的TcpListener监听、AcceptTcpClientAsync接收连接、Task.Run开一个独立任务处处理这个客户端。但注意看我特意把“每个客户端一个Task”写出来了因为群聊室的核心就是把“单连接”变成“连接集合”这是后面所有功能的地基。再说说网络流的读写。服务端拿到TcpClient之后真正用来搬运数据的是client.GetStream()返回的NetworkStream。它和你在文件流上做读写的姿势是一样的但它有一个很扎心的特性Read方法返回值是0的时候代表对端已经正常关闭了连接。这个看似简单的返回值其实是判断连接是否正常断开的最可靠信号——比Socket异常捕获和心跳超时都要可靠因为没有异常的对端优雅退出一定会让你读到0。我后面在踩坑章节会再次强调这个点现在先记住。还有个很常见的误解我得在这里纠正一下很多人分不清TcpClient.Close()和NetworkStream.Dispose()的关系。TcpClient是一个高层次的包装类它内部持有Socket你调用Close()会把底层的Socket一并关闭所以如果只想临时停掉某一个方向的传输反而要操作TcpClient.Client.Shutdown(SocketShutdown.Send)。这个细节我放到最后的踩坑部分说这里先记一笔。3. 定协议长度前缀JSON先把粘包拆包这颗钉子钉下去知道了TCP是水管之后第一件事就是设计一套分帧方案。主流的做法大致有三种我在网上查资料的时候看到很多人各执一词这里用一张表直接对比清楚。方案原理优点缺点特殊分隔符消息末尾加\r\n或空行等标志实现极简单消息里不允许出现分隔符需要转义定长报文每条消息都是固定字节数解析快短消息浪费空间长消息无法承载长度前缀先读4字节长度再读指定长度的包体通用、灵活、可靠实现稍复杂要处理跨包的读操作这个项目我选了“长度前缀”方案。理由很简单群聊室的消息结构里有消息类型、发送者、内容、时间戳等多个字段长度不可能固定而用分隔符方案就得小心翼翼地对内容做转义处理太容易出漏子。长度前缀就是把“这条消息有多长”这个信息放在最前面对端先读长度再按长度读body正好把一条消息完整切出来。再往里面走一层body这位我直接用了UTF-8编码的JSON。可能有人会问你都手搓TCP了怎么还用JSON这种“重”格式不用二进制流因为群聊室的消息字段多而杂JSON在开发调试阶段能直接打印出来对着看等以后要优化性能再换成BinaryWriter也不迟。咱们这个项目的第一目标是逻辑正确不是把几个字节的省到极致。通信协议设计的核心原则就是先把功能搞稳定再考虑压缩。下面是我在项目里实际用到的消息类和封包拆包逻辑。public enum MessageType : byte { Ping 1, // 心跳请求 Pong 2, // 心跳响应 Login 3, // 登录/上线 Chat 4, // 聊天消息 Logout 5, // 退出 System 6 // 系统通知 } public sealed class NetMessage { public MessageType Type { get; set; } public string Sender { get; set; } // 昵称 public string Target { get; set; } // 为空代表广播 public string Content { get; set; } public long Timestamp { get; set; } }封包就是取body长度拼上头部。需要注意BitConverter.GetBytes拿出来的字节序在小端机器上是倒着的两边都是同一平台的话问题不大但如果你以后要和Java、Go之类的跨语言通信记得统一用网络字节序C#里对长度做一次IPAddress.HostToNetworkOrder转换就好。public static byte[] Pack(NetMessage msg) { byte[] body JsonSerializer.SerializeToUtf8Bytes(msg); byte[] header BitConverter.GetBytes(body.Length); return header.Concat(body).ToArray(); }但拆包这一步才是真正的重头戏劝大家不要天真地写一个ReadAsync就指望读完一条消息。正确姿势是维护一个接收缓冲区先把数据攒着每次只要够一个头部4字节就先解析出body长度再判断缓冲区里够不够整个body不够就继续等够了就切出来处理。这个“攒数据、判断够不够、再切片”的过程和你在ATM上取钱是一个道理余额不够就继续等工资到账到账了再一次性把钞票拿走。我在项目里是用MemoryStream配合自定义的取用逻辑做的代码大概长这样private readonly MemoryStream _buffer new MemoryStream(); private byte[] ReadMessageBody(NetworkStream stream) { // 把新收到的数据追加到缓冲区尾部 stream.CopyTo(_buffer); // 示意实际需要ReadLoop配合 // 先尝试取头部4字节 if (_buffer.Length 4) return null; byte[] lenBuf new byte[4]; _buffer.Position 0; _buffer.ReadExactly(lenBuf); int bodyLen BitConverter.ToInt32(lenBuf); if (_buffer.Length 4 bodyLen) return null; // 半包继续等 byte[] body new byte[bodyLen]; _buffer.ReadExactly(body); // 记得把已消费的数据清掉避免缓冲区无限增长 return body; }这里我故意简化了CopyTo那步因为真实项目里我会单独维护一个Listbyte或者直接做byte数组位移。核心逻辑就三件事先拼上来的新数据、判断长度够不够、消费掉已经解析完的字节。很多人写拆包时最容易忘的是处理完一条消息之后缓冲区里的“残留”——下一次Read的数据和上次没读完的数据很可能连在一起如果每次都从缓冲区头部重读就会把已经处理过的字节再解析一遍导致消息错乱。还有一个特别容易忽视的安全问题你收到的bodyLen是来自网络对端的如果对方恶意传一个int.MaxValue你的程序就会试图分配一个4GB的缓冲区直接内存爆炸。所以我在真实代码里一定会加一条校验如果bodyLen大于某个合理上限比如10MB直接抛出异常并断开连接。写协议处理的时候永远默认网络那头的输入是不可信的。4. 心跳包TCP没有“你死了”的通知只有我们自己想办法知道协议层搞定之后紧接着要解决的就是开头那个假死连接问题。我先把这个问题的机理说透TCP连接的建立靠三次握手关闭靠四次挥手但这些都是建立在双方都在正常收发数据的前提上的。真实世界里暴力断电、拔网线、路由器崩溃都可能让TCP连接在双方不知情的情况下进入“半开”状态。你这边以为连接还在其实对端早就没了。举个例子你和同事约好每天早上开个15分钟的碰头会但没人规定缺席怎么算于是某一天对方没来你也不知道该不该等只能干耗着。心跳包就是给这个碰头会加一个规则每隔几秒你喊一声对方应一声连续几声没应你就默认人没了该干嘛干嘛去。对于客户端连服务器的场景这个“客户端主动喊、服务端应一声”的模式也就是Ping/Pong。心跳包有两个作用别混为一谈。第一个作用是保活让中间的网络设备路由器、防火墙、负载均衡器看到这条连接还在活跃防止它们因为“闲置过久”而把连接干掉了。第二个作用才是断线检测让服务端能发现那些已经不存在的客户端及时把他们踢出会话列表、回收资源。很多人在写群聊或长连接服务时懒得加心跳等到线上出问题才后悔——服务器上积累了几百条僵尸连接内存和文件描述符被白白占着广播消息还会往死掉的Socket上写数据引发一堆毫无必要的异常。心跳的设计要决定三组参数客户端发送心跳的间隔、服务端判定超时的阈值、客户端主动放弃的阈值。我按经验把这几个参数的大致范围列在下面你可以根据自己的场景调整。参数推荐值说明心跳间隔3秒太频繁浪费带宽太迟钝发现不了问题服务端超时阈值12秒至少允许漏掉3个心跳容忍瞬时抖动客户端放弃阈值15秒超过这个时间既没收到Pong也没收到任何数据就重连最大包体限制10MB防内存攻击协议安全底线我这个项目里直接定了客户端每3秒发一个Ping服务端给回应一个Pong。服务端给每个会话记一个LastActive时间只要收到任何消息包括业务消息就刷新它。另有一个后台循环每5秒扫一遍所有会话发现某个会话的LastActive超过12秒没动过就判定为超时走断开流程。这里有个我自己一开始踩过的小坑只对Ping刷新LastActive结果一个客户端正常地在聊天但一直不触发心跳定时器愣是被踢下线了。真实项目中应该把“收到了任何有效数据”都当作活跃信号而不是只看心跳包。服务端踢人的代码逻辑可以简化成下面这样重点不在于代码本身而在于“定时扫描活跃时间刷新”这个双层结构。public class ClientSession { public TcpClient Client { get; } public string UserName { get; set; } public DateTime LastActive { get; set; } DateTime.UtcNow; }后台清理线程大致如此示意我用的是每5秒扫一次的简单循环while (_running) { await Task.Delay(TimeSpan.FromSeconds(5)); var now DateTime.UtcNow; foreach (var session in _sessions.Values.ToList()) { if ((now - session.LastActive).TotalSeconds 12) { // 拉黑并移除 Kick(session.UserName, 心跳超时); } } }这里面一个很容易被忽视的细节是_sessions.Values.ToList()。如果你直接在遍历ConcurrentDictionary的同时调用Kick去移除元素就会撞上“集合已修改”的异常。先用ToList()拍一张快照再去遍历和操作是这方面最省心、最稳妥的写法。再来说说客户端这边的重连策略。很多人的第一版客户端是发了一个Ping等了一会儿没有回应就直接放弃。实际上网络抖动非常常见偶尔丢包不代表服务端挂了。所以正确的做法是连续几次没有回应才认为连接断了。我项目的做法是如果客户端在15秒内既没收到Pong也没收到任何来自服务端的业务消息就主动关闭当前连接进入断线重连流程。重连这步至少要做一点间隔退避不要死循环一秒重试一次把服务器打爆了。简单暴力的指数退避就能用第一次等1秒、第二次2秒、第三次4秒封顶到10秒。下面这段是客户端心跳循环的核心我把它做成了一个单独的任务和业务收发任务互不干扰。private async Task HeartbeatLoopAsync() { while (_connected) { try { await SendAsync(new NetMessage { Type MessageType.Ping }); } catch (Exception ex) { Console.WriteLine($心跳发送失败: {ex.Message}); break; // 一般来说发送失败就说明连接废了 } await Task.Delay(TimeSpan.FromSeconds(3)); } }心跳发送失败基本就等于连接已经坏了这时候再纠结重试没有意义直接进入重连流程才是对的。我见过很多人把重试写在发送循环里面结果一边断线一边疯狂抛异常日志刷几百行这就是没想明白“发送失败意味着连接不可用”这个底层逻辑。想明白这一点你的心跳代码会简洁很多。5. 群聊室从单聊到广播消息路由和会话管理才是重头戏心跳解决了连接健康问题接下来就可以安心做业务功能了。群聊室本质上是一个“消息路由器”客户端把消息发给服务器服务器看一眼消息类型决定是回一个响应、广播给所有人、还是只转给某一个人。整个过程比很多人想象中的“for循环遍历连接发一下”多一层线程安全的考虑但也仅限于此并没有想象中那么玄乎。先把服务端的核心数据结构立起来一个用户名到ClientSession的映射。因为是多线程环境多个客户端同时登录、退出、发消息直接用普通的Dictionary是危险的所以我用了ConcurrentDictionarystring, ClientSession。这个集合能保证并发读写不会把内部结构搞坏但要注意它只能保证单次操作的原子性不能保证“先判断存在再操作”这个复合逻辑的原子性这种场景还是得靠lock或者更高级的并发原语。private readonly ConcurrentDictionarystring, ClientSession _sessions new();登录和退出的逻辑很简单但里面有一个顺序问题值得注意。新用户进入聊天室时我的做法是先把用户加入_sessions然后立刻发送一条系统消息通知所有人“某某已上线”。为什么不是先通知再入集合因为如果先广播“某某上线”自己还没进集合别人这个时候给他发私聊就会查不到他。先入集合再广播虽然也可能有极短的时间窗口冲突但顺序上更安全。case MessageType.Login: session.UserName msg.Sender; bool added _sessions.TryAdd(msg.Sender, session); if (added) { await BroadcastAsync(new NetMessage { Type MessageType.System, Content ${msg.Sender} 加入了群聊 }); } else { await SendToAsync(session, new NetMessage { Type MessageType.System, Content 昵称已被占用请换一个 }); session.Close(); } break;广播是群聊里最频繁的操作也是最容易踩并发坑的地方。我最终的实现是遍历_sessions的快照对每个会话调用SendDataAsync。这里有一个很重要的取舍要不要在一个客户端发送失败时直接把它踢下线我的答案是“要”。因为对TCP来说一次写入失败通常意味着连接已经或者即将报废继续维护它只会让之后的广播流程反复撞墙。所以广播的时候遇到发送失败的客户端我会把它的异常记录下来、从会话列表里移除然后继续往下一个会话发送。这样单个僵尸连接拖慢所有人的问题就解决了。public async Task BroadcastAsync(NetMessage message) { byte[] data Pack(message); var snapshot _sessions.Values.ToList(); foreach (var session in snapshot) { try { await session.SendAsync(data); } catch (Exception ex) { Console.WriteLine(${session.UserName} 发送失败移除: {ex.Message}); _sessions.TryRemove(session.UserName, out _); } } }私聊比广播简单只需要从_sessions里查出目标用户然后单独写过去。但这里有个判断要处理目标用户不在线的时候怎么办我选择的是给发送者回一条系统消息说明“用户不存在或不在线”而不是直接吞掉异常。这样用户体验会好很多也方便客户端做提示。消息路由代码看起来像一个大switch-case但注意到我这儿是按照MessageType分发到不同的处理方法上的。这种做法简单直观适合第一版实现。如果你以后要在这个基础上做大可以考虑用策略模式或者把每个类型单独封装成一个Handler但第一版千万别过度设计——先把能跑通的逻辑写出来再考虑架构优雅性。最后是整个服务端的一个关键入口每个客户端的ReadLoop需要一个死循环去读消息循环退出条件就是ReadAsync返回0或者抛异常。这个循环是整个聊天室的发动机它负责把网络字节流变成一条条NetMessage交给路由器。里面要特别注意每次收到消息后刷新LastActive也就是把上一章心跳设计和业务通道打通。6. 实测翻车记录三个最容易出Bug的地方我都替你踩过了代码写完进测试的时候才是真正开始长经验的时候。我从这个项目里提炼出三个最常见的翻车点每个都花了很长时间才定位写在这里能帮你少走几小时弯路。第一个翻车点就是上一节提过的“集合并发修改”。我一开始天真地以为用了ConcurrentDictionary就万事大吉结果在广播里直接foreach (_sessions.Values)里面又调用TryRemove把掉线用户踢掉运行时直接抛InvalidOperationException。后来我把遍历改成先Values.ToList()再循环问题才消失。这个坑很多人都会踩特点是随机出现、非常难复现我那次是在压测到第200多个并发连接时才突然爆出来的。教训就一句话在C#里任何“边遍历边修改”的集合操作都要先用快照隔离别管你是Dictionary还是List。第二个翻车点是发送和接收没有分离导致同一时刻多个线程往同一个NetworkStream写数据。群聊室广播的时候可能多个业务线程同时对一个客户端连接执行SendAsync这就可能造成数据交织写入对端读到的消息就花掉了。我一开始不知道为什么对端偶尔收到乱码抓包才发现同一个连接的写请求被多个Task同时执行了。解决办法是给每个会话挂一把发送锁或者维护一个发送队列由专门的一个发送线程消费。我最终选择了更稳的发送队列方案每会话一个Channelbyte[]发送线程逐个写入这样还能避免锁粒度太大。如果你图省事先加个lock也能解决绝大多数问题但要注意async/await在lock里的用法很受限建议直接用信号量SemaphoreSlim。第三个翻车点最隐蔽客户端正常退出以后服务端的清理顺序搞反了。如果你先关闭了TcpClient再广播通知其他人“某某下线”自己这个会话还没从_sessions移除其他人那边显示的成员列表可能就会有短暂的不一致。更严重的是如果在关闭之后再试图给这个断开的会话发什么系统消息就会抛一次无意义的异常。正确顺序是先移除会话、再广播通知。我当时就因为顺序反了连着看了三天的错误日志才弄明白这一行代码的差别。除了上面三个还有一个和TCP本身相关的冷知识也值得作为警钟敲一下NetworkStream.ReadAsync返回0这件事是TCP在正常关闭时对端主动FIN包的结果。如果你发现服务端读循环一直卡在ReadAsync不出来那不是没数据而是说明你在连接关闭的处理上做得不对——最常见的情况就是忘了判断返回值0。我见过很多新手写的读循环长这样while (true) { int read await stream.ReadAsync(...); ... }结果客户端都关了半天了服务端还挂着死等。正确写法一定是要判断read 0就跳出循环并清理会话。7. 还能怎么升级给这份手搓代码留几条正式的进化路径写到这儿整个项目已经能跑起来了服务端监听、客户端登录、心跳保活、群聊广播、私聊、掉线清理一个完整的C#网络通信小闭环已经成型。但我自己很清楚这个版本距离“可以拿到生产环境去炫耀”还差好几步所以最后聊聊它正式的几条进化路径。第一条是序列化升级。目前用JSON编码相当于用空间换开发效率跑在局域网里毫无压力但如果你要部署到带宽紧张或大消息量场景就应该换成二进制序列化甚至直接在字节流里写字段。二进制的好处是不仅体积小而且解析快很适合那种每秒几千条高频消息的网关服务。换序列化的时候注意协议版本号要留着否则老客户端和新服务端一混合通信立刻出乱子。第二条是全面async/await化。我这个项目在部分场景仍然用了传统的同步阻塞风格原因是逻辑直观、排错简单能应付几百个连接的小型聊天室。但如果你的目标是承载几千个长连接就要重新设计I/O模型把Socket层面彻底改成异步不要使用“一个客户端一个Task”的朴素模型否则线程上下文切换的开销就会成为瓶颈。真要走到那一步还要考虑把连接管理、消息队列、分布式部署分开做架构设计那已经不是一篇文章能讲完的了。第三条是断线重连的智能化。我目前用的是固定间隔加指数退避够用但不优雅。更高级的做法是客户端在重连时带着自己的用户名和一个重连令牌去请求服务端服务端能判断出“这是同一台机器的原会话”直接把之前未读的消息补发给它这样用户体验会好很多。这个功能在聊天室应用里挺重要毕竟现实用户不会容忍掉线一次就丢消息。第四条是加密通道。如果聊天室要跑在不安全的网络上明文JSON就等于裸奔。用TCP的时候可以在应用层叠加TLS或者更简单粗暴地给消息体做AES加密。别把加密想得太复杂核心就两件事密钥怎么协商、消息体怎么在传输前加密/传输后解密。协议里加一个SecurityType字段以后升级也方便。要说我这套代码最值钱的收获其实不是聊天室本身而是终于把“TCP连接抽象成可靠连接”这件事彻底想明白了。TCP名义上是可靠的但它的可靠性是有条件的——你要自己处理消息边界自己处理连接假死自己在并发访问时保证安全这些东西教科书上都会讲但真正上手写一遍才会变成肌肉记忆。我后来做上位机通信和物联网网关时碰到任何断线问题第一反应都是直接想心跳、想重连策略而不是像以前那样在业务代码里反复打日志猜原因。这就是手搓一遍底层的价值。
返回列表