
简介面向学习 C# 网络编程与多线程通信的初中级开发者这份源码围绕 TCP 客户端收发数据的基础流程展开使用 TcpClient 类配合 NetworkStream 流操作实现基于 ASCII 或 Unicode 编码的发送与接收可用于调试工具原型、课程实验或二次开发参考。压缩包共 25 个文件含 6 个 cs 源码、3 个 exe、resx/resources 界面资源以及 sln/csproj 工程配置等整体约 68KB便于直接打开解决方案梳理窗体、程序入口和通信逻辑。已有 1240 人学习下载作为入门级 TCP 客户端案例具备一定参考价值。代码保留了 GroupBox 重绘、端口自动获取等未完成项读者可据此理解基础通讯实现并继续补全功能或排错TCP server 部分暂未包含适合先聚焦客户端多线程处理思路。1. 一条阻塞的 Receive 导致界面卡死这份 C# TCP 客户端多线程源码要解决什么去年帮人看一个设备采集的上位机连 8 台控制器界面每隔十几秒卡一下点关闭按钮要等好几秒进程才退得掉。追到最后接收数据写成了主线程里的while(true)阻塞读典型的不带多线程的写法直接导致整套交互失效。这份 C# TCP 客户端多线程处理源码针对的正是这个场景把建连、收、发三条链路拆到各自的线程上UI 线程只负责界面更新拆包、发送排队、断线重连各归各管。它更适合两类人一类是手里已经有个能连上、但跑久了就出问题的客户端 Demo准备往生产环境推另一类是把 Socket 基础语法看完想看看一个不那么玩具的客户端该怎么分层。源码体量不大但粘包、假在线、进程退不掉这些坑基本都覆盖到了。2. 源码结构拆解Core 与 App 分层的目录组织拿到一份客户端源码先不要急着从Main往下读按目录看一遍分层比逐行需要理解的代码高效得多。这份源码的整体思路是把「跟网络有关的一切」和「跟界面有关的一切」彻底切开Core 里没有一个TextBoxApp 里也没有一个Socket。分层做好之后换 WinForms 还是控制台宿主Core 一行都不用改动。2.1 目录结构Core 与 App 拆开的实际收益TcpClientDemo/ ├── src/ │ ├── TcpClientDemo.Core/ # 纯网络层可被控制台/WinForms/WPF 复用 │ │ ├── Connection/ │ │ │ ├── TcpConnection.cs # 建连、断开、状态机主体 │ │ │ ├── ConnectionState.cs # 枚举Idle/Connecting/Connected/Closing │ │ │ └── ReconnectPolicy.cs # 指数退避重连含最大次数与抖动 │ │ ├── Protocol/ │ │ │ ├── FrameDecoder.cs # 粘包/半包处理喂多少吐多少整包 │ │ │ ├── FrameEncoder.cs # 组包4 字节长度头 包体 │ │ │ └── IMessageHandler.cs # 业务回调接口收到整包后往外抛 │ │ ├── IO/ │ │ │ ├── ReceiveLoop.cs # 接收线程独占读流 │ │ │ └── SendQueue.cs # 发送队列 单写线程 │ │ └── Diagnostics/ │ │ └── TrafficCounter.cs # 收发字节数、包数、速率统计 │ └── TcpClientDemo.App/ # 宿主程序 │ ├── Program.cs # 组装依赖、启动连接 │ └── appsettings.json # Host/Port/超时/心跳间隔 └── tests/ └── TcpClientDemo.Tests/ # 用本地监听端口做回环测试这个划分带来的直接好处是测试成本。FrameDecoder是个纯函数式的类喂字节数组进去、吐整包出来单测里根本不需要开端口几十行 xUnit 就能把半包、粘包、超长包全跑一遍。真要用真实连接验证tests目录里起一个本地TcpListener往里面灌预设字节序列比对着真设备抓包快太多。我一般在改协议解析之前先把这些用例跑通再动 Core 里的代码。appsettings.json单独拿出来也是刻意的。现场调试最常见的情况是换了个设备、IP 变了如果这些参数硬编码在Program.cs里每次都要重新编译一次交接给别人还得重新教一遍怎么改代码。配置外置之后运维自己改个 JSON 重启就行。2.2 并发模型三条线程两个队列一次读写读这份源码最关键的一步是先在心里画清线程模型。它不是「每个请求开一个线程」那种粗暴做法而是固定三条线程各司其职线程归属职责关键约束接收线程ReceiveLoop独占NetworkStream.Read拆包后回调业务只有它能读流别人不许碰发送线程SendQueue从队列取整包独占Write只有它能写流天然串行心跳线程TcpConnection定时入队心跳包、检查最后收包时间只入队不直接写流两个队列分别是发送用的BlockingCollectionbyte[]以及业务侧接包的委托回调。这里有个容易忽略的设计点——接收线程不直接执行业务逻辑。它拆出整包后把包丢进业务侧的事件里由宿主决定是丢线程池处理还是塞进 UI 队列。这么做的原因是接收线程一旦被业务代码拖慢TCP 窗口就会堆满服务端那边看到的现象是「你客户端不消费」严重时直接把你踢掉。我见过把数据库写入放在接收线程里的写法几百台设备一上来队列积压直接把整个客户端拖到假死。心跳线程是独立的一条而不是挂在接收线程的定时器上。原因很实在接收线程是阻塞在Read上的它没法同时干别的事。要发心跳只能是从外面往发送队列里塞让发送线程去写。2.3 把源码跑起来目标框架、连接参数与首次连通验证跑起来这件事没什么玄学但顺序别搞反。先确认 SDK 版本源码里用到了CancellationTokenSource.CreateLinkedTokenSource和BlockingCollection的TryAdd超时重载net6.0及以上都能编net8.0打开Nullableenable/Nullable也不会报一堆警告。# 1. 还原依赖并编译先确认能过编译再谈跑通 dotnet restore dotnet build -c Debug # 2. 改配置指向你自己的服务端或本地测试监听 # appsettings.json 里改 Host / Port / ConnectTimeoutMs # 3. 启动宿主 dotnet run --project src/TcpClientDemo.App第一次验证不要直接连生产设备。我一般会在本机起一个最简单的TcpListener收到什么就打什么先确认「连得上、发得出、收得回」这三件事成立再去接真设备。因为一旦连的是真设备出问题时你分不清是客户端写错了还是设备协议对不上排查面会扩大一倍。验证时重点看三处日志连接状态从Connecting变成Connected的耗时、TrafficCounter里收发的字节数是否双向增长、断网再插回网线后是否自动回到Connected。这三个都过了才算真正跑通而不是「窗口没报错」就算成功。3. 收发核心链路建连、拆包、发送队列的三段实现这一章是整份源码里最值得逐行看的部分。建连只做一次出错概率低但失败原因最难猜拆包是每次都跑的热路径写错就整段数据错位发送队列看着简单实际是并发问题最集中的地方。3.1 建连NoDelay、连接超时与 KeepAlive 三件套public async Task ConnectAsync(CancellationToken outer Ct) { // 用 CAS 抢占状态防止用户连点两下按钮开出两条连接 if (Interlocked.CompareExchange(ref _state, (int)ConnectionState.Connecting, (int)ConnectionState.Idle) ! (int)ConnectionState.Idle) return; using var timeoutCts CancellationTokenSource.CreateLinkedTokenSource(outerCt); timeoutCts.CancelAfter(_options.ConnectTimeoutMs); // 常见取值 3000~5000ms var client new TcpClient(); try { client.NoDelay true; // 关 Nagle小指令包场景下延迟改善非常明显 await client.ConnectAsync(_options.Host, _options.Port, timeoutCts.Token); // 内核层保活只在长时间空闲时才起作用不能替代应用层心跳 client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); _client client; _stream client.GetStream(); _sendQueue new SendQueue(_stream); _sendQueue.OnWriteFailed OnTransportBroken; // 写失败统一走断线处理 _receiveLoop new ReceiveLoop(_stream, _decoder, OnFrameReceived); _receiveLoop.Start(); Volatile.Write(ref _state, (int)ConnectionState.Connected); } catch (OperationCanceledException) when (!outerCt.IsCancellationRequested) { // 是超时不是用户取消两者日志必须分开否则排查时满屏 Canceled 看不出所以然 SetState(ConnectionState.Idle); throw new TimeoutException( $连接 {_options.Host}:{_options.Port} 超过 {_options.ConnectTimeoutMs}ms 未响应); } catch { SetState(ConnectionState.Idle); client.Dispose(); throw; } }Interlocked.CompareExchange这一句是防重复连接的。听起来像是多余的保护实际上界面按钮双击、快捷键连按、重连定时器和手动重连撞在一起这三种情况我在现场都遇到过。没有这道 CAS就会出现两条连接同时存在服务端看到两个会话数据一半走这条一半走那条现象诡异到怀疑人生。NoDelay true值得单独说。TCP 默认开着 Nagle 算法会把小包攒一攒再发对文件传输友好对「发一条 12 字节的查询指令等回复」这种一问一答的模式就是灾难延迟能从几毫秒抖到几十毫秒。设备控制类客户端基本都该关掉。catch (OperationCanceledException) when (!outerCt.IsCancellationRequested)这个异常过滤器是血泪经验。ConnectAsync超时和用户主动取消抛的都是OperationCanceledException如果不加这个when区分日志里全是一模一样的记录你永远不知道是网络不通还是有人点了取消。KeepAlive那个选项要摆正预期它由操作系统按自己的节奏探测默认两小时才发第一个探测包改注册表能缩短但不该指望它。真正的在线判定要靠 4.2 节的应用层心跳。3.2 接收线程定长头 包体的拆包循环public sealed class FrameDecoder { private readonly Listbyte _buffer new(); private const int HeaderSize 4; // 4 字节小端长度只描述包体 private const int MaxFrameSize 1024 * 1024; // 1MB 上限防止对端把内存吃光 // 只由接收线程调用所以内部不需要任何锁 public IEnumerablebyte[] Feed(byte[] chunk, int count) { _buffer.AddRange(new ReadOnlySpanbyte(chunk, 0, count).ToArray()); while (true) { if (_buffer.Count HeaderSize) yield break; // 连头都不够等下次 int bodyLen BitConverter.ToInt32(_buffer.ToArray(), 0); if (bodyLen 0 || bodyLen MaxFrameSize) throw new InvalidDataException($非法包长 {bodyLen}对端不可信直接断链); if (_buffer.Count HeaderSize bodyLen) yield break; // 半包攒够再来 var frame new byte[bodyLen]; _buffer.CopyTo(HeaderSize, frame, 0, bodyLen); _buffer.RemoveRange(0, HeaderSize bodyLen); yield return frame; } } }这个Feed方法的核心思想是「喂进去的是碎片吐出来的是整包」调用方完全不用关心 TCP 怎么切分字节流。yield return让它一次调用可以吐出多个包也能一个都不吐。MaxFrameSize这道校验不是可选项。我遇到过一次服务端 bug长度头写的值是一个未初始化变量客户端按那个数字去new byte[]直接OutOfMemoryException把进程带走。加个上限超了就当协议错误断开比进程崩掉好处理一百倍。_buffer.ToArray()在这里每次都复制一遍是个明确的性能短板。数据量小的时候无所谓一旦单连接跑到几 MB/s这个分配会带来明显的 GC 压力。真要优化改成定长byte[]加读写指针用BinaryPrimitives.ReadInt32LittleEndian(_buffer.AsSpan(_readPos, 4))直接读零分配。源码保持Listbyte是为了可读性二次开发上量之前记得换掉。还有一个细节这个类故意不做线程安全。因为它只被接收线程一个人调用加锁纯属浪费。如果哪天你想把它挪到别处复用务必确认调用方只有一个线程否则_buffer会被两个线程同时AddRange导致数据错乱——这种 bug 的复现概率极低排查起来极其痛苦属于典型的「加锁一时爽不加火葬场」。3.3 发送链路BlockingCollection 单写线程与背压处理public sealed class SendQueue : IDisposable { private readonly BlockingCollectionbyte[] _queue new(new ConcurrentQueuebyte[](), 4096); // 有界宁可丢也不能无限涨 private readonly Thread _writer; private readonly Stream _stream; private volatile bool _disposed; public event ActionException? OnWriteFailed; public SendQueue(Stream stream) { _stream stream; _writer new Thread(WriteLoop) { IsBackground true, // 关键不设的话关窗后进程还挂在任务管理器里 Name tcp-send }; _writer.Start(); } public bool Enqueue(byte[] frame) { if (_disposed) return false; try { // 200ms 拿不到位置就说明下游堵了返回 false 让业务侧感知背压 return _queue.TryAdd(frame, 200); } catch (InvalidOperationException) { return false; } // CompleteAdding 之后的竞态 } private void WriteLoop() { foreach (var frame in _queue.GetConsumingEnumerable()) { try { _stream.Write(frame, 0, frame.Length); _stream.Flush(); } catch (Exception ex) when (ex is IOException or ObjectDisposedException) { // 写失败基本等于连接已断交给状态机处理绝不在这里原地重试 OnWriteFailed?.Invoke(ex); break; } } } public void Dispose() { _disposed true; _queue.CompleteAdding(); // 让 GetConsumingEnumerable 自然结束 _writer.Join(500); // 给写线程 500ms 收尾超时就不等了 _queue.Dispose(); } }为什么不让多个业务线程直接调_stream.Write因为NetworkStream.Write不保证原子性两个线程同时写字节会交错在一起对端收到的就是一坨无法解析的东西。这种 bug 在压力测试下才冒头平时跑得好好的上线之后偶发解析失败属于最难查的一类。BlockingCollection构造时传 4096 的容量上限是刻意的背压设计。队列无界的话服务端卡住不读、客户端还在拼命入队内存会一路涨到 OOM。有了上限TryAdd超时返回false业务侧就能知道「发不进去了」该降频降频、该报警报警。这个返回值千万别忽略我见过调用方直接_ Enqueue(x)丢掉返回值结果队列满了以后数据静默丢失用户反馈「指令偶尔不生效」查了三天。IsBackground true一行看着不起眼但它就是 5.3 节那个「关窗后进程还在」的直接解药。前台线程不结束进程就退不掉Application.Exit()都拦不住。4. 状态机与心跳重连连接生命周期该怎么收尾收发链路跑通之后剩下的问题全是「异常路径」网络抖一下、设备重启一次、用户点了关闭。这部分代码写得好不好决定了客户端能不能在现场无人值守地跑上几个月。4.1 状态机四个状态卡住重复连接与脏状态public enum ConnectionState { Idle, Connecting, Connected, Closing } private int _state (int)ConnectionState.Idle; private void SetState(ConnectionState next) { var prev (ConnectionState)Interlocked.Exchange(ref _state, (int)next); if (prev next) return; // 状态跃迁统一从这里出去UI 订阅这一处就够了不用到处散落刷新代码 StateChanged?.Invoke(prev, next); }四个状态够用不要贪多。Idle是干净态Connecting是中间态这个态最容易被漏掉漏了就会出现「连接中又点一次连接」的双开问题Connected是可收发态Closing是正在拆资源、拒绝新入队的态。所有跃迁集中到SetState一个出口好处是 UI 只需要订阅StateChanged一个事件。我接手过一份代码按钮颜色在七个地方各改一次最后没人说得清哪个状态该显示什么颜色。ReconnectPolicy里我一般用指数退避加抖动1s、2s、4s、8s上限 30s每次加个 ±20% 的随机抖动。抖动这玩意儿看着玄学其实很实在——二十个客户端同时掉线如果退避时间完全一致它们会同时扑向服务端把刚起来的服务再压垮一次。public TimeSpan NextDelay(int attempt) { var baseMs Math.Min(1000 * Math.Pow(2, attempt), 30_000); var jitter baseMs * 0.2 * (Random.Shared.NextDouble() * 2 - 1); // ±20% return TimeSpan.FromMilliseconds(baseMs jitter); }4.2 心跳与断线判定别把 Poll 当万能钥匙TCP 有一个反直觉的地方网线拔掉之后Read并不会立刻报错。因为对端没有发 FIN内核根本不知道链路已经没了你的客户端会一直以为自己在线。这就是现场最常见的「假在线」——界面上绿灯亮着数据一条不来。解决办法是应用层心跳两头配合客户端每 N 秒入队一个心跳包同时记录_lastRecvTicks接收线程每收到任何一个包包括心跳回包就刷新这个时间戳再用一个定时检查超过 3 个心跳周期没收到任何数据就判定断线主动Close并触发重连。private void CheckAlive() { var idle Environment.TickCount64 - Interlocked.Read(ref _lastRecvTicks); if (idle _options.HeartbeatIntervalMs * 3L) { // 主动断开Close 会让阻塞中的 Read 抛异常退出接收线程才收得回来 CloseTransport(new TimeoutException($心跳超时 {idle}ms判定链路失效)); _ ReconnectAsync(); return; } _sendQueue?.Enqueue(FrameEncoder.Heartbeat()); // 只入队绝不直接写流 }取 3 倍周期而不是 1 倍是给网络抖动留的余量。取太小会频繁误判重连尤其无线链路一次瞬时丢包就断一次体验比不断还差。有人喜欢用Socket.Poll配SelectRead判断连接是否活着这个做法只在特定场景下有效比如对端已发 FIN拔网线它是测不出来的。把它当辅助手段可以当唯一判据就是给自己挖坑。4.3 退出顺序取消、CompleteAdding、Join 谁先谁后关闭顺序错了轻则进程退不掉重则退出时抛一堆ObjectDisposedException日志脏得没法看。正确的顺序是固定四步public async Task CloseAsync() { if ((ConnectionState)_state ConnectionState.Closing) return; SetState(ConnectionState.Closing); _reconnectCts?.Cancel(); // 1. 先掐重连定时器别让它在拆的时候又连一条 _sendQueue?.Dispose(); // 2. CompleteAdding Join 写线程把队列里剩的写完 _receiveLoop?.Stop(); // 3. 关流/关 socket让阻塞 Read 抛异常退出 _client?.Dispose(); // 4. 最后释放连接本体 SetState(ConnectionState.Idle); }顺序背后的逻辑是「先停生产者再停消费者最后拆管道」。_reconnectCts必须第一个取消否则重连定时器可能在_client.Dispose()之后触发拿着一个已经释放的TcpClient去Connect抛出来的异常完全对不上号。第 3 步的Stop()内部就是_client.Close()靠它让阻塞在Read上的线程抛IOException或ObjectDisposedException退出来。这是关闭阻塞读线程最省事的方式比CancellationToken更直接——NetworkStream.Read的同步版本本来就不吃取消令牌。如果宿主是 WinFormsFormClosing里别用await CloseAsync()死等要CloseAsync().GetAwaiter().GetResult()又容易和 UI 线程打架。我的习惯是在FormClosing里先e.Cancel true异步关完再调Close()避免界面卡在关闭按钮上。5. 避坑记录粘包、假在线、进程残留的现场排查这一节全是我在别人代码和自写代码上真实见过的现象按「现象 → 原因 → 解决」写。如果你现在正好卡在其中一个直接对号入座。5.1 现象数据一多就整段错位解析全乱现象小包测试全对压力一大就偶尔蹦出「未知指令 0x7F」后面所有包跟着错位。原因把Read返回的count当成了一个完整包。TCP 是字节流协议一次Read可能只回来半个包也可能一次回来三个包加半个。用count当包长就是拿随机数当协议长度。解决所有收包统一走FrameDecoder.Feed不要在任何其他地方直接读流。另外检查_buffer的清空时机——只在出错重建连接时清正常拆包靠RemoveRange逐包消费别图省事一次清空。5.2 现象网线拔了十分钟界面还显示已连接现象日志停在某个时间点不再新增点击「发送」也不报错状态灯绿着不动。原因只靠Read抛异常判断断线但拔网线时对端没发 FINRead阻塞在那里一动不动。同理还有「设备直接断电」的场景。解决上应用层心跳3 倍周期无数据即判定失效。同时把KeepAlive打开作为兜底。心跳包本身也要计入_lastRecvTicks别只刷新发送时间——只发不收的心跳是自欺欺人。5.3 现象关掉窗口进程还在任务管理器里躺着现象点关闭界面消失了任务管理器里还能看到进程只能强杀。原因线程创建时没设IsBackground true默认是前台线程。只要有一条前台线程没结束CLR 就不让进程退出。接收线程和发送线程各漏一个就是两条。解决所有new Thread一律显式设IsBackground true。同时检查轮询循环里有没有while(true)没加退出条件——这种循环即使用后台线程不阻塞退出也会白白烧 CPU。5.4 现象跨线程更新界面偶发异常或者界面直接假死现象Cross-thread operation not valid异常或者用Invoke之后界面卡住不动任务管理器显示无响应。原因接收线程直接操作控件或者用同步Invoke且在 UI 线程里等接收线程的锁两边互等直接死锁。解决接收线程只做拆包通过IMessageHandler把整包抛出去宿主侧用BeginInvoke而不是Invoke异步投递到 UI 队列。更彻底的做法是往一个ConcurrentQueue里塞UI 定时器每 50ms 批量取出刷新这样高频率数据也不会把消息泵打爆。5.5 现象客户端一跑起来某个核就满了现象连接空闲CPU 却稳定占一个核笔记本风扇狂转。原因接收写成轮询if (stream.DataAvailable) { ... }死循环没有数据也在一秒转上百万次。解决改成阻塞Read有数据才返回没数据线程就挂在那儿不消耗 CPU。如果要支持超时退出用ReadTimeout或者ReadAsyncCancellationTokenSource别自己写空转轮询。6. 二次开发改造把协议解析抽成接口换设备只改一个类这份源码能直接跑但要接到自己的设备上改的第一处一定是协议解析。原始代码把拆包逻辑和心跳包格式都放在FrameEncoder、FrameDecoder里接不同厂家的设备就得改这两个类的内部。我的做法是先抽一层接口把「长度头怎么算、心跳包长什么样、收到包交给谁」三件事变成可替换的实现。public interface IProtocolCodec { // 管道的方向要说清楚Read 从流里拼出整包Write 把业务对象编成字节 IEnumerablebyte[] Decode(ReadOnlySpanbyte chunk, ref byte[] carry); byte[] Encode(object message); byte[] BuildHeartbeat(); void OnHeartbeatReply(ReadOnlySpanbyte frame); // 有的设备回显有的不回交给实现决定 } // 定长头 包体最常见的设备协议 public sealed class LengthPrefixedCodec : IProtocolCodec { /* 3.2 节那套逻辑 */ } // 帧头帧尾型0x68 ... 0x16老设备里特别多 public sealed class DelimiterCodec : IProtocolCodec { /* 找帧头、扫帧尾、校验和 */ }抽出接口之后TcpConnection只依赖IProtocolCodec换设备时新写一个实现类注册进去就行连接、重连、心跳、收发队列一行都不用动。DelimiterCodec这类帧头帧尾协议最容易翻车的地方是包体内部恰好出现了和帧尾相同的字节所以一定要做转义处理或者带校验和光扫字节是不够的。第二处值得改的是连接参数外置。把Host、Port、ConnectTimeoutMs、HeartbeatIntervalMs、MaxFrameSize全部从配置读再留一个DeviceId用于日志区分。多设备场景下我的惯例是一个DeviceId对应一个TcpConnection实例共享同一个TrafficCounter汇总日志里每条都带DeviceId前缀出问题时能一眼定位是哪一路。改造之后怎么验证我有一套固定动作先用本地TcpListener回环跑通编解码覆盖半包、粘包、超长包、非法长度四个用例再接真设备跑 30 分钟常态收发然后手动拔网线看是否在 3 个心跳周期内切到重连插回后看是否自动恢复最后观察 24 小时内存曲线是否平稳。内存这条最容易被跳过但_buffer不清、事件不解绑这类问题往往要跑上几小时才显形。改造点改动范围验证方式协议编解码新增IProtocolCodec实现类回环测试四个边界用例连接参数appsettings.json 启动组装改配置不改代码能否连通心跳策略HeartbeatIntervalMs与判定倍数拔网线后重连耗时是否达标多设备管理每设备一个TcpConnection实例单设备断线不影响其他设备有一个细节别忽略IMessageHandler的回调是在接收线程上跑的。如果你在里面做数据库写入或者调用外部 HTTP务必自己再丢一层线程池否则收包速度会被业务拖垮现象是「设备发得越快客户端丢包越多」其实是自己憋住了。这类多线程客户端的坑大多不是写不出来而是「写完能跑」和「跑三个月不出事」之间那一段没人教。我现在的习惯是任何网络代码合进去之前强制走一遍「拔网线、断电、连续重连 50 次、关窗口」这四个动作一个都别省。这份源码的价值不在于它多复杂而在于这些动作对应的处理它都提前写好了你照着改能少走不少弯路。希望帮到你。本文还有配套的精品资源点击获取