简介:一套基于 C# 的 TCP 客户端多线程通信源码,面向正在学习网络编程、需要快速理解客户端收发流程的初中级开发者。代码围绕 TcpClient 与 NetworkStream 展开,演示 ASCII 或 Unicode 编码下的连接、发送与接收处理,并用多线程思路组织通信逻辑,配有 WinForms 窗体界面,可作为通信调试工具、课程设计或 Socket 入门练习的参考原型,便于理解消息收发、线程调度与界面交互之间的配合关系。压缩包共 25 个文件,约 68KB,包含 6 个 cs 源码文件以及 sln、csproj 工程配置、resx/resources 界面资源、exe 与 pdb 等编译产物,保留 Visual Studio 项目的常见目录结构,便于直接打开对照阅读。已有 1240 人学习下载,适合用来梳理 TCP 客户端基本通讯链路、数据编码和线程处理方式;需注意 GroupBox 重绘、端口自动获取等功能尚未实现,TCP 服务端部分也留待后续补充,读者可据此继续扩展。
1. 从一台工控机说起:这套 C# TCP 客户端多线程源码到底解决什么问题
去年帮人看现场问题:一台工控机上跑着 C# 写的上位机,只接一台设备时数据刷得飞快,等到要同时对接三十来台,界面就开始一顿一顿,日志里全是发送超时和半包解析失败。拆到最后发现 Socket 那几行一行没写错,错的是收、发、心跳、重连全挤在同一个线程里排队。这份基于 C# 的 TCP 客户端多线程处理源码,就是围绕这个场景组织的:连接与重连走一条循环,收包单独一条线程,发送走有界队列,心跳独立计时,业务层只拿到拆好的完整包。适合做上位机、设备采集网关、长连接 IM 客户端这类需要“一直在线、随时可断”的 C# 开发者,新手能照着跑通,熟手能直接抠出来当骨架用。
2. 项目定位与技术选型:TcpClient 打底,多线程补吞吐
先把定位说清楚,这份代码不是网络框架,是一套能直接编译运行的客户端骨架。它没有引入任何第三方包,只用了System.Net.Sockets和 BCL 里的并发容器,把“连、收、发、心跳、重连”五件事拆成互相独立的执行单元,每个单元职责单一,出问题的时候能一眼定位到是哪条线程在作妖。工程丢进空的控制台项目或者 WinForms 项目都能编译,改一个 Host 和 Port 就能连自己的服务端。
技术栈上,底层是TcpClient+NetworkStream这套最经典的组合,而不是SocketAsyncEventArgs那套高性能 API。原因很现实:设备侧协议通常很土,四字节长度头加私有命令字,用 DotNetty 这类框架反而要额外写一堆编解码器,改起来束手束脚。并发部分用ConcurrentQueue、BlockingCollection、CancellationTokenSource、Interlocked,都是 BCL 自带的东西,不增加部署负担。目标框架在 .NET Framework 4.6.1 和 .NET 6/8 上都能过,异步 API 的写法做了一点兼容处理。
2.1 目录结构:五个文件各管一摊
源码的目录组织很克制,没有为了分层而分层,基本是一个职责一个文件:
TcpClientDemo/ ├── Program.cs # 入口:配置参数、启动、停止、挂事件 ├── Core/ │ ├── TcpClientWrapper.cs # 连接管理 + 重连主循环,对外唯一入口 │ ├── ReceiveWorker.cs # 收包线程 + 粘包拆包,只读不写 │ ├── SendWorker.cs # 发送队列消费者,只写不读 │ └── HeartbeatWorker.cs # 心跳发送与超时判定 ├── Protocol/ │ ├── FrameCodec.cs # 4 字节大端长度头编解码,纯函数 │ └── PacketEventArgs.cs # 完整包事件参数 ├── Options/ │ └── ClientOptions.cs # 地址、端口、超时、缓冲、队列长度 └── Logging/ └── SimpleLogger.cs # 控制台/文件日志,可整体替换TcpClientWrapper是整个项目的外壳,它不碰业务字节,只负责“什么时候建连接、什么时候销毁连接、三条工作线程什么时候起来什么时候收”。ReceiveWorker和SendWorker各自持有一条长驻循环,互相之间唯一的交集是一个队列和一个CancellationToken。FrameCodec是纯静态方法,输入byte[]输出byte[],这种设计的好处是它能单独写单元测试,不用起服务端就能验证粘包逻辑对不对——血泪经验,粘包 bug 靠联调抓,一周都不一定抓得出来。
2.2 线程模型:收、发、心跳三条腿走路
启动入口的写法很直白,参数集中在一个ClientOptions对象里,避免构造函数堆十几个可空参数:
// Program.cs 里的启动片段 var options = new ClientOptions { Host = "127.0.0.1", Port = 9000, ReceiveBufferSize = 8 * 1024, // 单次 Read 的缓冲,不是总缓冲 HeartbeatIntervalMs = 5_000, // 心跳发送间隔 TimeoutMs = 15_000, // 超过这个时间没收到任何数据就判定断线 MaxSendQueueLength = 10_000 // 发送队列上限,满了直接拒绝 }; using var client = new TcpClientWrapper(options, new SimpleLogger()); client.PacketReceived += (s, e) => Console.WriteLine($"收到 {e.Payload.Length} 字节"); client.ConnectionChanged += (s, connected) => Console.WriteLine(connected ? "已连接" : "已断开"); client.Start(); // 非阻塞,内部起连接循环,连上后拉起收/发/心跳 Console.ReadLine(); client.Stop(); // 取消令牌 + 关连接,等三条线程退出Start()必须是非阻塞的,这一点很关键。很多初学者版本会在Start()里直接Connect()然后死循环收数据,结果 UI 线程被卡死,窗口一片白。这里的做法是Start()只创建CancellationTokenSource并用Task.Run把连接循环甩到后台,方法立刻返回。Stop()做成幂等的,重复调用不会抛异常,因为退出流程里经常会有多个地方都想停一下客户端。
参数怎么调,跟业务强相关。采集类业务心跳 10 秒、超时 30 秒足够宽松;指令下发型业务,心跳 3 秒、超时 10 秒更合适,不然设备掉线了还在那儿傻等。ReceiveBufferSize给 8KB 对高频小包偏小,每次Read返回几十字节,系统调用次数上去了 CPU 就高,我一般直接开到 32KB 或 64KB,反正这点内存不算什么。
2.3 关键参数与默认值对照
| 参数 | 默认值 | 说明 | 常见误用 |
|---|---|---|---|
Host/Port | 无 | 服务端地址端口 | 写死 IP,换环境要重编译 |
ReceiveBufferSize | 8192 | 单次 Read 缓冲大小 | 当成“总缓冲”理解,导致拆包逻辑写错 |
HeartbeatIntervalMs | 5000 | 心跳帧发送间隔 | 设得比服务端超时还长,被服务端先踢 |
TimeoutMs | 15000 | 无数据判定超时 | 设太小,大包传输中被误判断线 |
MaxSendQueueLength | 10000 | 发送队列容量 | 设成无界,断网时内存涨到 OOM |
NoDelay | true | 关闭 Nagle 算法 | 大批量传输场景关掉反而增加小包数量 |
NoDelay = true这个开关值得单独说一句。指令类业务追求低延迟,关掉 Nagle 能让几十字节的小包立刻发出去;但如果是持续推送大块数据的场景,关掉它会让网络上充满小包,吞吐反而下降。源码里默认开着,改的时候心里要有数。
3. 核心链路拆解:一个数据包从连上到交付的完整路径
这一章是整份源码最值得看的部分。从ConnectAsync建连开始,到数据包被业务回调拿走,中间经过重连循环、收包循环、拆包函数、发送队列四个环节,每个环节都有自己的失败模式。下面按链路顺序拆。
3.1 连接与重连:退避策略比 Connect 本身重要
连接主循环是整套代码的心脏。它做的其实是一件事:连上就进收包,断了就等一会儿再连,直到外部喊停:
private async Task RunAsync(CancellationToken token) { int retry = 0; while (!token.IsCancellationRequested) { try { _client = new TcpClient(); _client.NoDelay = true; // 小包指令类业务,关 Nagle 降延迟 await _client.ConnectAsync(_options.Host, _options.Port, token); _stream = _client.GetStream(); retry = 0; // 连上就重置退避计数 SetConnected(true); StartWorkers(token); // 拉起收/发/心跳三条循环 await ReceiveWorker.RunAsync(_stream, token); // 阻塞到断开 } catch (OperationCanceledException) { break; } // 正常停止,不记错误 catch (Exception ex) { _logger.Error($"连接异常:{ex.Message}"); } finally { SetConnected(false); StopWorkers(); _client?.Close(); _client?.Dispose(); _client = null; } // 指数退避:1s、2s、4s、8s……封顶 30s,避免断网时空转打满 CPU int shift = Math.Min(retry++, 5); int delay = Math.Min(30_000, 1000 << shift); try { await Task.Delay(delay, token); } catch (OperationCanceledException) { break; } } }逐段说明。ConnectAsync传入取消令牌,是为了让Stop()能在连接过程中立刻中断,否则连一个不可达的 IP 要等系统默认的二十多秒超时。retry在连接成功后必须清零,否则一个跑了一周、中间断过五次的服务,退避会一路涨到 30 秒封顶,之后每次重连都要等半分钟。finally里的清理顺序是先Close再Dispose,因为Close会尝试把发送缓冲刷出去,直接Dispose可能丢数据。
退避那段用的是位移而不是Math.Pow,1000 << shift在shift大于 5 之后会溢出,所以上面用Math.Min(retry, 5)卡住上限,最大 32000 毫秒再被Math.Min压到 30000。这个封顶值不是随手写的:断网时如果按毫秒级疯狂重连,日志会被刷爆,磁盘写满不说,CPU 也直接跑满,现场看就是“程序卡死”的假象。
注意:
ReceiveWorker.RunAsync抛出的异常就是“连接已断”的信号,别在它内部吞掉IOException,否则外层永远不知道连接没了,重连逻辑形同虚设。
3.2 收包循环与粘包拆包:一次 Read 不等于一个包
TCP 是字节流,不是消息流,这是新手翻车最集中的地方。收包循环的职责只有两件:把字节从流里读出来,把完整的包从字节里切出来:
public static async Task RunAsync(NetworkStream stream, ClientOptions opt, Action<byte[]> onPacket, CancellationToken token) { var buffer = new byte[opt.ReceiveBufferSize]; var pending = new MemoryStream(opt.ReceiveBufferSize * 2); // 半包暂存区 while (!token.IsCancellationRequested) { int n = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), token); if (n <= 0) throw new IOException("对端关闭连接"); // 0 表示优雅关闭 pending.Write(buffer, 0, n); // 一次 Read 可能带出 0 个、1 个或多个完整包,必须循环切 while (FrameCodec.TryTakePacket(pending, out byte[] payload)) { onPacket(payload); // 交给业务,注意别在这里做耗时操作 } } }参数和逻辑说明。pending用的是MemoryStream而不是List<byte>,因为List每次RemoveRange(0, n)都要把后面的元素整体前移,数据量一大就是 O(n²) 的搬运,跑一晚上内存和 CPU 都难看,这是很典型的一个坑。ReadAsync返回 0 是 TCP 的优雅关闭语义,必须当成异常抛出去触发重连,不能继续循环,否则就是死循环空转。
拆包函数本身是纯函数,长度头用四字节大端,跟绝大多数设备协议一致:
// 协议格式:[4 字节大端长度][包体],长度值不含头本身 public static bool TryTakePacket(MemoryStream pending, out byte[] payload) { payload = null; if (pending.Length < 4) return false; // 头都没收全 var head = pending.GetBuffer(); int len = (head[0] << 24) | (head[1] << 16) | (head[2] << 8) | head[3]; // 长度必须校验,否则一个脏包能把内存撑爆 if (len <= 0 || len > 4 * 1024 * 1024) throw new InvalidDataException($"非法包长 {len},疑似数据错位"); if (pending.Length < 4 + len) return false; // 半包,等下次 Read payload = new byte[len]; Buffer.BlockCopy(head, 4, payload, 0, len); pending.Seek(4 + len, SeekOrigin.Begin); // 把剩余字节搬回开头,等价于消费掉已解析部分 long remain = pending.Length - pending.Position; if (remain > 0) Buffer.BlockCopy(head, (int)pending.Position, head, 0, (int)remain); pending.SetLength(remain); pending.Seek(0, SeekOrigin.Begin); return true; }大端解析写成位运算而不是BitConverter,是为了避免小端机器上还要判断字节序,多一层心智负担。那个 4MB 的上限是防脏数据的安全阀:一旦协议错位,读出来的“长度”可能是个天文数字,没有校验就会直接new byte[20亿]然后OutOfMemoryException。这个校验加上之后,错位会立刻抛异常触发重连,比内存爆掉好排查得多。消费完剩余字节的搬移用的是BlockCopy加SetLength,比反复RemoveRange温和。
3.3 发送队列:多个线程写同一个流一定会出事
发送侧的坑比接收侧隐蔽。NetworkStream不保证并发写的原子性,两个线程同时Write,字节会在缓冲区里交错,对端看到的就是帧错位。解法只有一个:所有发送都进队列,由唯一一条线程负责写:
private readonly BlockingCollection<byte[]> _sendQueue = new(new ConcurrentQueue<byte[]>(), 10_000); // 有界队列,满了就拒 public bool Send(byte[] payload) { if (payload == null || payload.Length == 0) return false; byte[] frame = FrameCodec.BuildFrame(payload); // 补上 4 字节长度头 return _sendQueue.TryAdd(frame); // 返回 false 说明队列已满 } private async Task SendLoopAsync(NetworkStream stream, CancellationToken token) { foreach (byte[] frame in _sendQueue.GetConsumingEnumerable(token)) { try { await stream.WriteAsync(frame.AsMemory(0, frame.Length), token); await stream.FlushAsync(token); } catch (Exception ex) { _logger.Error($"发送失败:{ex.Message}"); _sendQueue.TryAdd(frame); // 放回队列,重连后重发 break; // 跳出,交给外层重连 } } }BlockingCollection的容量参数是整套代码里最容易被忽略的一个。设成无界(不传容量),断网瞬间业务还在疯狂Send,队列几分钟就能吃掉几个 G 内存,然后进程被系统杀掉,现场表现是“程序莫名其妙消失了”,查起来非常痛苦。设成有界并让TryAdd返回失败,业务层就能感知到“下游堵了”,该降频降频、该丢包丢包,主动权在自己手里。
发送失败后TryAdd放回队列这个动作,只对“连接断了但数据还有价值”的场景成立,比如上报类业务。如果是实时指令,重连后重发一条五分钟前的指令是灾难,那就别放回去,直接记日志丢弃。这个取舍要看业务,源码里留了注释提醒。
3.4 心跳与超时:判定掉线不能只靠 Socket 状态
TCP 连接在物理断开后,本地Socket.Connected可能还是true,这是玄学但有明确解释——它只反映最后一次 IO 的状态,不能实时探测。所以必须有应用层心跳:
private async Task HeartbeatLoopAsync(CancellationToken token) { while (!token.IsCancellationRequested) { await Task.Delay(_options.HeartbeatIntervalMs, token); long last = Interlocked.Read(ref _lastRecvTicks); // 收包线程写,这里读 double idleMs = (DateTime.UtcNow.Ticks - last) / 10_000.0; if (idleMs > _options.TimeoutMs) { _logger.Error($"心跳超时,已 {idleMs:F0}ms 未收到数据"); _client?.Close(); // 关掉连接,让收包线程抛异常进重连 return; } Send(FrameCodec.BuildHeartbeat()); // 心跳帧走同一个发送队列 } }心跳帧必须走发送队列,不能另开一条线程直接写流,否则又回到并发写的老问题。超时判定读的是“最后一次收到任何数据的时间”,而不是“最后一次收到心跳回应的时间”,这样普通业务数据也能刷新活跃度,一条繁忙的连接不会被误判掉线。判定超时后调用Close()而不是等ReadAsync自己返回,是因为阻塞中的读在部分框架版本上对取消令牌响应不及时,主动关连接是最靠谱的叫醒方式。
4. 并发控制:三条线程共享状态怎么不打架
多线程客户端出问题,九成不是网络问题,是共享状态没管好。这一章把源码里所有跨线程访问的点列出来,讲清楚每个点为什么这么处理。
4.1 共享状态的三种保护手段
按开销从低到高排:Volatile/Interlocked、lock、并发容器。选哪个看数据形态:
private long _lastRecvTicks; // 收包线程写,心跳线程读 private int _connected; // 0/1 状态位,多处读写 private readonly object _stateLock = new(); public bool IsConnected => Volatile.Read(ref _connected) == 1; // 收包循环每次读到数据就刷新时间戳,不加锁,用 Interlocked 保证可见性 Interlocked.Exchange(ref _lastRecvTicks, DateTime.UtcNow.Ticks); // 连接状态切换涉及多个字段的一致性,才用锁 private void SetConnected(bool value) { lock (_stateLock) { if (_connected == (value ? 1 : 0)) return; // 状态没变就不触发事件 Volatile.Write(ref _connected, value ? 1 : 0); ConnectionChanged?.Invoke(this, value); } }_lastRecvTicks用long存Ticks而不是直接存DateTime,是因为DateTime在 32 位进程里不是原子写入,读写并发时可能读到一个撕裂的值,得到一个荒唐的年份,然后超时判定永远为真,连接反复断开重连。这个 bug 极难复现,属于那种“跑了三天才出现一次”的类型。
SetConnected里加了一个“状态没变就不触发事件”的判断。少了这一句,重连风暴期间每秒钟都会触发几十次ConnectionChanged,UI 上就是日志疯狂刷屏,看起来像程序失控。
4.2 CancellationToken 贯穿:退出时别留僵尸线程
停止流程是最容易写漏的地方。只调Cancel()是不够的,因为阻塞中的网络读不一定响应取消:
private CancellationTokenSource? _cts; private int _started; // 0/1,防重复启动 public void Start() { if (Interlocked.CompareExchange(ref _started, 1, 0) == 1) return; // 已在跑 _cts = new CancellationTokenSource(); _ = Task.Run(() => RunAsync(_cts.Token)); } public void Stop() { if (Interlocked.Exchange(ref _started, 0) == 0) return; // 已停,幂等 _cts?.Cancel(); _client?.Close(); // 关键:直接关,把阻塞的 Read 打断 _sendQueue.CompleteAdding(); // 让发送循环的 foreach 自然结束 // 给工作线程最多 3 秒收尾,超时就放弃,不让退出流程卡死 _workerTask?.Wait(3000); _cts?.Dispose(); _cts = null; }顺序不能乱:先Cancel()让循环里的Task.Delay立刻返回,再Close()打断网络读,最后CompleteAdding()让发送侧退出。如果反过来先CompleteAdding,业务线程可能还在Send,直接抛InvalidOperationException。Wait(3000)这个超时是后悔药,防止某条线程卡在死循环里导致整个进程退不掉——宁可留下一个后台线程,也别让用户以为程序无响应。
后台任务用Task.Run启动的默认是线程池线程,不会阻止进程退出;但如果源码里某处用了new Thread(...)并且没设IsBackground = true,那进程就会赖着不走。这是最典型的僵尸进程来源。
4.3 事件回调与 UI 线程:封送错了就是死锁
WinForms/WPF 场景下,PacketReceived是在收包线程上触发的,直接摸控件会抛InvalidOperationException,或者更糟——不抛异常但界面不刷新。正确姿势是封送:
client.PacketReceived += (s, e) => { // 收包线程上执行,绝对不能在这里做业务处理或更新 UI if (form.IsHandleCreated && form.InvokeRequired) form.BeginInvoke(new Action(() => AppendToLog(e.Payload))); // 异步封送 else AppendToLog(e.Payload); };用BeginInvoke而不是Invoke,区别很大。Invoke是同步等待 UI 线程处理完才返回,如果 UI 线程此刻正在等一个由收包线程释放的锁,两边互相等,死锁就来了。BeginInvoke只是把消息投递到 UI 消息队列就返回,收包线程立刻继续干活。
更稳妥的做法是事件里压根不碰 UI,只把包塞进一个ConcurrentQueue,UI 侧用Timer每 100 毫秒批量取出来渲染。高频数据场景下这个差别非常明显:每条都BeginInvoke的话,UI 消息队列会被塞爆,界面反而更卡。
5. 避坑与排查:客户端多线程跑起来之后最常见的五个坑
以下五条都是真实踩过的,按“现象 → 原因 → 解决”记下来,对着查能省不少时间。
一、第一条消息正常,后面全是乱码
现象:连上服务端后收到的第一包能解析,第二包开始字段全错位,日志里出现负数长度。原因:把一次Read的返回值当成一个完整包,多个包粘在一起时只取第一段,后面的字节被当成新包的头。解决:不管Read返回多少,一律进半包缓冲,用while (TryTakePacket(...))循环切,切不出来就等下一次Read。
二、对端反馈帧错位,本地日志却一切正常
现象:客户端自己打印发送日志顺序完全正确,服务端抓到的是两个包的内容交错在一起。原因:业务代码在多个线程里直接调stream.Write,NetworkStream不保证并发写原子性。解决:所有发送统一走Send()入队,由唯一的发送循环写流,业务侧只拿到一个bool返回值。
三、关掉窗口后任务管理器里进程还在
现象:程序界面消失了,进程列表里那个 exe 还挂着,占着端口。原因:收包线程阻塞在ReadAsync上没被唤醒,或者某处用了前台线程。解决:Stop()里先Cancel()再Close()连接,双保险;所有长驻循环用Task.Run或显式IsBackground = true;加一个Wait(3000)兜底。
四、断网后 CPU 跑到 100%,日志刷屏
现象:拔掉网线,客户端疯狂重连,日志文件几分钟涨到几百兆,风扇狂转。原因:重连没有退避,while(true)里连着Connect失败就立刻重试。解决:指数退避加 30 秒封顶,同时把重连日志做限流,同一类错误每分钟最多记一条。
五、跑一晚上内存涨到几个 G
现象:白天测试正常,挂机一夜之后内存曲线一路上扬不回落。原因有两个:发送队列无界,断网期间业务继续Send全堆在内存里;接收侧用List<byte>加RemoveRange(0, n)消费,大量临时数组和大对象堆碎片。解决:队列设容量上限并用TryAdd拒绝,接收侧改MemoryStream或环形缓冲复用同一块内存。
提示:排查这类问题,第一步永远是打开日志看“最后一次成功收包是什么时候”,时间戳比任何堆栈都直观。养成在收包处打时间戳的习惯,能省掉一半的猜测。
6. 二次开发改造:把骨架改成能上生产的样子
拿到这套源码,我一般不会直接改业务,先做三件事,做完之后它才敢放到现场跑。
第一件是把协议抽成接口。FrameCodec现在是静态方法写死 4 字节大端头,实际项目里可能遇到 2 字节小端、带命令字、带 CRC 校验的各种变体。做法是定义一个IFrameCodec,把TryTakePacket和BuildFrame抽出来,在ClientOptions里注入,这样同一套收发循环能复用到几个不同协议的项目上,不用每次复制粘贴再改。
第二件是补可观测性。至少加三个计数:累计收包数、累计发包数、当前发送队列长度,再加一个“最近一次收包距今毫秒数”。这四个指标在排查现场问题时价值极高——队列长度持续上涨说明下游堵了,最近收包时间变大为心跳超时前兆,比看日志猜快得多。
第三件是用本地假服务器做回归。粘包逻辑不联调是测不出来的,所以我习惯写一个几十行的TcpListener,专门干一件事:把两个包合并在一次Write里发出去,把第三个包拆成三次发。跑一遍,如果客户端拆包和组包都对,这套代码基本就稳了。
// 本地回环假服务器:故意制造粘包和半包,验证拆包逻辑 var listener = new TcpListener(IPAddress.Loopback, 9000); listener.Start(); using var c = await listener.AcceptTcpClientAsync(); var s = c.GetStream(); byte[] a = FrameCodec.BuildFrame(Encoding.UTF8.GetBytes("packet-A")); byte[] b = FrameCodec.BuildFrame(Encoding.UTF8.GetBytes("packet-B")); s.Write(a.Concat(b).ToArray()); // 粘包:两个完整包一次发出 await s.FlushAsync(); await Task.Delay(200); byte[] half = FrameCodec.BuildFrame(Encoding.UTF8.GetBytes("packet-C")); s.Write(half, 0, 3); // 半包:先发 3 字节头 await s.FlushAsync(); await Task.Delay(200); s.Write(half, 3, half.Length - 3); // 补齐剩余,触发半包拼接 await s.FlushAsync();改造时还有一个小技巧:把TimeoutMs做成可热更新。现场调参很常见,设备型号不同、网络质量不同,超时值要跟着变,做成配置项重启生效已经算合格,能通过一个管理接口直接改内存里的值就更省事。但改的时候要注意别在心跳线程读到一半值的时候被换掉,ClientOptions里几个数值字段用volatile或者干脆整体替换引用,别原地改字段。
从那以后我每接一个 TCP 客户端,都强制先跑一遍那个假服务器的粘包用例,再谈业务逻辑。希望帮到你。
本文还有配套的精品资源,点击获取