
简介这份源码面向具备一定C#基础、希望掌握网络编程与多线程处理的开发者聚焦TCP客户端的数据收发场景。作者基于微软TCPClient控件与NetworkStream流操作思路实现了ASCII码与Unicode码两种编码下的基本通讯功能并引入多线程机制处理并发收发可作为学习Socket通信与线程调度的参考范例。压缩包共25个文件约68KB包含6个cs源码文件、3个exe可执行程序、2个resx资源文件及sln解决方案、csproj工程文件、settings配置等覆盖从工程结构到编译产物的完整内容便于直接打开运行与调试。目前已有1238人学习下载。读者可从中获取TCP客户端连接建立、数据流读写、多线程收发分离等核心实现思路同时作者也说明groupbox重绘与端口自动获取尚未完成TCP server部分将在后期补充适合作为二次开发与功能扩展的起点。1. 从一条产线报警说起C# TCP 客户端多线程到底在解决什么去年帮朋友看一条装配线的上位机现象很典型界面每隔十几秒卡死一次日志里一堆SocketExceptionPLC 那边的数据还断断续续。翻代码发现一个TcpClient在主线程里同步Read读一次处理一次只要对端慢半拍UI 线程就跟着一起等。这不是个例很多用 C# 写 TCP 客户端的项目第一版都是这么写的能跑通但一上量就翻车。这个标题讲的就是这件事用 C# 写一个 TCP 客户端并且用多线程把「连接、收发、处理」拆开让界面不卡、吞吐上得去、异常不互相拖累。它适合两类人一类是做工控上位机、设备网关、数据采集的需要长期稳定地跟几十上百个服务端或设备保持连接另一类是刚接触System.Net.Sockets想搞清楚同步、异步、线程池之间到底怎么配合的开发者。核心不是「多线程」这三个字而是连接生命周期、收发解耦、异常隔离这三件事怎么落地。2. 选型先立住C# TCP 客户端为什么绕不开多线程2.1 单线程同步模型的三个硬伤先说清楚为什么不能只用一个线程。最朴素的写法是这样// 反面示例主线程里同步收发 var client new TcpClient(); client.Connect(192.168.1.10, 502); var stream client.GetStream(); while (true) { var buffer new byte[1024]; int n stream.Read(buffer, 0, buffer.Length); // 阻塞点 Process(buffer, n); // 处理也可能慢 }这段代码有三个问题。第一Connect和Read都是阻塞调用网络一抖动调用线程就被挂住如果这是 UI 线程界面直接假死。第二Process和Read串在同一个循环里处理一条报文要 50ms那接收速率就被压到 20 条/秒跟网络能力无关。第三任何一次Read抛异常整个循环退出连接就废了没有重连、没有隔离。提示TcpClient.ReceiveTimeout只能让Read超时返回不能解决阻塞占用线程的问题别把它当成多线程的替代品。2.2 多线程方案的分层连接、收发、处理各归各合理的结构是把职责拆成三层。连接层负责建立、维持、重连收发层负责从NetworkStream读写字节处理层负责解析协议、落库、更新界面。这三层用不同的线程或线程池任务承载中间用队列衔接。层次承载方式关键点连接管理独立线程或Task循环断线检测、退避重连接收专用读线程 /ReadAsync只做读不做业务处理线程池 / 消费者线程可并行与读解耦发送发送队列 单写线程避免多线程同时写同一流为什么发送要单独一个写线程因为NetworkStream的写操作不是线程安全的多个线程同时Write会导致字节交错报文直接损坏。常见做法是搞一个BlockingCollectionbyte[]当发送队列一个后台线程专门从队列取数据往流里写。2.3 同步多线程 vs 异步怎么选C# 里做 TCP 客户端绕不开「用Thread还是用async/await」。我的经验是接收侧优先用异步因为ReadAsync不占线程几十个连接也扛得住处理侧用线程池或专用消费者线程因为业务处理往往是 CPU 或 IO 混合需要控制并发度。// 接收侧异步读不阻塞线程 private async Task ReceiveLoopAsync(NetworkStream stream, CancellationToken token) { var buffer new byte[4096]; while (!token.IsCancellationRequested) { int n await stream.ReadAsync(buffer, 0, buffer.Length, token); if (n 0) break; // 对端关闭 var data new byte[n]; Buffer.BlockCopy(buffer, 0, data, 0, n); _receiveQueue.Add(data); // 交给处理层 } }ReadAsync返回 0 表示对端正常关闭这是判断断线最可靠的方式比靠Socket.Connected靠谱得多——那个属性只反映最后一次 IO 的状态经常骗人。3. 动手实现一个能跑的多线程 TCP 客户端骨架3.1 连接管理与退避重连连接不能一断就立刻重连否则对端没起来时会把 CPU 打满。用指数退避private async Task ConnectLoopAsync(CancellationToken token) { int retry 0; while (!token.IsCancellationRequested) { try { var client new TcpClient(); await client.ConnectAsync(_host, _port); retry 0; // 连上就重置 _client client; await ReceiveLoopAsync(client.GetStream(), token); } catch (Exception ex) { Log.Warn($连接异常: {ex.Message}); } // 退避1s, 2s, 4s... 上限 30s int delay Math.Min(1000 * (1 retry), 30000); retry; await Task.Delay(delay, token); } }1 retry是位移做 2 的幂retry每次加一延迟翻倍Math.Min封顶 30 秒。参数上初始 1 秒适合局域网设备广域网可以调到 2 秒起。CancellationToken贯穿全程程序退出时能干净地停掉所有循环不然进程会挂着不退。3.2 接收与处理解耦队列 消费者接收线程只管把字节塞进队列处理线程从队列取。用BlockingCollection最省事它自带阻塞和线程安全private readonly BlockingCollectionbyte[] _receiveQueue new(new ConcurrentQueuebyte[](), 10000); // 消费者线程 private void ProcessLoop(CancellationToken token) { foreach (var data in _receiveQueue.GetConsumingEnumerable(token)) { try { var frame ParseFrame(data); // 解析协议 HandleFrame(frame); // 业务处理 } catch (Exception ex) { Log.Error($处理失败: {ex.Message}); // 单条失败不影响后续 } } }GetConsumingEnumerable会在队列空时阻塞有数据就唤醒比手写while TryTake Sleep干净。容量设 10000 是防止对端狂发时内存爆掉满了之后Add会阻塞接收线程形成天然背压。try/catch放在循环体内保证一条报文解析失败不会让整个消费者线程退出——这是血泪经验早期版本一个格式错误的报文直接把处理线程干掉了。3.3 发送队列与单写线程发送侧同样用队列保证同一时刻只有一个线程写流private readonly BlockingCollectionbyte[] _sendQueue new(new ConcurrentQueuebyte[](), 10000); private async Task SendLoopAsync(NetworkStream stream, CancellationToken token) { foreach (var payload in _sendQueue.GetConsumingEnumerable(token)) { try { await stream.WriteAsync(payload, 0, payload.Length, token); await stream.FlushAsync(token); } catch (Exception ex) { Log.Error($发送失败: {ex.Message}); break; // 写失败通常意味着连接已断退出让重连接管 } } } // 外部调用只管入队 public void Send(byte[] data) _sendQueue.Add(data);FlushAsync对NetworkStream其实不是必须的它不缓冲但写上无害换到带缓冲的流时能省事。发送失败直接break而不是继续因为流一旦出错后续写基本都会失败交给连接层重连更合理。3.4 参数怎么设缓冲区、并发度、超时几个容易拍脑袋定的参数给个参考区间参数建议值说明接收缓冲区4096~8192 字节太小增加系统调用太大浪费内存队列容量5000~20000按峰值速率 × 处理耗时估算处理并发度1~4业务有共享状态时用 1纯计算可加连接超时3~5 秒ConnectAsync配合CancellationToken重连上限延迟30 秒避免对端长期不在时空转处理并发度不是越高越好。如果HandleFrame里要更新同一个Dictionary或写同一个文件多线程反而要加锁不如单消费者。要并行就用多个消费者线程但共享状态必须自己保证线程安全。4. 避坑与排查多线程 TCP 客户端最容易翻车的地方4.1 现象界面偶尔卡一下日志显示处理耗时正常原因通常是BlockingCollection.Add在队列满时阻塞了接收线程而接收线程如果恰好是 UI 线程调起来的就会卡界面。解决接收循环永远跑在后台线程或线程池任务里绝不在 UI 线程上await一个长循环队列容量调大或者监控队列长度超过阈值就告警。4.2 现象报文偶尔错位、解析出乱码原因多半是多个线程同时往同一个NetworkStream写。解决发送必须走单写线程或加锁别图省事在业务代码里直接stream.Write。另外粘包问题也要处理TCP 是字节流没有消息边界得靠长度前缀或分隔符自己切帧。4.3 现象程序退出后进程还在任务管理器里杀不掉原因是有后台线程或Task没响应取消。解决所有循环都接收同一个CancellationToken退出时Cancel()并且对BlockingCollection调用CompleteAdding()让GetConsumingEnumerable正常结束。Task.Delay也要传 token否则会等满延迟才退出。4.4 现象连接看着是通的但收不到数据原因可能是Socket.Connected给了假信号或者对端半关闭。解决靠ReadAsync返回 0 判断断线配合心跳包——定时往发送队列塞一个心跳帧连续几个周期没收到对端响应就主动断开重连。心跳间隔一般 5~30 秒看网络质量。4.5 现象CPU 占用高但吞吐没上去原因常见于忙等待比如while(true)里没有阻塞点或者Task.Delay时间设得太短。解决确认接收用ReadAsync、消费用GetConsumingEnumerable这些都是阻塞式等待不占 CPU。如果自己写了轮询加个最小延迟。5. 进阶把客户端做成可观测、可压测的组件骨架跑通之后真正决定这套代码能不能上产线的是它好不好观测、扛不扛压。我一般会加三样东西。第一是计数器。用Interlocked维护几个关键指标接收字节数、发送字节数、队列当前长度、重连次数、处理异常次数。这些数字定时打到日志或暴露给监控出问题时一眼能看出是网络断、处理慢还是队列堵。private long _receivedBytes; private long _reconnectCount; // 接收循环里 Interlocked.Add(ref _receivedBytes, n); // 重连时 Interlocked.Increment(ref _reconnectCount);Interlocked比lock轻适合这种简单累加。队列长度可以直接读_receiveQueue.CountBlockingCollection的Count是线程安全的。第二是压测。别等上线才发现吞吐不够。本地起一个回环服务端用TcpListener狂发数据看客户端处理速率和内存增长。压测时重点看两个数队列长度是否持续上涨说明处理跟不上接收以及 GC 频率说明分配太频繁。如果队列一直涨要么加消费者要么优化ParseFrame的分配——比如用ArrayPoolbyte复用缓冲区减少new byte[]。第三是优雅关闭。退出时按顺序来先Cancel()停止接收再CompleteAdding()让消费者排空等消费者线程Join完最后Close连接。顺序错了会丢数据或者卡住。我习惯把关闭逻辑封成一个StopAsync内部用Task.WhenAll等所有循环任务结束超时 5 秒强制返回。public async Task StopAsync() { _cts.Cancel(); _receiveQueue.CompleteAdding(); _sendQueue.CompleteAdding(); await Task.WhenAll(_tasks).WaitAsync(TimeSpan.FromSeconds(5)); _client?.Close(); }WaitAsync是 .NET 6 之后的方法老版本用Task.WhenAny配Task.Delay也能实现。这套关闭流程我踩过坑早期没等消费者排空就Close结果最后几条报文丢了对端以为我们处理完了实际没有。最后说个习惯这套代码我会先在本地回环上跑够 24 小时模拟断线、慢处理、大报文确认重连和队列都稳再上真实设备。多线程 TCP 客户端的坑八成都能在本地压出来别指望现场调试。希望帮到你。本文还有配套的精品资源点击获取