ARTICLE DETAIL

资讯详情

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

C# TCP Server实现指南:Socket建连、粘包拆包与心跳检测

C# TCP Server实现指南:Socket建连、粘包拆包与心跳检测 简介这是一份用 C# 语言实现的 TCP 通信服务器端程序面向需要学习 Socket 编程或搭建局域网通信服务的初中级开发者也适合计算机网络相关课程的学生进行实验参考。程序以 Visual Studio 解决方案形式组织核心代码包括服务器主类、程序入口以及点对点服务类完整展示套接字创建、端口监听、客户端接入和消息收发的基本流程同时也涉及消息编码与异常处理等常见细节适合作为课程设计或毕业设计的基础原型。压缩包共含 26 个文件整体大小仅 50KB属于轻量级示例工程。除 7 个 C# 源文件外还提供了可直接运行的执行文件、调试所需的符号文件、界面资源文件、工程与解决方案配置以及文本说明其中源码目录、编译输出目录和资源目录划分明确便于打开学习或二次修改。目前已有 344 人学习下载可帮助读者快速建立 TCP 服务端编程的整体认识并为后续扩展多线程并发、心跳检测或异步通信提供清晰的改造思路实用性较强。1. C# TCP Server 值不值得下先想清楚它是给谁用的C# TCP Server 这份资源说白了就是一个能直接跑的 TCP 服务器端工程。不管你是做上位机、数据采集还是设备联调迟早会遇到写个程序把设备数据收上来的需求。很多第一次碰 socket 的 C# 开发者会觉得自己写的不是代码是玄学——客户端连不上、收发卡死、数据对不上十有八九不是协议问题而是对 TCP 流式传输的行为理解不透。这篇拆解把 TCPServer 源码的核心段落逐块讲清楚两种建连方式怎么选、粘包怎么切、断线怎么检测、上线前怎么压测。照着走比从零翻 MSDN 快得多适合刚入门的 C# 开发者和正在调上位机通信的工程师。2. 从 TcpListener 到 Socket两类建连方式的选型与骨架2.1 TcpListener 封装适合起步与低频命令交互C# 里写 TCP 服务端最快的是 TcpListener。它把 bind、listen、accept 都包了一层处理客户端连上来、发一条命令、等一条回复这类低频交互非常够用。下载站上那份 TCPServer 资源对应关键词就是 C# TCP server / TCP socket / TCP通信服务器端程序解压出来第一版骨架就是这套。先看监听和接受连接的代码// 在 9000 端口上监听所有本机网卡 TcpListener listener new TcpListener(IPAddress.Any, 9000); listener.Start(50); // 50 是挂起连接队列长度超过会被对端当拒连处理 while (!_cts.IsCancellationRequested) { TcpClient client await listener.AcceptTcpClientAsync(); // 每个客户端开一个任务独立处理避免一个卡住全队堵车 _ HandleClientAsync(client, _cts.Token); }然后是接收循环private async Task HandleClientAsync(TcpClient client, CancellationToken token) { using (client) using (NetworkStream stream client.GetStream()) { byte[] buffer new byte[4096]; while (!token.IsCancellationRequested) { int n await stream.ReadAsync(buffer, 0, buffer.Length, token); if (n 0) break; // 对端正常关闭读返回 0 // 这里拿到的是裸数据下一步要做拆包见第 3 章 ProcessRawData(buffer, n); } } }逻辑说明TcpListener 的 Start(int) 参数是 backlog即内核为监听 socket 排队等待 accept 的连接数。不传默认为 int.MaxValue生产环境建议显式给 50 或 100否则客户端瞬间并发上来时队列溢出对端会收到拒连。AcceptTcpClientAsync 是异步方法不会阻塞主线程放在 WinForm、WPF 上位机里不会导致界面卡死。参数说明IPAddress.Any 表示监听所有本地 IP如果只监听内网特定网卡改成 IPAddress.Parse(192.168.1.10)。buffer 4096 在多数工控报文场景够用但传大图或文件流时要加大到 8192 以上并配合第 3 章的拆包逻辑不能拿裸接收去拼文件。TcpListener 的缺点也明显每次 Accept 都新建 TcpClient 对象高频连接下分配开销大对 Socket 层高级选项控制弱。资源包里保留这一版是给刚接触 TCP 的人一条最容易上手的路。真正压吞吐量要切到原生 Socket。2.2 原生 Socket把网络层选项握在自己手里原生 Socket 代码量变长但换来对网络行为的完全控制。典型的服务端监听骨架Socket listenSocket new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); // 允许端口复用否则服务端重启时会遇到“每个套接字地址只允许使用一次” listenSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); listenSocket.Bind(new IPEndPoint(IPAddress.Any, 9000)); listenSocket.Listen(100);接收连接用 AcceptAsync 池化方案private void BeginAccept() { Socket client listenSocket.EndAccept(_acceptArgs); // 每次用完要把 AcceptSocket 置空否则下次会复用旧连接 _acceptArgs.AcceptSocket null; // 分发给处理逻辑 HandleClient(client); listenSocket.AcceptAsync(_acceptArgs); }逻辑说明Socket 直接面向 TCP/IP 协议栈SetSocketOption 设置的 ReuseAddress 非常关键。服务端异常退出再重启内核的 TIME_WAIT 连接还没释放完新进程 bind 同一端口就报每个套接字地址(协议/网络地址/端口)只允许使用一次。这行写在 bind 之前是资源包里有意为之的设计。参数说明AcceptAsync 基于 IO 完成端口连接量大后比 Begin/End 模式减少回调分配。它返回 false 表示同步完成true 表示异步进行中两种情况都要在 Completed 回调里 EndAccept 并重新投递下一次。漏了重新投递是服务端跑一晚上就再也连不上的常见原因。建连部分到这里有两条路线快出活、内网低频交互用 TcpListener要控制端口复用、准备异步池化用原生 Socket。我一般把两套骨架都留在工程里联调阶段拿 TcpListener 跑通协议上线前换原生 Socket 版本压测。3. 把收发缓冲区设计好粘包、半包与断线识别三板斧3.1 定长包头 长度字段TCP 粘包拆包的常规解法TCP 是流协议没有消息边界这是所有 socket 新手第一次翻车的地方。send 两次数据服务端可能合成一次收到send 一次大数据又可能被切成两段。所谓粘包、拆包本质是应用层没做定界。最常见的解法是自定义协议头前 4 字节存报文长度后面跟报文体。服务端接收时要先把数据收进累计缓冲区再按长度字段逐条切出完整报文。资源包里的拆包逻辑private Queuebyte[] SplitPacket(byte[] incoming, ref byte[] cache) { Queuebyte[] ready new Queuebyte[](); // 新数据追加到已有缓冲末尾 byte[] merged new byte[cache.Length incoming.Length]; Buffer.BlockCopy(cache, 0, merged, 0, cache.Length); Buffer.BlockCopy(incoming, 0, merged, cache.Length, incoming.Length); cache merged; while (cache.Length 4) { int bodyLen BitConverter.ToInt32(cache, 0); // 前 4 字节是长度 if (cache.Length 4 bodyLen) break; // 半包继续等 byte[] body new byte[bodyLen]; Buffer.BlockCopy(cache, 4, body, 0, bodyLen); ready.Enqueue(body); // 切掉已消费的头部和消息体 byte[] rest new byte[cache.Length - 4 - bodyLen]; Buffer.BlockCopy(cache, 4 bodyLen, rest, 0, rest.Length); cache rest; } return ready; }逻辑说明Buffer.BlockCopy 按字节复制不涉及类型转换网络字节流里效率最高。BitConverter.ToInt32 把长度字段当小端 int 解析如果协议约定大端要配合 IPAddress.NetworkToHostOrder 转换字节序否则对端发来的长度在这边会变成天文数字直接撑爆缓冲区。参数说明4 字节长度上限约 2GB对绝大多数设备和上位机交互够用报文可能超大的场景要改 8 字节长度字段。cache 是每个连接独立的缓冲区不能在多个客户端线程间共享否则拆包会串数据。while 循环一次能切出所有完整报文比一条消息来一次回调更高效。注意bodyLen 要做一层上限校验比如不能超过 16MB否则异常报文会把内存吃满。资源包实现里默认加了 16MB 检查实际部署时按你的报文上限再收紧。3.2 心跳与断线识别服务端别死等一个已经不存在的客户端TCP 三次握手建立连接后对端突然断电、拔网线服务端感知不到ReadAsync 会一直阻塞到超时。工控现场拉闸是家常便饭服务端不能干等。资源包的做法是每个连接记录最后活跃时间后台定时器周期性扫描超过阈值直接掐掉private async Task HeartbeatCheckAsync(ConcurrentDictionarylong, ClientSession sessions, int timeoutSec) { while (!_cts.IsCancellationRequested) { await Task.Delay(1000); var now DateTime.UtcNow; foreach (var kv in sessions) { if ((now - kv.Value.LastSeen).TotalSeconds timeoutSec) { kv.Value.Socket.Close(); sessions.TryRemove(kv.Key, out _); } } } }逻辑说明LastSeen 在每次收到完整报文时更新服务端不主动发心跳包这是被动策略。它要求客户端周期性上报数据适合设备本来就持续上报的场景。如果客户端可能长时间静默要换成服务端主动发 Ping、客户端回 Pong、两次没回就断。资源包里两种模式都写了默认走被动策略因为改动最小。参数说明timeoutSec 根据业务数据频率定建议是正常上报间隔的 3 到 5 倍。设备每 5 秒上报一次超时设 15 秒太小会误判正常间隙为掉线太大失去心跳意义。扫描间隔 1 秒是通用值连接数以千计时扫描本身有开销定时器可拉长到 5 秒一轮。血泪经验心跳超时后不要只 Close 等 GC要主动 Shutdown(SocketShutdown.Both) 再 Close 回收句柄。直接 Close 在 Windows 上走 RST 流程对端能感知异常但本端资源释放不如 Shutdown 后再 Close 干净。4. 多客户端连接的线程模型并发处理的开销与取舍4.1 每连接一线程的适用边界很多新手拿到 TCPServer 的第一反应是来一个客户端就 new 一个 Thread。这在 20 个连接以内没毛病代码直观、调试方便。但线程栈空间默认 1MB每个线程还有内核对象开销连接数到几百就明显吃力如果连接里还有阻塞读线程就挂在 Read 上纯浪费。资源包保留了每连接一线程版本并标注适用边界连接数几十、报文频率低、协议调试阶段Thread t new Thread(() HandleClientRaw(client)); t.IsBackground true; t.Start();逻辑说明IsBackground true 很关键。主程序退出时后台线程随进程终止不会被挂起的 Read 阻塞拖住控制台和上位机都能利落退出。漏了这行关程序时会发现进程退不掉任务管理器里残留进程占着端口。参数说明Thread 方式没有内置取消机制关闭服务端时线程还在阻塞读只能靠 Close socket 让 Read 抛异常退出。所以这个模型只用于联调期正式版本切 Task 或异步循环。4.2 Task 异步接收代码可读性与并发能力的平衡点Task 比裸线程轻得多配合 await Socket.ReceiveAsync 或带 CancellationToken 的 ReadAsync接收循环写起来几乎和同步代码一样直白又能撑住上千连接。这是资源包正式推荐的模型private async Task ReceiveLoopAsync(Socket socket, CancellationToken token) { byte[] head new byte[4]; while (!token.IsCancellationRequested) { int n await socket.ReceiveAsync(head, SocketFlags.None, token); if (n 0) break; if (n 4) { /* 长度头没读全需要补读完整实现见资源包 */ } int bodyLen BitConverter.ToInt32(head, 0); byte[] body new byte[bodyLen]; int offset 0; while (offset bodyLen) { int m await socket.ReceiveAsync(body.AsMemory(offset), SocketFlags.None, token); if (m 0) break; offset m; } ProcessOne(body); } }逻辑说明ReceiveAsync 的泛型版本一次能只读 4 字节头部也可配合 Memory 把报文体读满。第二个 while 循环专门处理半包——TCP 不保证一次读满 bodyLen所以要按已读位置继续收直到凑齐。逐段补读比把缓冲区开大更可控内存占用稳定。参数说明SocketFlags.None 表示普通读。异步方法传 CancellationToken停机时直接取消所有连接的读循环比 Close 抛异常优雅——走 OperationCanceledException 而不是 SocketException日志里能分清是主动取消还是网络故障。Task 模型不是没代价每个连接仍要维护接收缓冲和状态对象连接数过万后 GC 压力是主要矛盾。真上万连接要换 SocketAsyncEventArgs 池化方案把实例在连接间复用这是 .NET 官方推荐的高性能路径但代码复杂度明显上升。资源包先给 Task 版本是让你先在功能和性能间找到能落地的点。5. 排查与避坑端口占用、半开连接与防火墙三座山5.1 现象服务端重启报每个套接字地址只允许使用一次原因进程上次退出时连接没正常关闭TCP 进入 TIME_WAIT 状态默认要等 2 分钟MSL 两倍才能重新绑定同一端口。Windows 上服务端退出、客户端异常断开都可能留下这个状态。解决代码里 bind 前加 ReuseAddress前面 2.2 节已经提到这是服务端程序的后悔药。如果加上还报错用命令查哪个进程占着端口netstat -ano | findstr :9000拿到 PID 后在任务管理器核对进程名确认是残留服务再结束。不要无脑杀 pid可能误杀别的服务。5.2 现象客户端断电后服务端 Read 一直阻塞连接像幽灵一样挂着原因纯收数据的协议里服务端对断电没有感知内核不会立刻通知。这就是 3.2 节心跳要解决的核心问题。解决按资源包的 HeartbeatCheckAsync 做超时清理。不想改代码可以给 Socket 开操作系统级探活socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);但系统探活默认间隔约 2 小时实际用起来很难受应用层心跳才可靠。5.3 现象服务端明明在监听客户端就是连不上原因Windows 防火墙默认拦截入站 TCP 端口工控现场内网机器常见这个坑。先在服务端放行端口netsh advfirewall firewall add rule nameTCPServer 9000 dirin actionallow protocolTCP localport9000排查时先确认端口通再回来看代码netstat -ano | findstr LISTENING也能用 Test-NetConnection 或 telnet 试连通性把网络层和代码层隔离开。5.4 现象小包高延迟收发正常但感觉卡顿原因Nagle 算法在未收到确认前合并小包对频繁交互的工控协议是灾难。服务端和客户端都要把 NoDelay 设为 trueTcpClient tcp new TcpClient(); tcp.NoDelay true; // 原生 Socket 版本 socket.NoDelay true;设置完再观察到达间隔还慢就查对端是否也启了 Nagle单改一边没用。NoDelay 关闭 Nagle 合并代价是每个小包独立发送在低带宽链路会放大开销。内网跑没问题跨公网传输要权衡。如果连接建立成功但数据收不全先把这条命令的输出过一遍netsh int tcp show global查有没有全局参数把窗口或时间戳改坏了比如 timestamps 被强制开启或关闭会影响某些协议栈兼容性。多数时候保持默认就行。5.5 现象服务端重启瞬间旧客户端疯狂抛 SocketException原因客户端没有处理服务端主动关闭后的重连退避一个进程起来就狂连把日志刷爆。解决客户端重连加指数退避第一次等 1 秒失败后 2 秒、4 秒、8 秒最大 30 秒封顶private async Task ReconnectWithBackoffAsync(CancellationToken token) { int delay 1; while (!token.IsCancellationRequested) { try { await ConnectAsync(127.0.0.1, 9000); return; } catch { await Task.Delay(TimeSpan.FromSeconds(delay), token); } delay Math.Min(delay * 2, 30); } }逻辑说明指数退避把重连风暴变成稳定渐进的试探服务端刚起来时不会被几百个客户端同时冲击。参数说明delay 初始 1 秒上限 30 秒适合绝大多数内网场景跨公网可以把上限放到 60 秒。6. 上线前压测与保活验证并发与断线恢复的最后一步资源包做到能跑只是第一步上线前我会固定做两轮验证。第一轮是并发压测用 C# 写一个多任务模拟客户端同时建立 100 个连接每个连接循环收发 1000 条报文观察服务端有无丢包、卡死或内存暴涨var tasks Enumerable.Range(0, 100).Select(i Task.Run(async () { using var client new TcpClient(); await client.ConnectAsync(127.0.0.1, 9000); for (int j 0; j 1000; j) { byte[] body Encoding.UTF8.GetBytes($msg-{i}-{j}); byte[] head BitConverter.GetBytes(body.Length); await client.GetStream().WriteAsync(head.Concat(body).ToArray()); // 读回包验证协议闭环 } })); await Task.WhenAll(tasks);这轮压测能一次性暴露四种问题拆包逻辑在并发下是否串数据、接收缓冲区是否有线程安全缺陷、半包补读是否能撑住乱序到达、服务端在 GC 压力下表现如何。压测时盯着任务管理器的内存和句柄数句柄只增不减大概率有连接没走完 Shutdown/Close 流程。第二轮是断线恢复。我会在压测进行到一半时用任务管理器直接结束几个模拟客户端进程再观察心跳逻辑能否在 timeoutSec 内把对应连接清理干净。想模拟拔网线就把虚拟机网卡直接禁用那才是真正的无通知断线比 Close socket 更接近现场。两轮跑完TCPServer 才算真正能落地。我第一次上线就是在压测这步翻车的当时只测了功能没测掉线结果工位现场断电重连几次服务器上挂了几十个僵尸连接最后摸到心跳超时设置的问题。从那以后每次改服务端代码我都强制走一遍并发压测 断线恢复这套组合半小时验证完再挪到现场机。希望帮到你。本文还有配套的精品资源点击获取
返回列表