简介:这是一套面向C#网络编程学习者与开发者的Socket通信完整项目,包含WinForm客户端、WinForm服务端以及可独立引用的Socket功能类库三部分。项目重点解决了长连接场景下的心跳保活、断线重连、异步收发数据、消息回调反馈与粘包处理等常见难点,支持多客户端同时在线,服务端既能广播消息也可定向推送给指定客户端,适合用于即时通讯、设备管理等需要稳定长连接的场景。资源包共165个文件,以cs源码、txt说明、dll依赖、xml配置、config配置及exe可执行文件为主,另有sln解决方案与docx文档,压缩包约8.71MB,bin目录下附带运行日志便于排查程序状态。类库模块复用性较强,注释详细,调用方法即可集成到其他项目。目前已有2344人学习下载,可作为Socket通信入门到进阶的参考范例。
1. 从一次产线掉线说起:C# Socket 通信到底要解决什么
去年帮朋友调一套产线上的上位机,二十台工位终端通过 TCP 连到一台服务端,跑了两天开始出问题:客户端偶尔掉线不重连,服务端收到的报文偶尔少半截,日志里全是乱码。排查下来,掉线是因为没做心跳,中间的网络设备把空闲连接悄悄回收了;报文少半截是典型的粘包,发送端两次 Write 被接收端一次 Read 全收走了。这两个问题几乎是所有 C# Socket 项目的必修课,也是这个标题里提到的几个关键词——心跳、断线重连、异步接收、消息回调、粘包处理、多客户端——背后真正要落地的东西。
这套方案适合谁?做上位机、工控采集、设备网关、内部消息中转的 C# 开发者。它不依赖任何第三方通信框架,纯System.Net.Sockets就能跑起来,服务端用异步 Accept 撑住多客户端,客户端用独立线程做心跳和重连,消息层用长度前缀解决粘包。下面按「协议怎么定 → 服务端怎么写 → 客户端怎么写 → 坑在哪 → 怎么验证」的顺序讲透,代码可以直接抄。
2. 先把协议定死:长度前缀 + 心跳包的最小设计
2.1 为什么粘包必须靠应用层协议解决
TCP 是字节流,不是消息流。发送端调两次Send,接收端可能一次Receive就把两段数据全拿到;反过来,一次Send的数据也可能被拆成两次Receive。这不是 bug,是 TCP 的设计。所以「粘包」这个说法其实不准确,准确的说法是:应用层没有自己划消息边界。
常见做法有三种:固定长度、特殊分隔符(比如\r\n)、长度前缀。固定长度浪费带宽且不灵活;分隔符遇到消息体里本身含分隔符就翻车;长度前缀最稳,工业场景基本都用它。我一般会定成「4 字节消息总长 + 1 字节消息类型 + N 字节消息体」,总长字段用int小端序,接收端先读 4 字节拿到长度,再按长度读满整包。
提示:长度字段本身也可能被拆包,所以读长度和读消息体要分开处理,不能假设一次 Receive 就能拿到完整头部。
2.2 心跳包和业务包用同一个协议
心跳不需要单独设计一套格式,直接复用消息类型字段。约定0x01是心跳请求,0x02是心跳响应,0x10以上是业务消息。这样接收端的解包逻辑只有一套,不用为心跳写特殊分支。
心跳周期怎么定?内网环境 10 到 30 秒都行,跨机房或者经过 NAT 设备建议 5 到 10 秒。服务端超过 3 个心跳周期没收到任何数据就判定该连接死亡,主动关闭。客户端连续 2 次心跳没收到响应就触发重连。这两个阈值要配合着调,客户端重连阈值必须小于服务端判死阈值,否则会出现客户端还在等、服务端已经把连接关了的尴尬局面。
2.3 消息类型和回调的映射关系
消息回调反馈的本质是「请求-响应」配对。客户端发一条带序列号的消息,服务端处理完把序列号原样带回,客户端根据序列号找到对应的回调委托执行。序列号用Interlocked.Increment生成,保证多线程下不重复。
| 字段 | 长度 | 说明 |
|---|---|---|
| 总长度 | 4 字节 | 含头部在内的整包字节数 |
| 消息类型 | 1 字节 | 0x01 心跳请求,0x02 心跳响应,0x10+ 业务 |
| 序列号 | 4 字节 | 请求响应配对用,心跳包填 0 |
| 消息体 | N 字节 | 业务数据,UTF-8 或二进制 |
这套头部一共 9 字节,开销很小,解析逻辑也简单。下面服务端和客户端的代码都基于这个格式。
3. 服务端:异步 Accept 撑住多客户端
3.1 用 AcceptAsync 而不是线程池阻塞 Accept
老写法是while(true) { var client = listener.AcceptTcpClient(); },每个连接开一个线程。几十个客户端还行,上百个线程上下文切换就开始拖性能。正确做法是用AcceptTcpClientAsync,配合async/await,连接接入不占线程。
public class TcpServer { private readonly TcpListener _listener; private readonly ConcurrentDictionary<string, ClientSession> _sessions = new(); private CancellationTokenSource _cts = new(); public TcpServer(int port) { _listener = new TcpListener(IPAddress.Any, port); } public async Task StartAsync() { _listener.Start(); _ = AcceptLoopAsync(_cts.Token); // 不阻塞调用方 await Task.CompletedTask; } private async Task AcceptLoopAsync(CancellationToken token) { while (!token.IsCancellationRequested) { try { var client = await _listener.AcceptTcpClientAsync(token); client.NoDelay = true; // 关闭 Nagle,降低小包延迟 var session = new ClientSession(client); _sessions.TryAdd(session.Id, session); _ = session.RunAsync(token); // 每个连接独立跑,不 await } catch (OperationCanceledException) { break; } catch (SocketException ex) { Console.WriteLine($"Accept 异常: {ex.SocketErrorCode}"); } } } }AcceptTcpClientAsync返回后立刻把连接交给ClientSession独立处理,主循环继续接下一个。NoDelay = true是工控场景的常用设置,关掉 Nagle 算法,避免小消息被攒着延迟发送。_sessions用ConcurrentDictionary是因为多个连接的回调可能并发读写。
3.2 每个连接的接收循环和粘包处理
接收端最容易翻车的地方是「假设一次 Read 就是一条完整消息」。正确做法是维护一个累积缓冲区,每次读到新数据就追加,然后循环尝试从缓冲区头部解析完整包。
private async Task RunAsync(CancellationToken token) { var buffer = new byte[8192]; var accumulated = new List<byte>(8192); while (!token.IsCancellationRequested) { int read; try { read = await _stream.ReadAsync(buffer, token); } catch { break; } if (read == 0) break; // 对端正常关闭 accumulated.AddRange(buffer.Take(read)); // 循环解析,直到缓冲区里凑不出一个完整包 while (TryParsePacket(accumulated, out var packet)) { await HandlePacketAsync(packet); } } _sessions.TryRemove(Id, out _); } private bool TryParsePacket(List<byte> buf, out Packet packet) { packet = null; if (buf.Count < 9) return false; // 头部都不够 int total = BitConverter.ToInt32(buf.ToArray(), 0); if (total < 9 || total > 1024 * 1024) return false; // 防非法长度 if (buf.Count < total) return false; // 包体还没收全 packet = new Packet { Type = buf[4], Seq = BitConverter.ToInt32(buf.ToArray(), 5), Body = buf.Skip(9).Take(total - 9).ToArray() }; buf.RemoveRange(0, total); // 移除已消费的字节 return true; }TryParsePacket是整套方案的核心。它先检查头部够不够 9 字节,再读总长度,再检查缓冲区里有没有收满整包。三个条件都满足才切出一个包,然后从缓冲区头部移除对应字节数。total > 1024 * 1024这个上限是防恶意包,实际项目按业务最大消息调。
注意:
buf.RemoveRange(0, total)在数据量大时会有内存搬移开销。如果单连接吞吐很高,可以改用环形缓冲区或者MemoryStream配合偏移量,避免频繁搬移。
3.3 心跳超时检测和会话清理
服务端不能只等客户端发心跳,自己也要主动检查。常见做法是起一个定时器,每 5 秒扫一遍所有会话,把最后活跃时间超过阈值的连接关掉。
private async Task HeartbeatCheckLoopAsync(CancellationToken token) { while (!token.IsCancellationRequested) { await Task.Delay(5000, token); var now = DateTime.UtcNow; foreach (var kv in _sessions) { var s = kv.Value; if ((now - s.LastActiveTime).TotalSeconds > 30) { Console.WriteLine($"会话 {s.Id} 心跳超时,主动关闭"); s.Close(); _sessions.TryRemove(kv.Key, out _); } } } }LastActiveTime在每次收到任何数据时更新,不管是心跳还是业务包。30 秒这个值对应前面说的「3 个心跳周期」,客户端 10 秒发一次心跳,服务端给 3 倍容错。如果网络抖动大,可以放宽到 60 秒,但客户端重连阈值也要相应调整。
4. 客户端:心跳、重连和回调怎么串起来
4.1 独立心跳线程和重连状态机
客户端最忌讳把心跳和业务收发混在一个线程里。业务处理慢的时候心跳发不出去,服务端误判掉线。我一般会开一个独立的心跳任务,用Task.Run跑循环,和接收循环完全解耦。
private async Task HeartbeatLoopAsync(CancellationToken token) { while (!token.IsCancellationRequested) { try { if (_isConnected) { var hb = Packet.BuildHeartbeat(); await SendAsync(hb); _missedHeartbeats++; if (_missedHeartbeats >= 2) { Console.WriteLine("连续 2 次心跳无响应,触发重连"); _ = ReconnectAsync(); _missedHeartbeats = 0; } } } catch (Exception ex) { Console.WriteLine($"心跳发送异常: {ex.Message}"); } await Task.Delay(10000, token); // 10 秒一次 } }_missedHeartbeats在收到心跳响应时清零。连续 2 次没响应就触发重连,重连逻辑本身要加锁,防止多个地方同时触发导致重复连接。
4.2 重连的退避策略和幂等处理
重连不能死循环猛冲,否则服务端刚重启就被打满。常见做法是退避:第一次等 1 秒,第二次 2 秒,第三次 4 秒,上限 30 秒。
private async Task ReconnectAsync() { if (Interlocked.CompareExchange(ref _reconnecting, 1, 0) != 0) return; try { int delay = 1000; while (!_isConnected && !_cts.IsCancellationRequested) { try { await ConnectAsync(); _isConnected = true; Console.WriteLine("重连成功"); break; } catch { Console.WriteLine($"重连失败,{delay}ms 后重试"); await Task.Delay(delay); delay = Math.Min(delay * 2, 30000); } } } finally { Interlocked.Exchange(ref _reconnecting, 0); } }Interlocked.CompareExchange保证同一时刻只有一个重连流程在跑。重连成功后要把_missedHeartbeats清零,并且重新启动接收循环——旧连接的接收循环在断开时已经退出了。
4.3 消息回调反馈的序列号配对
回调的本质是「发出去的时候登记一个委托,收到响应的时候按序列号取出来执行」。用一个ConcurrentDictionary<int, TaskCompletionSource<Packet>>存待响应的请求。
private readonly ConcurrentDictionary<int, TaskCompletionSource<Packet>> _pending = new(); public async Task<Packet> SendRequestAsync(byte type, byte[] body, int timeoutMs = 5000) { int seq = Interlocked.Increment(ref _seqSeed); var tcs = new TaskCompletionSource<Packet>(TaskCreationOptions.RunContinuationsAsynchronously); _pending[seq] = tcs; var packet = Packet.Build(type, seq, body); await SendAsync(packet); using var cts = new CancellationTokenSource(timeoutMs); using (cts.Token.Register(() => tcs.TrySetCanceled())) { try { return await tcs.Task; } finally { _pending.TryRemove(seq, out _); } } } // 接收循环里收到响应包时 private void OnResponseReceived(Packet p) { if (_pending.TryRemove(p.Seq, out var tcs)) tcs.TrySetResult(p); }TaskCreationOptions.RunContinuationsAsynchronously这个参数很关键,不加的话回调可能直接在接收线程上同步执行,业务处理一慢就把接收循环堵死。超时用CancellationTokenSource控制,超时后从字典里移除,避免内存泄漏。
5. 避坑排查:这五个问题我全踩过
5.1 端口被占用:通常每个套接字地址只允许使用一次
现象:服务端启动直接抛SocketException,提示「通常每个套接字地址(协议/网络地址/端口)只允许使用一次」。
原因:上一次进程没退干净,端口还在TIME_WAIT状态,或者另一个程序占着这个端口。
解决:先netstat -ano | findstr :端口号找到占用进程,确认是不是自己的残留进程。如果是自己程序频繁重启导致的TIME_WAIT,可以在TcpListener启动前设置SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true)。但要注意,这个选项在 Windows 上行为比较微妙,多个进程同时监听同一端口仍然会冲突,它主要解决的是TIME_WAIT复用问题。
5.2 粘包处理里长度字段被拆开
现象:偶尔解析出超大长度值,程序抛异常或者内存暴涨。
原因:接收缓冲区里只有 2 字节,代码却直接BitConverter.ToInt32读了 4 字节,读到了后面的业务数据。
解决:解析长度前必须先判断buf.Count >= 4。这个检查看起来废话,但实际项目里十有八九是漏了这一步。另外长度值要做合法性校验,超过业务最大包长的直接断开连接,不要试图分配内存。
5.3 心跳线程和接收线程同时操作连接对象
现象:偶发ObjectDisposedException或者NullReferenceException,堆栈指向NetworkStream。
原因:心跳线程在发心跳的同时,接收线程检测到断开把NetworkStream关掉了,两边没同步。
解决:所有对连接对象的操作加同一把锁,或者用CancellationToken统一控制生命周期。我一般会在ClientSession里放一个_closed标志,Interlocked读写,发送前先检查。
5.4 重连后旧回调没清理
现象:重连成功后,之前超时的请求回调偶尔还会被执行一次,业务逻辑重复。
原因:重连时没有清空_pending字典,旧连接的响应包序列号和新连接的撞上了。
解决:重连成功的第一件事就是遍历_pending,把所有TaskCompletionSource设为取消,然后清空字典。序列号种子也要重置或者继续递增,避免和新连接冲突。
5.5 异步接收里用了同步 Read
现象:服务端 CPU 不高但吞吐上不去,客户端多了以后延迟明显。
原因:接收循环里用了_stream.Read(buffer, 0, buffer.Length)同步方法,每个连接占一个线程阻塞在 Read 上。
解决:全部改成await _stream.ReadAsync(...)。如果用的是老版本 .NET,至少要用BeginRead/EndRead或者NetworkStream.ReadAsync。同步 Read 在连接数少的时候看不出问题,上百连接就是灾难。
6. 进阶:用抓包和压测验证你的实现
代码写完不代表没问题,得验证。我一般分两步:先抓包看协议对不对,再压测看并发稳不稳。
抓包用 Wireshark,过滤条件写tcp.port == 你的端口。重点看三件事:心跳包是不是按周期在发、有没有出现半包(一个 TCP 段里只有部分消息)、服务端判死连接后有没有发 FIN。如果看到大量重传,说明网络质量有问题,心跳周期要放宽。
压测不用上重型工具,写个 C# 控制台循环开 200 个客户端,每个客户端每秒发 10 条业务消息,跑 30 分钟。观察服务端内存有没有持续增长——如果_sessions只增不减,说明会话清理逻辑有漏洞。观察客户端重连次数,如果频繁重连,检查心跳阈值是不是太激进。
// 简易压测客户端 var tasks = Enumerable.Range(0, 200).Select(async i => { var client = new TcpClient(); await client.ConnectAsync("127.0.0.1", 9000); var stream = client.GetStream(); var rnd = new Random(i); for (int j = 0; j < 10; j++) { var body = new byte[64]; rnd.NextBytes(body); var pkt = Packet.Build(0x10, j, body); await stream.WriteAsync(pkt); await Task.Delay(100); } client.Close(); }); await Task.WhenAll(tasks);跑完压测后重点看服务端日志里有没有「心跳超时」误报。如果 200 个客户端稳定跑 30 分钟零误报、零异常,这套实现基本就能上产线了。
最后说个习惯:我每次改完 Socket 相关代码,都会先把心跳周期临时改成 2 秒、超时阈值改成 5 秒,让问题快速暴露,确认稳定后再改回正常值。这个「加速老化」的土办法帮我提前发现过好几次重连竞态和回调泄漏。希望帮到你。
本文还有配套的精品资源,点击获取