资讯动态

C# 多客户端 Socket TCP 群发实战:帧协议、异步会话与单写者队列

发布时间:2026/10/24 4:17:52 来源:尧图企业网站定制
简介这份资源面向具备一定 C 基础、希望深入理解 Windows 网络编程的开发者基于 Visual Studio 2013 实现服务端与多客户端之间的 TCP 实时通信可支撑群聊式消息广播、服务端主动推送以及文件二进制流传输等典型场景是学习 Socket 并发处理与连接管理的实践案例。压缩包为 rar 格式共 58 个文件、约 16.42MB以 4 个 cpp 源文件与 2 个 h 头文件承载服务端、客户端核心逻辑2 个 sln 与 2 个 vcxproj 提供可直接打开的工程配置另含 exe、pdb、obj、ilk 等编译产物与日志缓存文件便于对照运行结果与调试信息。目前已有 513 人学习下载。读者可从中梳理 TCP 连接建立、多线程并发接入、消息群发广播及二进制文件传输的完整实现路径并借助现成工程快速复现实验、理解服务端与客户端的分工协作为后续即时通讯类项目开发打下基础。1. 一条通知发不出去多客户端 TCP 群发的真实起点教师机要往机房所有学生机推一条下课提示代码写完第一次联调就翻车第一台客户端连上一切正常第二台一连上第一台就再也收不到消息。问题不在 Socket API 会不会用而是把 Windows 服务端写成了单人对话——Accept 阻塞在主线程、连接没有登记、发消息直接同步 Send。真正要落地的是三件事服务端同时持住多客户端 TCP 连接、给每条连接一个身份和生命周期、让一条消息能广播到全部在线端。多客户端 Socket TCP 通信这类课设和毕设绕不开群发需求也绕不开这三件事。下面按需求、协议、服务端、客户端、验收的顺序把这套东西拆成一份能照着写的完整方案。2. 并发模型与帧协议先把「谁能同时说话」和「一句话到哪结束」定死动手写第一行代码之前有两个决定比写业务逻辑更靠前服务端用什么方式同时盯住 N 条连接以及一条消息在字节流里怎么界定边界。这两个问题没定死后面写多少行都是返工。2.1 三种并发模型摆在一起比较常见的做法有三种选型不看流行度看连接规模和代码维护成本。模型典型写法连接上限主要问题适用场景每连接一线程new Thread(Handle)几百受线程栈限制线程切换开销大一个阻塞拖一个线程连接数固定且很少Select 轮询Socket.Select默认 64需重定义FD_SETSIZE每次轮询全量遍历还要自己维护读写集合老代码、跨平台 C异步 完成端口AcceptSocketAsync/ReceiveAsync数千级需要理解回调与状态机Windows 服务端首选.NET 在 Windows 上的异步 Socket 底层走的是内核完成端口await写法把回调状态机包了一层代码量跟同步版差不多但没有线程占用问题。我一般直接选第三种理由是它不用碰FD_SETSIZE这个上限也不用为了躲阻塞而到处开线程。要提醒一句异步不等于免费ReceiveAsync返回的字节数只代表「这一次收到了多少」不代表「一条消息收完了」这才是后面帧协议要解决的问题。2.2 帧格式4 字节长度 1 字节类型TCP 是字节流没有消息边界。客户端连着发两条hello和world服务端一次Receive可能收到hello、可能收到hell也可能收到helloworld。前者叫半包后者叫粘包靠调大缓冲区解决不了只能靠协议约定。这套方案用最朴素的长度前缀帧前 4 字节是大端序的载荷长度第 5 字节是消息类型后面紧跟载荷。// FrameCodec.cs —— 全工程只有这一处处理字节序别在别处手写 public static class FrameCodec { public const int HeaderSize 5; // 4 字节长度 1 字节类型 public const int MaxPayload 64 * 1024; // 单帧上限 64KB防止伪造长度撑爆内存 public static byte[] Pack(byte msgType, byte[] payload) { payload ?? Array.Emptybyte(); var frame new byte[HeaderSize payload.Length]; var len BitConverter.GetBytes(payload.Length); if (BitConverter.IsLittleEndian) Array.Reverse(len); // 统一大端跨语言对接不用猜 Buffer.BlockCopy(len, 0, frame, 0, 4); frame[4] msgType; Buffer.BlockCopy(payload, 0, frame, HeaderSize, payload.Length); return frame; } public static int ReadLength(byte[] buf, int offset) { var tmp new byte[4]; Buffer.BlockCopy(buf, offset, tmp, 0, 4); if (BitConverter.IsLittleEndian) Array.Reverse(tmp); return BitConverter.ToInt32(tmp, 0); } }长度字段只统计载荷不含 5 字节头这样拆包时算偏移最直接。MaxPayload这个上限必须有否则对端随便报一个 20 亿的长度服务端new byte[len]就会直接崩这是最容易被忽略的一处外部输入校验。2.3 拆包器把半包攒起来凑够一整帧再交出去每个连接配一个独立的累积缓冲区。这里有一个 C# 的坑拆包方法最初想写成Feed(ReadOnlySpanbyte chunk)配yield return编译器直接报错——ref struct不能出现在迭代器里。改成传数组加偏移量就没这个问题。// FrameBuffer.cs —— 每条连接一个实例线程内独占不加锁 public sealed class FrameBuffer { private byte[] _acc new byte[8192]; private int _len; // 当前累积的有效字节数 public List(byte Type, byte[] Payload) Feed(byte[] chunk, int offset, int count) { if (_len count _acc.Length) Array.Resize(ref _acc, Math.Max(_acc.Length * 2, _len count)); Buffer.BlockCopy(chunk, offset, _acc, _len, count); _len count; var frames new List(byte, byte[])(); int pos 0; while (_len - pos FrameCodec.HeaderSize) { int len FrameCodec.ReadLength(_acc, pos); if (len 0 || len FrameCodec.MaxPayload) throw new InvalidDataException($帧长度非法: {len}); if (_len - pos - FrameCodec.HeaderSize len) break; // 载荷还没到齐等下次 var payload new byte[len]; Buffer.BlockCopy(_acc, pos FrameCodec.HeaderSize, payload, 0, len); frames.Add((_acc[pos 4], payload)); // pos4 就是类型字节 pos FrameCodec.HeaderSize len; } if (pos 0) // 把已消费的头部挪掉剩下的半包留在缓冲区开头 { Buffer.BlockCopy(_acc, pos, _acc, 0, _len - pos); _len - pos; } return frames; } }关键在最后那次BlockCopy一次Receive可能正好切在帧中间处理完完整帧后剩余字节必须挪到缓冲区开头否则下一批数据拼上来位置就全错了。这个方法每次返回的是「本次能解析出的所有完整帧」一条消息拆成三次到达也能正确拼回来。2.4 消息类型约定类型字节决定载荷怎么解释先定表再写代码客户端服务端各写一份常量类对齐。类型值名称方向载荷内容0x01登录客户端 → 服务端UTF-8 昵称0x02群发文本双向UTF-8 文本0x03私聊客户端 → 服务端16 字节目标 Id 文本0x04心跳双向空0x05系统通知服务端 → 客户端UTF-8 文本如「某某上线」0x06在线列表服务端 → 客户端昵称以\n分隔私聊的目标 Id 用 16 字节定长放在载荷最前面好处是不用再套一层变长结构拆开就是payload[0..16]和payload[16..]解析代码三行写完。3. 服务端骨架会话表、接收泵与单写者发送队列的三段式拆分服务端的职责拆干净就是三块接进来、记下来、发出去。混在一个循环里写就会出现第一节那个现象——Accept 一阻塞所有已连接的客户端全部失联。3.1 监听与接入backlog、NoDelay 和「一条连接两个任务」// ServerHost.cs 片段 private readonly Socket _listener new(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); private readonly ConcurrentDictionaryGuid, ClientSession _sessions new(); public void Start(int port) { _listener.Bind(new IPEndPoint(IPAddress.Any, port)); _listener.Listen(512); // backlog已完成三次握手但还没 Accept 的排队上限 _ AcceptLoopAsync(_cts.Token); } private async Task AcceptLoopAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { Socket socket; try { socket await _listener.AcceptSocketAsync(ct); } catch (OperationCanceledException) { break; } catch (SocketException ex) { Log(ex); continue; } // 单次 Accept 失败不能让循环退出 socket.NoDelay true; // 关掉 Nagle否则小消息会被攒着一起发群发延迟忽高忽低 var session new ClientSession(socket); _sessions[session.Id] session; _ ReceiveLoopAsync(session, ct); // 读任务 _ session.SendLoopAsync(ct); // 写任务 } }Listen(512)这个数字不是随便填的。压测时 200 个客户端同一秒连进来backlog 太小会出现「连接被拒绝」客户端报ConnectionRefused而服务端日志干干净净看着像玄学。NoDelay也建议打开默认的 Nagle 算法会把小包攒到一定量再发群发消息体只有几十字节时延迟会莫名其妙抖动几十毫秒。3.2 会话对象身份、最后活跃时间与有界发送队列每条连接一个ClientSession把「这条连接是谁、多久没说话、要发的东西排队在哪」全部收在一个对象里。public sealed class ClientSession { public Guid Id { get; } Guid.NewGuid(); public string Nick { get; set; } anonymous; public Socket Socket { get; } public DateTime LastSeen { get; set; } DateTime.UtcNow; // 有界队列慢客户端堆到 256 帧就丢最旧的绝不让它拖住整个服务端 private readonly Channelbyte[] _outbox Channel.CreateBoundedbyte[]( new BoundedChannelOptions(256) { FullMode BoundedChannelFullMode.DropOldest }); public ClientSession(Socket socket) Socket socket; // 只入队不做任何 IO任何线程调用都是毫秒级返回 public bool Enqueue(byte[] frame) _outbox.Writer.TryWrite(frame); public async Task SendLoopAsync(CancellationToken ct) { try { await foreach (var frame in _outbox.Reader.ReadAllAsync(ct)) { int sent 0; while (sent frame.Length) // SendAsync 也可能只发出去一部分 sent await Socket.SendAsync(frame.AsMemory(sent), SocketFlags.None, ct); } } catch (Exception) { /* 异常统一交给外层 CloseAsync 处理 */ } } public void StopAccepting() _outbox.Writer.TryComplete(); }这里是整套方案里最关键的一个设计决定发送永远只有一个写者。多个线程同时往一个 Socket 上Send字节流会交错客户端收到的帧直接是坏的。用Channel把并发写收敛成单消费者天然解决这个问题。队列设上限同样重要某个客户端网络卡住、发送窗口塞满时SendAsync会挂在那里如果没有上限后续群发的所有帧都堆在内存里几十兆就上去了。3.3 群发实现快照遍历加逐会话入队public int Broadcast(byte msgType, byte[] payload) { var snapshot _sessions.Values.ToArray(); // 先取快照遍历期间新连接进来不影响 var frame FrameCodec.Pack(msgType, payload); // 同一个 byte[] 复用不重复分配 int ok 0; foreach (var s in snapshot) if (s.Enqueue(frame)) ok; return ok; }整个群发方法没有一次真正的网络 IO全部是内存入队所以它的耗时基本等于会话数乘以一次TryWrite几百个连接也就几毫秒。真正的发送被分摊到每个会话自己的写循环里并行进行一个慢客户端只会堵住它自己那条队列。返回的ok是入队成功的条数可以回给发起方做提示也可以只记日志。frame在所有会话之间共享同一个数组因为写循环里只读不写这样 200 人同时群发只需要分配一次。3.4 断线清理先摘牌再关连接清理顺序错了会出现「已经关掉的连接还在被群发」的诡异现象。public async Task CloseAsync(ClientSession s, string reason) { if (!_sessions.TryRemove(s.Id, out _)) return; // 先摘牌之后进来的群发快照就不会包含它 s.StopAccepting(); // 通知写循环结束 try { s.Socket.Shutdown(SocketShutdown.Both); } catch { } s.Socket.Dispose(); _ Broadcast(0x05, Encoding.UTF8.GetBytes(${s.Nick} 已离线({reason}))); } // 心跳巡检每 5 秒扫一遍30 秒没有任何帧就判死 private async Task ReapLoopAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { await Task.Delay(TimeSpan.FromSeconds(5), ct); var deadline DateTime.UtcNow.AddSeconds(-30); foreach (var s in _sessions.Values) if (s.LastSeen deadline) await CloseAsync(s, heartbeat timeout); } }TryRemove的返回值当互斥标志用接收循环和对端关闭、心跳巡检可能同时触发清理只有第一次能成功摘牌后面的直接返回不会出现重复广播离线通知的情况。接收循环本身很薄private async Task ReceiveLoopAsync(ClientSession s, CancellationToken ct) { var buffer new byte[8 * 1024]; var fb new FrameBuffer(); try { while (!ct.IsCancellationRequested) { int n await s.Socket.ReceiveAsync(buffer.AsMemory(), SocketFlags.None, ct); if (n 0) break; // 返回 0 表示对端正常关闭 s.LastSeen DateTime.UtcNow; foreach (var (type, payload) in fb.Feed(buffer, 0, n)) await DispatchAsync(s, type, payload); } } catch (Exception) { } finally { await CloseAsync(s, peer closed); } }ReceiveAsync返回 0 是一个必须单独判断的分支它代表对端发了 FIN不是「暂时没数据」。漏掉这个判断连接会一直挂在会话表里越积越多。4. 客户端与端到端链路登录、心跳、群发与断线重连怎么串起来服务端只是半边群发能不能用取决于客户端有没有正确解帧、心跳有没有按时发、断线后能不能自己回来。4.1 连接与登录帧的发送时机public async Task ConnectAsync(string host, int port, string nick, CancellationToken ct) { _socket new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await _socket.ConnectAsync(host, port, ct); var login FrameCodec.Pack(0x01, Encoding.UTF8.GetBytes(nick)); await _socket.SendAsync(login, SocketFlags.None, ct); // 连上立刻报名字服务端才好广播上线 _ ReceiveLoopAsync(ct); _ HeartbeatLoopAsync(ct); }登录帧必须在接收循环启动之前发出去否则服务端推过来的「某某上线」可能在本地还没准备好接收时就到了。顺序颠倒一般不会立刻出问题但在高并发压测时就会看到个别客户端少一条通知。4.2 接收循环拆包完全复用服务端那套private async Task ReceiveLoopAsync(CancellationToken ct) { var buffer new byte[8 * 1024]; var fb new FrameBuffer(); try { while (true) { int n await _socket.ReceiveAsync(buffer.AsMemory(), SocketFlags.None, ct); if (n 0) break; foreach (var (type, payload) in fb.Feed(buffer, 0, n)) RaiseMessage(type, payload); // 抛事件给 UI别在这里碰控件 } } catch { } finally { _ ReconnectAsync(ct); } // 任何原因断开都走重连 }RaiseMessage只负责把字节转成字符串并触发事件UI 层收到后自己Invoke回主线程。WinForms 里直接在 IO 线程改TextBox.Text会抛跨线程异常这是新手最常见的翻车点之一。4.3 心跳与指数退避重连private async Task HeartbeatLoopAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { await Task.Delay(TimeSpan.FromSeconds(10), ct); // 比服务端 30 秒判死留足余量 try { await _socket.SendAsync(FrameCodec.Pack(0x04, Array.Emptybyte()), SocketFlags.None, ct); } catch { break; } } } private async Task ReconnectAsync(CancellationToken ct) { int[] delays { 1000, 2000, 4000, 8000, 15000 }; // 封顶 15 秒别无限翻倍 int i 0; while (!ct.IsCancellationRequested) { await Task.Delay(delays[Math.Min(i, delays.Length - 1)], ct); try { await ConnectAsync(_host, _port, _nick, ct); return; } catch { /* 继续下一轮 */ } } }心跳间隔 10 秒、服务端判死 30 秒这个比例是故意留的允许客户端连续丢两次心跳还不被踢。反过来的话网络稍微抖一下就全员掉线日志里一片心跳超时。4.4 端到端时序与 200 并发客户端的压测脚本一条群发消息从发出到所有端收到走的是这样一条链路客户端 A 把文本封装成 0x02 帧Send到服务端服务端接收循环Receive到字节交给FrameBuffer.Feed拆出完整帧DispatchAsync校验昵称与长度调用Broadcast(0x02, payload)Broadcast取会话快照把同一个frame依次TryWrite进每个会话的发送队列各会话的SendLoopAsync并行取帧、SendAsync发出每个客户端接收循环拆帧触发事件UI 追加一行。链路里没有任何一步是串行等待网络的所以群发耗时基本等于最慢那个客户端的网络耗时。下面这个脚本用来验证并发连接和群发是否真的成立200 个模拟客户端同时在线只收不发统计 10 秒内收到的帧数# load_test.py —— 模拟 N 个客户端上线并统计群发到达情况 import socket, struct, threading, time HOST, PORT, N 127.0.0.1, 9527, 200 counter {frames: 0} lock threading.Lock() def pack(msg_type, payloadb): return struct.pack(IB, len(payload), msg_type) payload # 大端与服务端一致 def recv_exact(s, n): buf b while len(buf) n: chunk s.recv(n - len(buf)) if not chunk: return None buf chunk return buf def client(idx): s socket.create_connection((HOST, PORT)) s.sendall(pack(0x01, fbot{idx}.encode())) while True: head recv_exact(s, 5) if head is None: break length struct.unpack(I, head[:4])[0] if length: recv_exact(s, length) with lock: counter[frames] 1 threads [threading.Thread(targetclient, args(i,), daemonTrue) for i in range(N)] for t in threads: t.start() time.sleep(3) # 等所有连接完成登录 start counter[frames] time.sleep(10) print(f10 秒内到达帧数: {counter[frames] - start}, 在线客户端: {N})脚本里的recv_exact是自己实现的「读满 n 字节」标准库的recv不保证一次读够这也是压测脚本本身的常见错误——脚本写错会让人误判服务端有 bug。跑到 200 并发、每 2 秒群发一次10 秒窗口内应该收到接近200 × 5的帧数明显偏低就说明有会话被队列丢弃或者卡在写循环里。5. 避坑与排查五个让群发卡住的现场这些问题几乎每个手写多客户端通信的人都会撞上一次提前知道长什么样排查能省半天。5.1 连接与会话相关的三个坑现象一第二台客户端连上后第一台就收不到任何消息。原因Accept写在主线程的while循环里或者用同步AcceptTcpClient()后在同一线程里处理收发第二台连上时线程被占住第一台的收发逻辑根本没机会执行。 解决接入和收发彻底分开接入只做「登记会话 起两个任务」一条连接一个读任务一个写任务主循环永不阻塞在 IO 上。现象二客户端拔网线或直接断电服务端会话表里那条连接一直不消失群发计数始终比实际在线人数多。原因TCP 是面向连接的但异常断线时对端不会发 FIN服务端ReceiveAsync就永远等在那里内核也不会主动通知。纯靠Receive返回 0 判断离线只覆盖了正常关闭这一种情况。 解决应用层心跳。客户端每 10 秒发 0x04服务端每收到任何帧就刷新LastSeen巡检任务按 30 秒阈值清理。这是唯一可靠的手段Socket.Connected属性在异常断线后仍然是true拿它做判断等于没判断。现象三压测一上来就报「目标计算机积极拒绝」。原因Listen的 backlog 设得太小200 个连接在同一秒发起握手队列溢出后面的直接被内核拒掉也可能是上一个进程没退干净端口还占着。 解决backlog 按峰值连接数的 2 倍给200 并发就填 512。端口占用用netstat -ano | findstr :9527找 PID再决定是杀进程还是换端口。开发期反复重启时建议把服务端退出流程写完整Dispose监听 Socket别靠强杀进程。5.2 收发与线程相关的两个坑现象四群发时整体延迟突然飙到几秒日志显示某个客户端「卡住」其他人也跟着慢。原因在接收循环里直接对着 Socket 同步Send做转发。某个客户端网络差、接收窗口满Send就阻塞在那里而这个线程同时还负责处理其他消息等于一个慢客户端拖垮全服务。 解决所有发送一律入队由每条连接自己的写循环消费队列设有上限满了丢最旧帧。群发方法里不出现任何网络 IO这样最差情况只是某个客户端自己丢消息不影响别人。现象五客户端界面偶发卡死或者内存一路上涨不回落。原因IO 线程直接更新控件跨线程访问在 WinForms 里表现为随机卡顿或异常内存上涨通常是事件订阅没解绑每次重连都新建一个客户端对象旧对象的接收循环还挂在旧 Socket 上。 解决收到消息只抛事件UI 层统一BeginInvoke回主线程重连前先取消旧的CancellationTokenSourceDispose旧 Socket断开事件里的订阅一并清掉。重连逻辑写完一定要连续拔插网线十次验证只看一次是看不出泄漏的。提示排查这类问题日志里带上会话 Id 和昵称比只打「发送失败」有用得多。一个 Id 一路跟到底能直接看出是入队失败、写循环异常还是被心跳清理。6. 验收与答辩四个能当场复现的测试把方案讲圆方案能不能交不看代码行数看四个测试能不能当场跑出来。6.1 四个测试用例与通过标准用例操作通过标准并发接入压测脚本起 200 个客户端全部登录成功服务端会话数等于 200群发到达每 2 秒广播一次持续 30 秒每个客户端收到 15 条误差不超过 1 条粘包拆包脚本故意把一条帧拆成 1 字节一次发送服务端解析出的消息内容与原文完全一致异常断线运行中直接拔掉一台客户端网线30 秒内该会话从在线列表消失其他人收到离线通知第三条最容易被跳过但它是唯一能证明帧协议写对了的测试。做法很简单把socket.sendall(pack(...))换成循环sendall(bytes([b]))一次发一个字节如果服务端还能正确拼出整条消息拆包逻辑就站得住。第四条要在演示前提前演练。拔网线这个动作在答辩现场不好复现通常改成在客户端进程管理器里直接结束任务效果接近服务端同样收不到 FIN。6.2 答辩时怎么讲这套东西讲的时候不要从 API 开始从第一节那个「第二台连上第一台就失联」的现象开始然后顺着三个决定往下推为什么用异步而不用每连接一线程、为什么要长度前缀帧、为什么发送要收敛成单写者。评委追问最多的一般是「为什么不用现成的消息队列」回答落在场景上——单机内网、连接数几百、只做文本广播引入中间件反而多一个部署步骤和故障点用完成端口加一个有界队列就够了。真被问到答不上来的地方别硬编。我一般会说这块只做了功能验证还没做长稳测试然后把话题带回已经跑过的四个用例上。这套东西我第一次写的时候图省事在接收循环里直接同步转发本地两台机器测着一切正常到机房三十台一起上线延迟直接飙到肉眼可见。从那以后我每次写群发都强制先把「一个慢客户端」这条用例单独跑一遍再谈别的功能。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑