ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C# TCP服务端开发实战:TcpListener与异步拆包避坑指南

C# TCP服务端开发实战:TcpListener与异步拆包避坑指南 简介这是一份基于C#语言实现的TCP通信服务器端示例程序面向.NET开发者或正在学习Socket编程的初学者用于演示服务端监听、客户端接入以及数据收发的完整流程。主要源码包括Program.cs、TCPServer.cs、P2PServer.cs等可帮助理解TcpListener、TcpClient或Socket的核心用法也能作为课程设计或简单通信项目的改造基础。压缩包共26个文件大小约50KB包含7个cs源文件、3个可直接运行的exe以及配套pdb调试文件并附带csproj、sln等工程文件既能在Visual Studio中直接打开编译也可运行可执行程序快速查看效果轻量实用且便于二次开发。目前已有344人学习下载适合需要快速掌握C# TCP服务端编写思路的读者。源码整体结构清晰覆盖了服务器初始化、多客户端连接管理等关键环节工程目录简洁适合对照代码理解网络编程细节。1. C# TCP Server 不是只监听一个端口先搞清楚它到底要扛住什么在 C# 上位机、工业网关和物联网接入场景里TCPServer 几乎是绕不开的一块硬骨头。很多人以为写一个服务端就是拿到 Socket 后 Accept 一下、Read 一下结果一到现场就是粘包、半包、端口被占、客户端断开了程序却不知道整个连接管理黑得像个黑匣子。我曾见过一个同事用同步阻塞方式同时接收 20 台设备数据CPU 占用不高但连接动不动卡死最后查下来是接收缓冲区和拆包逻辑写反了。这篇就以一个可在本地跑通的最小 C# TCP 服务端为起点把 TcpListener 和 Socket 的选型、接收循环、异步回调、异常识别、踩坑记录都过一遍。适合刚接触 socket 网络编程的人跟着把代码敲通也适合已经能跑通 demo、但上线前想排查边界问题的工程师。2. 在 TcpListener 与 Socket 之间做选择服务器端程序的第一步选型做 TCP 服务器端程序第一件事不是写 Accept而是想清楚你的服务端程序到底属于哪种连接模型。TcpListener 是 Socket 的薄封装它把 Bind、Listen、Accept 这三步压缩成了几个方法方便是真方便可一旦进入生产环境你要面对的 TIME_WAIT、半开连接、端口复用这些底层问题封装类并不会帮你抹平。所以选型的关键不是我更熟哪个类而是连接数、数据包形态、以及你能接受的调试成本。选型逻辑我一般看三点。第一是并发规模设备数量少于 20 台、交互不密集的时候同步阻塞代码最简单排查问题也直观超过 50 台以后再同步阻塞线程开销和调度延迟会让你怀疑人生。第二是数据交互频率高频小包更适合异步回调加缓冲区队列低频大包反而同步更顺手。第三是排错成本TcpListener 上手快但出问题后你仍然要回到底层 Socket 的错误码上所以动手前把 SocketErrorCode 的常用枚举过一遍后面省很多事。2.1 同步还是异步第一次做 C# TCP 服务器最容易犯的决策同步代码长得很友好一个 while 循环里 AcceptTcpClient拿到 TcpClient 后直接 Read把数据处理完再回来 Accept 下一个。问题就在于 Read 是阻塞调用只要缓冲区里没有数据当前线程就挂在那里。你以为程序在正常等待实际上第一个客户端连进来之后如果它一直不发数据整个 Accept 循环都被卡住后面的客户端全在握手队列里排队表现就是服务端能连通、但只有第一台设备能用。常见的两种修法都很直接一种是为每个客户端 new 一个 Thread 或 Task.Run 去处理简单粗暴几十个连接勉强能跑另一种是换成异步回调让接收动作不独占任何线程。我见过太多人用第一种方式上线因为单个设备调试确实没问题等设备数量加到 100 台线程上下文切换带来的 CPU 毛刺就非常明显。说到底异步模型不是炫技它解决的是连接数增加时线程数不跟着线性爆炸这个实际问题。以 C# 上位机的典型场景来说设备一般发小包包长几十字节到几 KB一台网关可能要同时跟十几台上位机收发。这种场景下异步回调的价值在于你不用为每条连接分配一个常驻线程而是让操作系统在 Socket 有数据时通过回调告诉你。回调之间的并发管理是另一门课下面 4.2 会专门讲 buffer 的归属问题因为异步代码最容易踩的坑就是那个回调共享一个缓冲区。2.2 用 TcpListener 搭出最小可运行的监听骨架先看一个能跑起来的最小服务端。它监听本机 9000 端口把收到的 UTF-8 文本打印到控制台。这段代码故意保持简单用来演示 TcpListener 的基本链路。using System; using System.Net; using System.Net.Sockets; using System.Text; class SimpleTcpServer { private readonly TcpListener _listener; public SimpleTcpServer(int port) { // 监听本机所有网卡的 port 端口 _listener new TcpListener(IPAddress.Any, port); } public void Start() { // backlog 100 表示内核里最多排队 100 个未处理的连接请求 _listener.Start(100); Console.WriteLine($TCP 服务已启动: {_listener.LocalEndpoint}); while (true) { // AcceptTcpClient 阻塞当前线程直到有客户端连入 TcpClient client _listener.AcceptTcpClient(); Console.WriteLine($新连接: {client.Client.RemoteEndPoint}); HandleClient(client); } } private void HandleClient(TcpClient client) { using (client) { NetworkStream stream client.GetStream(); byte[] buffer new byte[4096]; int readCount; // Read 返回 0 表示客户端正常关闭连接 while ((readCount stream.Read(buffer, 0, buffer.Length)) 0) { string text Encoding.UTF8.GetString(buffer, 0, readCount); Console.WriteLine($收到 {readCount} 字节: {text}); } Console.WriteLine(客户端已断开); } } public static void Main() { new SimpleTcpServer(9000).Start(); } }IPAddress.Any 绑定的是所有网卡这样无论客户端从以太网还是从回环地址连入都能被监听到如果你只想本机调试改成 IPAddress.Loopback 可以少弹一次防火墙授权窗口。Start(100) 里的 100 是 backlog它表示内核协议栈最多替应用排队 100 个还没被 Accept 的连接。这个值不是越大越好每个排队连接都会占内核内存设成几千对单机服务端没有意义。HandleClient 里的逻辑看着是通的实际有两个隐患。第一这个 Read 循环是逐帧处理直接把字节转字符串在 TCP 流式协议里其实拿不到帧的概念收到的很可能只是半条消息第二HandleClient 是顺序执行的第一个客户端不断发数据时后面的客户端根本进不了 Accept。这也是为什么我说这段代码只能当骨架看真正的 TCPServer 至少要走到下一章先拼缓冲再解析这一步。2.3 什么时候该从 TcpListener 换到原生 SocketTcpListener 能做的只是把 Socket 包了一层让你少写几行 Bind 和 Listen但它没有解决连接生命周期的精细控制。当你需要给每个 Socket 设置 KeepAlive、调整 ReceiveBufferSize、控制 NoDelay或者想按客户端 IP 做流量整形时直接用原生 Socket 更顺手。TcpListener.AcceptSocket() 返回的本来就是 Socket两个类可以在同一个程序里混用没有非黑即白的界限。我常用的判断表是这样的判断维度TcpListener原生 Socket上手成本低适合原型验证高需要自己管理更多细节连接管理每连接一个 TcpClientAccept 后直接拿到 Socket异步接收靠 NetworkStream 的异步方法BeginReceive / ReceiveAsync 更直观控制能力一般完整访问 SocketOption典型适用内网小规模、功能验证多连接生产网关、上位机服务实际项目里我通常先用 TcpListener 把协议调通等确认业务逻辑没问题、连接数上来了再把接收层替换成原生 Socket 的异步模型。替换成本不高因为拆包和业务处理逻辑可以完全复用。有一点要提醒无论用哪个类监听端口前都要先确认端口没被占用。命令行 netstat -ano | findstr 9000 是最快的检查方式找到 LISTENING 的 PID 后去任务管理器看清楚是不是自己的旧进程。这个坑后面还有专门一章展开。3. 把 TCPServer 从能连通改成能干活数据边界、拆包与断开识别到这一步监听早已不是问题真正的问题是收到了数据却拼不出完整消息。TCP 是流协议它只保证字节顺序不保证消息边界。一次 Send 可能被拆成两次到达两次 Send 也可能合并成一次读出这就是俗称的粘包和半包。很多人第一次写 C# 服务端就在这里翻车拿到的 JSON 少了一半、或者两个命令黏在一起。我的习惯是拆包逻辑先于业务逻辑写好因为服务端几乎所有后续处理都依赖拿到完整的一帧。3.1 为什么 TCP 没有消息边界粘包、半包从哪来TCP 协议栈把数据看作一串没有边界的字节流它根据 MSS、窗口大小和网络拥塞状态决定怎么分段发送。当客户端连续 Send 两条消息内核可能把两条消息合并成一个 TCP 段发出去也可能因为 Nagle 算法把多个小段攒在一起反过来一条 10KB 的消息因为接收缓冲区只有 4KB必须分三次读完。这两种情况是 TCP 的内生行为不是 bug。所以服务端绝对不能每次都把 Read 到的字节直接当一条完整消息处理。正确做法是准备一个接收缓冲区把每次 Read 的数据 Append 进去然后检查里面够不够一帧够就解析、不够就继续等。这个模式叫累积缓冲也叫按长度拆包是 TCP 服务端最基础的设施。如果你用的协议没有长度字段也可以按分隔符拆比如常见的 \r\n 或自定义 0xFF 结束符但二进制协议里分隔符容易和正文冲突我一般倾向包头带 4 字节长度字段。3.2 在 TCPServer 里按长度拆包四字节长度头的解析器下面这个 RecvBuffer 是一个可供直接抄的拆包器。协议约定每个数据帧的前 4 字节是 little-endian 的 int32 长度紧接着是 payload。客户端发送时必须严格按这个结构组包。// 每个客户端连接对应一个 RecvBuffer 实例 class RecvBuffer { private readonly byte[] _buffer new byte[64 * 1024]; private int _dataLength 0; public void Append(byte[] segment, int length) { // 如果缓冲剩余空间不够说明对端违反了长度约定 if (_dataLength length _buffer.Length) { throw new InvalidOperationException(接收缓冲溢出请检查客户端组包逻辑); } // 把新收到的字节拼到缓冲区尾部 Array.Copy(segment, 0, _buffer, _dataLength, length); _dataLength length; } public bool TryParseFrame(out byte[] frame) { frame null; // 连 4 字节长度头都没收全继续等 if (_dataLength 4) return false; // 读取长度字段小端序 int frameLength BitConverter.ToInt32(_buffer, 0); // 长度非法说明解析状态已经错位工程上应断开连接 if (frameLength 0 || frameLength _buffer.Length - 4) { throw new InvalidOperationException(帧长度非法); } // payload 还没到齐 if (_dataLength 4 frameLength) return false; frame new byte[frameLength]; Array.Copy(_buffer, 4, frame, 0, frameLength); // 已消耗部分移除未处理数据前移 int remain _dataLength - 4 - frameLength; if (remain 0) { Array.Copy(_buffer, 4 frameLength, _buffer, 0, remain); } _dataLength remain; return true; } }接收循环里这样用RecvBuffer recvBuffer new RecvBuffer(); byte[] tempBuffer new byte[4096]; int readCount; while ((readCount stream.Read(tempBuffer, 0, tempBuffer.Length)) 0) { recvBuffer.Append(tempBuffer, readCount); while (recvBuffer.TryParseFrame(out byte[] frame)) { // 到这里拿到的就是完整一帧交给业务处理 string message Encoding.UTF8.GetString(frame); Console.WriteLine($完整消息: {message}); } }逻辑说明Append 只负责把新数据堆到缓冲区尾部不做解析TryParseFrame 每次检查缓冲区内有没有完整帧有就取出、并前移剩余字节。这个累积再前移的过程把半包问题完全挡在业务之外。参数上缓冲区 64KB 是按单帧最大长度设的超过就说明对端发来超长帧或解析错位直接抛异常断开连接比硬撑更安全。BitConverter.ToInt32 默认按小端序解析正好对上大多数 ARM 嵌入式设备的组包习惯。如果你的客户端用的是大端序这里要先 Reverse 再解析不然后果是帧长度变成一个超大整数直接走进异常分支。这个细节不显眼实测最容易出问题。外层 while 里嵌套 while 的写法有一个好处一次 Read 到的 1024 字节里可能包含 3 个完整帧也可能只有半帧内层 while 负责把能解析的帧全部消费掉解析不了的残留留在缓冲里等下一批数据。还有一点必须记住Read 返回 0 是客户端正常关闭的信号要立刻退出循环不要继续 Append。3.3 客户端断开时三类异常与一个救命的 Finally客户端断开在 TCP 里远不止一种表现。正常挥手时Read 返回 0对端网线被拔或程序崩溃内核会发 RSTRead 抛 SocketException错误码是 ConnectionReset服务端自己 Close 了 Socket未完成的读取会抛 ObjectDisposedException。如果把这三类混在一起 catch你就分不清连接是正常结束还是异常掉线。我的标准写法是这样try { while ((readCount stream.Read(tempBuffer, 0, tempBuffer.Length)) 0) { recvBuffer.Append(tempBuffer, readCount); while (recvBuffer.TryParseFrame(out byte[] frame)) { // 业务处理 } } Console.WriteLine(对端正常关闭); } catch (SocketException ex) when (ex.SocketErrorCode SocketError.ConnectionReset) { // 对端发 RST最常见于客户端拔网线、进程崩溃 Console.WriteLine(客户端异常复位连接); } catch (ObjectDisposedException) { // 服务端主动 Close 时等着接收的 IO 会走这里 Console.WriteLine(Socket 已被主动关闭); } finally { // 无论哪条路径走到这里连接都已不可用 client.Close(); }Socket 对象的 Connected 属性是出了名的不可靠它只反映最后一次 IO 的状态不能用来判断连接是否活着。判断连接是否有效的唯一方式是实际读写。我建议把 finally 里的 Close 当成铁律catch 里打了日志就完事、忘了释放 Socket 的后果是明明重启了程序端口还是被占着这就是 TCP 里 TIME_WAIT 在作祟。4. 撑起多连接的关键把 TCPServer 改成异步 BeginReceive 模型单连接的 TCPServer 没有性能可言真正难的是同时挂 50 台设备还能稳定收数。同步阻塞模型的问题在于线程被 IO 挂起而线程一旦多起来调度开销和内存占用同时恶化。到这一步我一般是直接砍掉同步读全部换成 BeginReceive 回调模型。它不省代码量但省线程。4.1 同步阻塞为什么扛不住多客户端线程是排队买票的窗口想象一个窗口只有一个售票员Accept 是叫号Read 是等客户递钱。同步模型里处理 a 客户端数据的整个时间段内售票员眼睛只能盯着 ab、c、d 只能排队。改成每连接一个线程后相当于给每个客户配一个售票员50 个客户就是 50 个线程1000 个客户机器就要先被线程栈吃掉一大块内存。更隐蔽的问题是线程切换。每个线程在自己的时间片里执行一点代码就被切走100 个线程同时等待网络数据操作系统频繁做上下文切换CPU 波形会呈现锯齿状。我见过一个生产网关设备量加到 80 台后 CPU 使用率 60%但连接数并不高最后把同步 Read 换成异步回调CPU 直接掉到 8%。原因不是业务计算量变了而是不再有线程在空等 IO。4.2 BeginReceive 的最小可运行写法回调、State、链式接收BeginReceive 的标准用法是注册回调IO 完成后回调在线程池线程上触发。你必须在回调里立刻再次调用 BeginReceive否则这条链就断了数据流停摆。每个客户端必须有一个独立的 state 对象里面保存它自己的 Socket、接收缓冲区和拆包器绝不能用同一个字节数组给所有客户端共享。// 连接状态对象一个客户端一个实例 class AsyncClientState { public Socket Socket; public byte[] Buffer new byte[4096]; public RecvBuffer RecvBuffer new RecvBuffer(); } void BeginAccept() { _listenSocket.BeginAccept(OnAccept, null); } void OnAccept(IAsyncResult ar) { Socket clientSocket _listenSocket.EndAccept(ar); Console.WriteLine($连接接入: {clientSocket.RemoteEndPoint}); // 每个连接独立的状态实例 AsyncClientState state new AsyncClientState { Socket clientSocket }; TryBeginReceive(state); // 关键: 必须再次 BeginAccept否则后续连接进不来 BeginAccept(); } void TryBeginReceive(AsyncClientState state) { try { state.Socket.BeginReceive( state.Buffer, 0, state.Buffer.Length, SocketFlags.None, OnReceive, state); } catch (SocketException ex) { Console.WriteLine($接收启动失败: {ex.SocketErrorCode}); state.Socket.Close(); } }接收回调里拿到字节数后先把数据交给拆包器再继续注册下一次接收void OnReceive(IAsyncResult ar) { AsyncClientState state (AsyncClientState)ar.AsyncState; try { int bytesRead state.Socket.EndReceive(ar); if (bytesRead 0) { state.RecvBuffer.Append(state.Buffer, bytesRead); while (state.RecvBuffer.TryParseFrame(out byte[] frame)) { // 完整帧交给业务层 string message Encoding.UTF8.GetString(frame); Console.WriteLine($完整消息: {message}); } // 关键: 链式续接让下一个数据到来时有回调可触发 state.Socket.BeginReceive( state.Buffer, 0, state.Buffer.Length, SocketFlags.None, OnReceive, state); } else { // bytesRead 为 0说明对端正常关闭 state.Socket.Close(); } } catch (ObjectDisposedException) { // Socket 已在其他路径被关闭 } catch (SocketException ex) { Console.WriteLine($接收失败: {ex.SocketErrorCode}); } }这里最需要注意的是 state.Buffer 的复用。BeginReceive 注册时传入的 Buffer和 EndReceive 实际填充的是同一个数组。如果业务处理耗时较长而数据量又大下一次 BeginReceive 可能会在同一块 Buffer 还没被消费完时就把新数据写进去。所以拆包器里的 Append 是立即拷贝进内部累积区的这个拷贝动作必须在当前回调里同步完成不能把 Buffer 引用直接丢给别的线程。回调里解析出来的 frame 是一个新分配的数组和 Buffer 无关可以放心把它交给后台线程。如果图省事直接传 state.Buffer后面收到的数据会覆盖掉它你处理到一半的数据内容就莫名其妙变了。4.3 多连接管理用 ConcurrentDictionary 兜住每个客户端的生命周期异步回调是多线程并发的注册连接和处理断开的代码会同时跑在不同线程池线程上。这时候再用普通 Dictionary 存连接轻则丢数据重则直接抛“集合已修改”的异常。我的做法是引入 ConcurrentDictionary以连接 ID 为键值就是那个 AsyncClientState。ConcurrentDictionarystring, AsyncClientState _clients new ConcurrentDictionarystring, AsyncClientState(); void RegisterClient(Socket clientSocket) { AsyncClientState state new AsyncClientState { Socket clientSocket }; string id Guid.NewGuid().ToString(N); _clients[id] state; TryBeginReceive(state); } void RemoveClient(string id) { if (_clients.TryRemove(id, out AsyncClientState state)) { state.Socket.Close(); } }连接 ID 用 GUID 字符串是为了避免客户端 IP 加端口做键的坑同一台设备重连后端口会变同一个键可能指向过期连接导致状态互相覆盖。RemoveClient 在 finally 里调用保证无论正常关闭还是异常抛错字典里都不会堆积死连接。到这里这个 TCPServer 才算具备基本的生产形态异步监听、异步接收、按长度拆包、连接字典收尾。再往后就是本章之外的运维问题了比如怎么感知死连接、怎么控制重启时的端口复用。5. 避坑C# TCP 服务端开发中最常见的 5 个翻车现场这一章是血泪经验合集。每一个都是我自己或在同事机器上见过的问题写下来之前又把关键复现了一遍。所谓踩坑坑就坑在现象都特别像程序正常就是偶尔出问题。5.1 启动时报“每个套接字地址(协议/网络地址/端口)只允许使用一次”现象程序重启后 Bind 抛 SocketException错误码 10048端口被占用或者第一次能启动但 CtrlC 强制结束后立刻重启就起不来。原因TCP 断开后连接会进入 TIME_WAIT 状态内核默认要等 2 到 4 分钟才释放端口。如果服务端是被强杀或者有旧连接没被 Close端口就仍被挂着。还有种常见情况是上次运行的程序进程没退干净后台还站着一个僵尸进程在监听。解决先用netstat -ano | findstr 端口号看是谁占着端口确认 PID 后把旧进程结束掉。如果是频繁重启的开发机可以在服务端 Bind 之前设置 SO_REUSEADDR 套接字选项让端口能被快速复用_listenSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); _listenSocket.Bind(new IPEndPoint(IPAddress.Any, 9000)); _listenSocket.Listen(100);注意SO_REUSEADDR 解决的是 TIME_WAIT 导致的绑定失败不是让你在同一个端口上同时起两个服务端。两个进程都设 ReuseAddress 去 Bind 同一个端口结果仍然是后一个失败别在这个问题上绕弯。5.2 客户端一次发送两条消息服务端收到一条拼起来的脏数据现象客户端连续发两条 JSON服务端只收到一条内容成了{a:1}{a:1}这样的拼接体有时又反过来一条长消息被拆成两半。原因TCP 流没有消息边界Nagle 算法和内核缓冲会任意合并、拆分数据。这是协议设计问题不是代码 bug。解决必须在应用层定义边界。常见做法有两种一是每条消息末尾加分隔符例如 \r\n二是在消息头放长度字段。我在 3.2 里的 RecvBuffer 就是按长度拆包的实现。如果你已经在用分隔符方案注意消息正文里不能出现相同的分隔符否则要把内容做转义或改用长度头。5.3 客户端断电后服务端 Read 一直挂着不返回现象客户端是工控设备现场工人直接断电。服务端这边连接看起来还在RecvBuffer 没有异常也没有收到 0 字节就那样永远等下去。原因TCP 断电磁落后对端没有机会发 FIN 或 RST服务端永远不知道链路已经死了。协议栈在没有数据收发时根本不会主动探测。解决两条路要同时走。第一是启用 Socket 的 KeepAlive设置 KeepAliveTime 和 KeepAliveInterval让协议栈定期发探测包第二是应用层心跳双方约定每 N 秒发一个心跳帧服务端记录每个客户端最后一次心跳时间超过阈值就主动 Close。心跳帧通常是个固定字节序列比如 5 字节0xAA 0x55 0x00 0x00 0x01收到后不回业务数据只表示“我还活着”。5.4 异步回调里做了耗时操作后来收到的数据被覆盖现象数据量稍大后业务处理出现 A 客户端的内容里混着 B 客户端的内容或者一帧被拆成两帧处理。原因我在 4.2 提到的 buffer 复用问题。多位 Beginner C# 开发会把回调里的 state.Buffer 直接传给业务线程下一次 BeginReceive 在旧数据还没读完时又把新数据写进同一块数组于是数据就串了。解决回调里唯一允许做的是把数据拷贝到 RecvBuffer 的独立累积区然后尽可能快地再次调用 BeginReceive。耗时业务交给线程池或独立队列不要再持有 state.Buffer 的引用。我一般会约定一条铁律接收缓冲区只属于接收线程谁都不能碰业务层永远看不到它。5.5 在回调里直接更新 WinForms/上位机界面UI 直接崩现象TCPServer 跑在 WinForms 上位机里收到数据后想往 TextBox 追加日志程序在回调里抛 InvalidOperationException提示“线程间操作无效”。原因BeginReceive 回调在 IO 线程池上执行不在 UI 线程上。WinForms 控件只能由创建它的 UI 线程访问跨线程更新控件是非法操作。解决用 SynchronizationContext 或 Control.Invoke 把更新动作切回 UI 线程最稳妥的是在服务启动前捕获主窗体的 SynchronizationContext 实例收到完整帧后 Post 过去SynchronizationContext _uiContext; // 在 UI 构造函数里捕获 _uiContext.Post(_ { textBoxLog.AppendText(message Environment.NewLine); }, null);这比直接调用this.Invoke更干净因为它不依赖具体控件也方便在服务类是独立类时用。另一种思路是把收到的事件发布到事件总线UI 订阅后自己去 Invoke耦合会小很多。6. 最后一个技巧心跳、断线重连与 TimeWait 的收尾一个能上线的 C# TCP 服务端光能收发数据还不够还得能扛住网络不稳定和进程频繁重启。这里有一个我常用的组合拳应用层心跳定期扫活、服务端主动踢掉僵尸连接、以及 SO_REUSEADDR 应对快速重启。心跳扫活的做法是维护一个 Dictionary 记录每个连接的最后活跃时间戳收到任何一帧都更新用一个 Timer 每 10 秒遍历一次超过 30 秒没有数据的连接直接 Close 并从字典移除。这个时间阈值要按业务定设备是每 5 秒上报一次数据那 30 秒才判定死亡是合理的如果业务本身就不常发数据就不能依赖业务帧做心跳必须单独发心跳包。两种方式可叠加核心逻辑只有一个没有活跃就必须清理。断线重连是客户端的责任服务端要做的是让重连尽量顺利。TCP 里连接关闭后会有 TIME_WAIT 状态服务端程序重启后原端口还没被内核释放Bind 就会失败。要么等服务状态超时要么用第 5 章讲过的 ReuseAddress。我自己的习惯是服务端在 Main 里启动前就设好 ReuseAddress而不是等启动失败被运维骂了再去补。另外有一个被忽略的细节心跳包的发送频率不要高于 TCP 协议栈的 Nagle 算法缓冲时间否则心跳被攒着和业务帧一起发反而测不出真实断线。如果心跳是独立 socket 通道可以直接把 NoDelay 设为 true但服务端这边没必要对每条连接开 NoDelay只有对延迟敏感的业务才需要。做 C# TCP 服务端这几年我最大的体会是能连上只是开始能稳定收帧才算真正完成。第一次做上位机服务时我因为没写心跳现场一台设备断电后服务端整整挂了两天才被发现那之后我把连接扫描、断线清理、异常日志全都加上了。希望这篇 TCPServer 落地笔记能在你动手前帮到一点少走几个我已经替你走过的弯路。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进