简介:这份源码面向具备一定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 客户端多线程到底在解决什么
去年帮朋友看一条装配线的上位机,现象很典型:界面每隔十几秒卡死一次,日志里一堆SocketException,PLC 那边的数据还断断续续。翻代码发现,一个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会导致字节交错,报文直接损坏。常见做法是搞一个BlockingCollection<byte[]>当发送队列,一个后台线程专门从队列取数据往流里写。
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 BlockingCollection<byte[]> _receiveQueue = new(new ConcurrentQueue<byte[]>(), 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 BlockingCollection<byte[]> _sendQueue = new(new ConcurrentQueue<byte[]>(), 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.Count,BlockingCollection的Count是线程安全的。
第二是压测。别等上线才发现吞吐不够。本地起一个回环服务端,用TcpListener狂发数据,看客户端处理速率和内存增长。压测时重点看两个数:队列长度是否持续上涨(说明处理跟不上接收),以及 GC 频率(说明分配太频繁)。如果队列一直涨,要么加消费者,要么优化ParseFrame的分配——比如用ArrayPool<byte>复用缓冲区,减少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 客户端的坑,八成都能在本地压出来,别指望现场调试。希望帮到你。
本文还有配套的精品资源,点击获取