简介:这是一套基于C#的TCP/IP异步通信示例工程,由AsyncTcpServer与AsyncTcpClient两个项目构成,面向正在学习网络编程、需要开发实时数据交换应用的开发者。压缩包共含29个文件,核心为14个.cs源码文件,另有.resx界面资源、.sln解决方案、.csproj工程文件及多处配置文件,整体仅34KB,结构精简、便于快速阅读。工程实现了服务端监听与客户端连接,使用TcpListener、TcpClient获得网络流后,通过Begin/End异步读写完成数据收发,覆盖了字节流编解码、UTF8/ASCII文本转换、Socket异常与连接状态处理等关键点;同时给出带输入框的简单交互界面,可直观验证消息回显和并发请求响应。代码注释完整、工程划分清晰,适合配合计算机专业课本深入理解异步网络模型,也可作为课程设计或企业级通信模块的起步模板。目前已有153人学习浏览,附带的说明文本对测试方法和预期结果有简要指引,能帮助读者定位问题、加深对TCP可靠传输机制与收发流程的理解。
1. 异步 TCP 在 C# 上位机里到底解决了什么
看到 TCP.rar 这种命名,先别急着解压——里面大概率是几段 C# TCP 示例、抓包文件和一份陈年笔记。真正让上位机、工业网关和协议中间件摆脱玄学的,是先把异步 TCP 的选型和拆包逻辑理顺。同步 Socket 收数据会卡 UI 线程,开一个 Thread 又难以控制并发;用上 C# 的异步方法之后,连接、收发、重连才能变成可编排的代码。这篇笔记写给正在做 C# 上位机和 TCP/IP 通信的朋友,从选型讲到参数整定,把可以直接复现的写法拆开,也把高频踩的坑一次说清。
2. 先拆协议再写代码:TCP 异步模型的选型依据
2.1 Socket 异步三种写法:APM、EAP、async/await 怎么选
C# 里写 TCP 异步,经历过三代 API。第一代 APM,BeginReceive/EndReceive 成对出现,回调里再套回调,调试时基本是个黑匣子;要传业务上下文只能靠 IAsyncResult.AsyncState,嵌套一多,代码根本没法维护。第二代 EAP,SocketAsyncEventArgs 是 .NET 官方高性能服务器的经典套路,用事件回调替代方法配对,对象可以复用,吞吐极高。第三代 async/await,配合 TcpClient、NetworkStream 以及 .NET 6 起的 Memory 重载,代码长得像同步,但每一步都没有阻塞线程。
选型我一般这么定:并发连接少于 500、需要快速交付、团队能驾驭 async/await 的,直接用 async/await;连接上千、单连接收包频率很高、或者要做网关聚合的,才上 SocketAsyncEventArgs。盲目追“高性能”反而会把工期拖死在状态机上。判断标准从来不是网速,而是峰值连接数和每路每秒收包量。如果业务里还有大量“连接后收到应答再发下一个请求”的时序逻辑,async/await 的表达成本远低于事件回调。
还有一点容易忽略:async/await 最怕半吊子。事件处理器里必须全链路 await,只要有一处用了 .Result 或 .Wait(),线程池就会被占住,UI 假死立刻回来。C# 高级编程里讲异步方法一定会强调这个,实际项目里翻车最多的也正是这里。
2.2 字节流边界:粘包/半包和长度字段设计
TCP/IP 协议栈只保证可靠地传字节流,不保证一次 Write 对应一次 Read。两次发送可能在内核里合并成一个 TCP 段,这叫粘包;一个段里可能只有半个帧,这叫半包。这个特性跟异步无关,同步 Socket 一样存在;但异步多路收包时,多个帧混在一起,更容易暴露。
解决思路就一条:自己定义帧边界。最常见做法是“长度前置”:每帧开头用固定字节数声明负载长度。我习惯用 2 字节小端长度 + 1 字节类型 + 负载,最大负载 65535 字节。类型字段用来区分心跳、数据、应答,解析时 switch 分发,后续加协议不会推翻重来。如果设备要传大图、波形数据,把长度字段扩成 4 字节,同时把单帧上限和缓冲区上限写成常量。
字节序也要提前对齐。很多 SCADA 设备习惯大端,而 C# 的 BitConverter 默认输出小端。发出去之前先跟协议文档核对,不然两边读反,现象就是“第一个字节对,后面全乱”。注意,这里说的长度字段是指 payload 长度,不要把帧头长度也算进去,收发双方口径必须一致。
2.3 最小可用异步 TCP 客户端:连接、发送、接收主循环
下面这段是能直接跑通的最小客户端,覆盖连接、发送一帧、接收循环三件事。我用 .NET 6 的 TcpClient API,注意每个异步方法都带了 CancellationToken,这是为了控制超时,避免界面卡在连不上时无法取消。
// 最小异步 TCP 客户端样例,.NET 6+ using System.Net.Sockets; using System.Text; using var client = new TcpClient(); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); try { // 异步连接,5 秒连不上就抛 OperationCanceledException await client.ConnectAsync("127.0.0.1", 9100, cts.Token); } catch (OperationCanceledException) { Console.WriteLine("连接超时"); return; } // 小包场景建议关掉 Nagle,降低延迟 client.NoDelay = true; await using var stream = client.GetStream(); // 组帧:2 字节小端长度 + UTF-8 负载 var payload = Encoding.UTF8.GetBytes("ping"); var header = BitConverter.GetBytes((ushort)payload.Length); var frame = header.Concat(payload).ToArray(); await stream.WriteAsync(frame, cts.Token); // 接收循环:先不拆包,只读原始字节 var buffer = new byte[4096]; while (true) { var read = await stream.ReadAsync(buffer, cts.Token); if (read == 0) { Console.WriteLine("对端关闭"); break; } Console.WriteLine($"收到 {read} 字节"); }参数说明:连接超时用 CancellationTokenSource 而不是自己写 Thread.Sleep;ReadAsync 返回 0 表示对端优雅关闭,必须 break,否则会一直空转。buffer 4KB 适合心跳和小帧,大流量场景要按帧长度扩容。NoDelay 适合频繁发小包,反之如果传大文件,关闭 NoDelay 反而能减少碎包。失败时先看异常类型:SocketException 里 10061 是端口不通、10060 是超时,排查完再动代码。
提示:尽量不要用 Task.Run 去包一个同步 Socket.Read,那只是把阻塞线程换成线程池线程,异步 API 才真正让出线程。
3. 用 async/await 写一个能上线的异步 TCP 服务端
3.1 服务端主循环与连接生命周期管理
客户端重点在连接和重连,服务端重点在并发连接管理和资源释放。下面的主循环只做两件事:持续 Accept,以及把每个连接丢给后台任务处理。注意 Accept 本身也是异步的,主循环不会阻塞。
// 异步 TCP 服务端主循环,.NET 6+ using System.Net; using System.Net.Sockets; var listener = new TcpListener(IPAddress.Any, 9100); listener.Start(100); // backlog:允许 100 个连接排队 while (true) { var client = await listener.AcceptTcpClientAsync(); _ = HandleClientAsync(client); // 不 await,新连接立即并发处理 } static async Task HandleClientAsync(TcpClient client) { try { using (client) await using (var stream = client.GetStream()) { // 这里写收包、拆包、业务处理 var buffer = new byte[1024]; while (true) { var read = await stream.ReadAsync(buffer); if (read == 0) break; // 处理一帧 } } } catch (Exception ex) { // 统一兜底,避免线程池里抛出未处理异常 Console.WriteLine(ex.Message); } }注意using (client)不是多余的:Accept 出来的 TcpClient 不释放会占着句柄。任务最外层必须有 try/catch,不然任何一个连接抛异常都可能导致进程退出。这里常见误用是在循环外加 try/catch,那拦不住每个连接内部的错误。正确做法是将异常包在 HandleClientAsync 里,让单个连接失败不影响主循环继续 Accept。
如果连接数多了,建议用一个 ConcurrentDictionary<int, TcpClient> 记录会话,便于主动踢掉超时连接和给指定设备下发数据。每条连接建立时分配一个自增 ID,清理时按 ID 移除,避免拿 IP 当 key 跟多网卡设备撞车。
3.2 接收缓冲与拆包器的实现
服务端代码最核心的是拆包器。ReadAsync 一次不一定读满一个帧,必须循环直到读够长度。下面给的是一个“先读 2 字节长度,再读完整负载”的流式拆包器,逻辑收敛在一个函数里,业务层拿到的永远是完整帧。
// 拆包器:先读 2 字节长度,再读完整负载 static async Task<byte[]> ReadFrameAsync(NetworkStream stream, CancellationToken ct) { var head = new byte[2]; if (!await ReadExactlyAsync(stream, head, ct)) return null; // 对端关闭 var len = BitConverter.ToUInt16(head); // 与协议端序一致 if (len > 65535) return null; // 上限保护 var body = new byte[len]; if (!await ReadExactlyAsync(stream, body, ct)) return null; return body; } // 循环读直到 buffer 读满,处理半包 static async Task<bool> ReadExactlyAsync(NetworkStream stream, byte[] buffer, CancellationToken ct) { var offset = 0; while (offset < buffer.Length) { var read = await stream.ReadAsync(buffer.AsMemory(offset, buffer.Length - offset), ct); if (read == 0) return false; // 连接被关闭 offset += read; } return true; }参数说明:ReadExactlyAsync 是这里的灵魂。TCP 一次 Read 可能返回 1 字节,也可能返回几百字节,读不满长度就循环。拆包器只认长度字段,不关心一次网络包边界。如果设备带宽很高,可以用 Memory 重载减少复制;流量不大时 byte[] 足够。
如果想把“读数据”和“拆包”分开,可以维护一个内部缓冲区:先 Read 到 byte[],再用偏移量标记已经消费的位置。流式拆包器的好处是代码直观,适合中小规模;缓冲区式的好处是能一次接收多个帧,减少系统调用。我建议 500 路以下先用流式,等压测发现 CPU 高在 Read 上再优化。
3.3 心跳、超时与断开重连的参数设定
工业现场设备隔着交换机、无线网桥,一条 TCP 可能几小时不发数据。设备断电时,操作系统不会立刻告诉对端,只能靠心跳。我的习惯是 30 秒一问,连续 3 次无响应判定断开;服务端对超过 60 秒无心跳的连接主动 Close。心跳只在空闲时发,高频上报的设备不需要额外心跳。
重连要讲究退避。客户端断线后不能每毫秒重试,不然服务端刚恢复就会收到几千个 SYN,造成连接风暴。常见做法是按 1s、2s、4s、8s 递增,封顶 30s。这个参数在异步方法里用 Task.Delay 实现,不要用 Thread.Sleep,否则断开期间线程池被占满。
// 客户端断线重连:指数退避 var delay = 1000; while (true) { try { using var client = new TcpClient(); await client.ConnectAsync(host, port); await HandleSessionAsync(client); delay = 1000; // 连接成功则复位 } catch { await Task.Delay(delay); delay = Math.Min(delay * 2, 30000); } }这个循环把“连接”、“会话处理”、“断线重连”串成一条线。HandleSessionAsync 内部返回值后,外层马上重连。C# 线程和异步在这里的配合是:Task.Delay 不占线程,遇到长时间重连也不会拖垮 UI。生产环境里,重连逻辑要加一个停止标志,不能用一个永远 while(true) 让程序退不掉。
4. 异步 TCP 避坑指南:5 个最常见的翻车现场
4.1 现象:UI 线程卡死 / 原因:同步阻塞 / 解决:Task.Run 或 async 全链路
现象:WinForms 上位机点一下“启动连接”按钮,窗口立刻转圈,日志也不滚动。原因十有八九是监听端口程序的循环写在事件处理器里,Accept 或 Read 阻塞了 UI 线程。异步方法本身不会自动把代码移出 UI 线程,只有 await 的 API 才真正让出线程;如果事件处理器里用client.ConnectAsync(...).Wait(),一样死锁。
解决方案:把网络收发 main loop 放到Task.Run或独立后台服务里,事件处理器只负责启动和停止。另一个关键是全链路 async。检查代码里有没有 .Result、.Wait()、长期 while(true),有就拆掉。下面这个对比很直观:
// 错误:事件处理器里同步阻塞 private void btnConnect_Click(object sender, EventArgs e) { var client = new TcpClient(); client.ConnectAsync(host, port).Wait(); // UI 线程被卡住 } // 正确:后台任务跑连接循环,UI 只做状态刷新 private async void btnConnect_Click(object sender, EventArgs e) { await Task.Run(() => RunClientLoopAsync()); }需要注意 async void 事件处理器里不能抛异常,否则会直接崩应用。RunClientLoopAsync 内部要自行捕获异常。C# 上位机最忌讳把网络层和界面层写成一个类,连一个状态改变就发一个事件,界面只在事件里刷新。
4.2 现象:粘包导致数据错乱 / 原因:TCP 是字节流 / 解决:LengthField 拆包
现象:连续发几条消息,接收端偶尔把两条拼在一起,解析完第一条后第二条字节错位,甚至出现长度字段超大的报错。原因就是 TCP 是字节流,不保证消息边界。这个坑在异步收包时更隐蔽,因为多个连接的字节会交错。
解决:协议里必须有长度字段,接收端必须按长度拆包。不要用分隔符去猜,二进制报文里任何字节都可能出现 0x0D 0x0A。下面这段就是在 4.2 的 ReadExactlyAsync 基础上的完整用法:
var frame = await ReadFrameAsync(stream, ct); if (frame == null) break; var type = frame[0]; // 帧类型 var payload = frame[1..]; // 负载 switch (type) { case 0x01: // 心跳 break; case 0x02: // 数据上报 await HandleReportAsync(payload); break; }注意读取长度字段时要决定端序。C# BitConverter 默认读小端,可很多设备协议是网络字节序大端,不转换就全乱。另外长度字段要设上限,比如 65535,收到超长帧直接丢弃并断开,防止恶意设备把缓冲区撑爆。
4.3 现象:Socket 异常后进程崩溃 / 原因:未捕获 ObjectDisposedException / 解决:统一异常回退
现象:客户端拔掉网线,服务端下一次 Read 抛异常,程序没兜住,整个进程退出。很多人以为 TCP 连接断了 Read 会返回 0,其实不一定。网络层异常通常表现为 IOException、SocketException 或 ObjectDisposedException,如果不捕获,后台任务会把异常抛进线程池,导致进程崩。
解决:把每个连接的处理逻辑包在 try/catch/finally 里。finally 里释放 NetworkStream 和 TcpClient,catch 里分类记录日志。这里要注意释放顺序:先关流再关客户端,否则会有半开连接。还应该在 catch 里判断是不是可预期的关闭,比如 SocketException 10053、10054 属于对端重置,业务上按“断线”处理即可。
try { using var client = await listener.AcceptTcpClientAsync(); await using var stream = client.GetStream(); // ... } catch (SocketException ex) { // 10054 对端强制关闭,10060 超时 logger.LogWarning($"连接异常: {ex.SocketErrorCode}"); } catch (ObjectDisposedException) { // 主动关闭时触发,忽略即可 } finally { // 统一清理句柄 client?.Close(); }我见过最典型的翻车:把连接处理写成静态方法,抛异常后没有 finally,靠 GC 回收句柄。结果 Windows 上句柄数一路涨到几千,服务端再也 Accept 不了新连接。这种问题不跑到压测现场根本发现不了。
4.4 现象:内存涨不停 / 原因:缓冲区分配频繁 / 解决:ArrayPool 与可复用 Buffer
现象:服务端跑一个小时后内存曲线一路向上,甚至触发高负载 GC。原因是每个包都 new byte[4096],高频小包每秒成千上万次分配,加上因为异步引用还在被使用,不能马上回收,GC 压力巨大。
解决:高频收发时用 System.Buffers.ArrayPool 。Rent 拿到的数组可能比请求大,用完后 Return 回池子,避免每次分配新数组。注意 Return 之后不能再碰这个数组,否则会有敏感数据残留问题。
using System.Buffers; var rented = ArrayPool<byte>.Shared.Rent(4096); try { var read = await stream.ReadAsync(rented.AsMemory(0, 4096)); // 只处理 rented[0..read],不要 Read 整个 4096 if (read == 0) break; } finally { ArrayPool<byte>.Shared.Return(rented); }参数说明:Rent 不一定返回 4096 长度的数组,取到可能更大,使用下标一定要限定实际长度。Return 默认会清空数组,为了性能可以传 clearArray: false,但要确认没有敏感数据。ArrayPool 适合 channel 消费场景里用来做帧缓冲区;如果只是低频心跳,普通 new byte[] 更简单,没必要过度优化。
4.5 现象:服务端句柄泄漏 / 原因:未及时关闭连接 / 解决:using + 超时清理
现象:并发测试后 Windows 任务管理器里句柄数持续增长,最终导致无法创建新连接。原因就是 Accept 出来的 TcpClient 没有释放,或者在业务异常路径上直接 return,线程资源没归还。
解决:所有 TcpClient 用 using 或者手动 Close。用 using 只能处理正常流程,如果中途 catch 之后还引用着流,一样会泄漏。更稳妥的是专门建一个会话清理任务,定时扫描活动连接,超过 N 秒无心跳的强制 Close。监听端口的程序关闭时,也要把 listener.Stop() 放进 finally。
// 每 60 秒清理一次空闲连接 using var timer = new PeriodicTimer(TimeSpan.FromSeconds(60)); while (await timer.WaitForNextTickAsync()) { foreach (var kv in sessions) { if (!kv.Value.IsAlive) { kv.Value.Client.Close(); sessions.TryRemove(kv.Key, out _); } } }这里的 IsAlive 可以是你自己维护的“最后活跃时间”字段。网络对象从不可靠地告诉你“我断了”,只能靠超时判断。Windows 查看句柄数的命令是“任务管理器 → 详细信息 → 句柄”,Linux 是ls /proc/<pid>/fd | wc -l,压测前后对比一眼就能看出有没有泄漏。
5. 实际部署:从本地回环到工业现场的参数整定
5.1 服务端监听与客户端重连的参数对照表
同一套异步 TCP 代码,在局域网里跑和跨无线网桥跑,参数完全不一样。下面的表格是我的默认值,实际部署时按这个基准调。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| ListenBacklog | 128~256 | 排队连接上限,突然涌入大量连接时不丢握手 |
| ReceiveTimeout | 30 秒 | 心跳 3 次无响应则判定超时 |
| SendTimeout | 30 秒 | 发送阻塞保护,防止写日志把连接拖死 |
| Socket Buffer 接收 | 8192~16384 | 匹配 MTU,减少小包碎片 |
| Socket Buffer 发送 | 8192 | 高频上报时看发送队列丢包现象 |
| 客户端重连退避 | 1s, 2s, 4s, 8s…封顶 30s | 避免断线重连风暴 |
| 单帧上限 | 65535(2 字节长度) | 超过则用 4 字节长度字段 |
| 心跳间隔 | 30 秒空闲触发 | 主动上报类协议可不单独发心跳 |
需要注意:ReceiveTimeout 只对同步 Read 有效,异步 Read 的超时要放在 CancellationTokenSource 里。很多人在 async/await 模型里写了 ReceiveTimeout 却发现不生效,原因就在这里。线程数和连接数不要硬扛,C# 的 async/await 已经把线程让出来了,真正限制服务端的是句柄数和内存。
无线场景要把心跳调短,比如 15 秒,因为无线网桥的缓存经常导致断线感知滞后。有线场景可以放宽到 60 秒,减少心跳流量。设备数量和上报频率决定了单帧上限,别为了一个场景硬压到 1KB,后面加功能又要改协议。
5.2 用 telnet 或网络调试助手验证异步收发
服务端代码写完,先不接客户端,用最简单工具验证监听端口是否正常。telnet 是验证 TCP 层最直接的工具,连上后输入字符串再回车,服务端应该能收到原始字节:
telnet 127.0.0.1 9100再开一个终端,用系统命令看端口和连接状态:
netstat -ano | findstr 9100 # Windows ss -tnlp | grep 9100 # Linux 自测时netstat 输出里的 ESTABLISHED 表示连接建立成功,LISTENING 表示监听正常。如果 telnet 连不上,先查端口是否被占用和防火墙规则。Windows 上常见问题是防火墙默认不放开自研端口,开发时最容易忽略这一条。
telnet 发的是原始字节,没法构造自定义帧,所以只用来验证 TCP 层通不通,不要指望它替代业务客户端。要验证业务协议,用网络调试助手或写个快速脚本组帧,重点看拆包器能否把收到的字节还原成完整帧。
5.3 压力测试:1 分钟 500 连接,内存和 CPU 怎么看
压力测试不能只测“能不能连”,要测“连上后持续收发会不会出问题”。下面的代码起 500 个客户端,每个连接发 100 帧,然后统计完成率。这是我最常用的验证方式,逻辑不复杂但能暴露泄漏和拆包错误。
// 并发 500 连接压测脚本片段 using System.Net.Sockets; using System.Text; var tasks = Enumerable.Range(0, 500).Select(async i => { using var client = new TcpClient(); await client.ConnectAsync("127.0.0.1", 9100); var stream = client.GetStream(); for (int j = 0; j < 100; j++) { var payload = Encoding.UTF8.GetBytes($"device-{i}-msg-{j}"); var frame = BitConverter.GetBytes((ushort)payload.Length).Concat(payload).ToArray(); await stream.WriteAsync(frame); var reply = await ReadFrameAsync(stream, CancellationToken.None); if (reply == null) break; } }); await Task.WhenAll(tasks); Console.WriteLine("压测完成");测试时看三个数:句柄数、进程工作集内存、收包率。收包率低于 99.9% 时,优先怀疑拆包器在并发下的字节序或长度计算错误。内存要看 10 秒均值,排除 GC 毛刺;句柄数可以用 Windows 资源监视器,也可以连续截取三次观察趋势。C# 线程数如果持续上涨,说明有同步阻塞在吃掉线程池,而不是异步在正常让出线程。
6. 异步 TCP 的进阶用法:把收发管道化,避免回调地狱
6.1 用 Channel 解耦收包与业务处理
连接数多了之后,拆包和业务处理挤在一个循环里会互相拖累。比如设备上报一条数据,业务处理要查库、写日志,拖了 100ms,后续帧全堵在网络层。常见做法是引入 Channel 做生产者和消费者解耦:收包循环只负责拆包和写入,业务侧独立消费。
// 生产侧:收包循环 var channel = Channel.CreateUnbounded<Frame>(); while (true) { var frame = await ReadFrameAsync(stream, ct); if (frame == null) break; await channel.Writer.WriteAsync(frame, ct); } // 消费侧:业务处理 await foreach (var frame in channel.Reader.ReadAllAsync(ct)) { await ProcessFrameAsync(frame); }参数说明:Unbounded 是无界队列,消费跟不上时内存会涨,所以只在拆包快、业务可控时用。如果要背压,换成 BoundedChannelOptions,容量按“每秒帧数 × 最大处理耗时”估算。管道化还有个好处:每段可以单独加日志、重试和监控,排错从黑匣子变成透明链路。
6.2 身份验证与数据上报的时序设计
多设备接入后,帧里要带类型字段和会话 ID。我把帧格式定为:2 字节长度 + 1 字节类型 + 1 字节会话 ID + 负载。类型 0x01 握手指纹,0x02 心跳,0x03 数据上报。服务端起一个异步定时任务,每 30 秒扫描会话表,把没完成握手的连接直接 Close,防止资源被空连接占满。
这里有个细节:Channel 默认多消费者并发,帧顺序不保证。要保序,必须是单消费者;多消费者按 SessionId 哈希分流到不同通道,比在内部排序简单得多。我第一次实现时让三个消费 Task 抢同一个 Channel,帧全乱了,后来改成按设备号取模,问题立刻消失。这个习惯我一直保留:先画“收包 → 拆包 → 入队 → 消费”的管道,再写代码。回调嵌套的代码当时能跑,两周后自己都看不懂;管道化之后,每个环节可以单独加日志和重试。希望帮到你。
本文还有配套的精品资源,点击获取