拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C# TcpListener/TcpClient 多客户端并发通信实战与避坑指南

C# TcpListener/TcpClient 多客户端并发通信实战与避坑指南 简介面向C# WinForm开发者的TCP通信示例工程包含服务端FrmTcpServerV2与客户端FrmTcpClientV2两个独立窗体项目并附有各自的Visual Studio解决方案文件可在VS中直接打开编译运行。代码清晰展示TcpListener监听端口、接受连接TcpClient连接服务器、发送数据以及基于NetworkStream的消息读写与UI交互涵盖IP与端口绑定、连接管理、数据编码、异常处理等关键知识点同步阻塞式读写让流程更加清晰易懂适合作为Socket编程入门案例也可用作课程设计参考。压缩包共61个文件核心是18个.cs源文件覆盖窗体设计、网络逻辑与程序入口6个.exe可执行文件可直接运行查看效果另有resx界面资源、config应用配置可调整IP和端口、pdb调试符号、sln工程文件以及2个txt说明文件整体仅104KB轻巧完整目录按服务端与客户端分组织检索方便。已有963人学习下载适合刚接触网络编程的开发者和需要快速搭建TCP测试工具的技术人员资源内项目结构简单非常利于快速掌握。通过该项目可掌握双端联调全流程包括服务端启动监听与接受连接、客户端发起连接并发送请求、接收响应以及使用后台线程避免WinForm界面冻结、关闭流与TcpClient释放资源等实用技巧是实践C#网络通信的高性价比参考资料也可作为企业内网简易通信程序的雏形。1. TcpListener 和 TcpClient这个压缩包要解决的 C# 网络通信难题拿到手一个叫 TcpListenrAndTcpClient.rar 的压缩包大概率是某个旧工程里扒出来的 C# 网络通信示例。注意标题里 TcpListener 少了最后的 e实际类名是 TcpListener——这种拼写差异几乎能确认是从某次手工打包流传出来的 Demo但核心内容不会变一个基于 TCP 的服务端监听器、一个客户端连接器外加 C# TcpListener 多客户端场景下的处理参考。对大多数从业者来说这个组合就是 Windows 服务、工控上位机、局域网数据中继里最常见的传输底座不依赖任何第三方库.NET Framework 和 .NET 5 都内置。我刚入行时也拿到过类似压缩包当时以为搞定一对一连通就是万事大吉直到被现场设备数量一冲才发现同步模式根本扛不住。这个压缩包真正的价值不在那几十行代码而在于它逼你去想清楚三件事TcpListener 监听之后怎么把连接交给工作线程、TcpClient 断线之后怎么自己爬起来、多客户端并发时协议边界在哪里。这篇文章就沿着这三条线展开新手能照着跑通最小示例熟手能看到同步与异步的分界线在哪儿。2. 从一对一连到多客户端TcpListener 的核心选型与同步读写2.1 同步一对一连通AcceptTcpClient 与 NetworkStream 的最小代码TcpListener 和 TcpClient 的设计逻辑比直接玩 Socket 要省心TcpListener 只负责监听和接受连接AcceptTcpClient 每接受一个客户端就返回一个新的 TcpClient 实例TcpClient 拿到之后 GetStream 得到 NetworkStream后续收发全是流式读写。服务端最小代码一般长这样// 服务端一对一的最小实现 using System.Net; using System.Net.Sockets; using System.Text; var listener new TcpListener(IPAddress.Any, 9000); listener.Start(backlog: 10); Console.WriteLine(监听端口 9000等待客户端接入...); var client listener.AcceptTcpClient(); // 阻塞直到有客户端连进来 Console.WriteLine($客户端接入: {client.Client.RemoteEndPoint}); var stream client.GetStream(); var buffer new byte[1024]; while (true) { int len stream.Read(buffer, 0, buffer.Length); // 阻塞读 if (len 0) break; // 正常关闭时返回 0 string msg Encoding.UTF8.GetString(buffer, 0, len); Console.WriteLine($收到: {msg}); stream.Write(buffer, 0, len); // 原样回显 }这段代码里值得注意几个细节。AcceptTcpClient 是阻塞调用没有客户端连入时线程挂在 socket 上这不是死循环而是正常的等待。stream.Read 同样阻塞返回 0 表示客户端主动关闭了连接并完成了四次挥手这时候循环退出、释放资源。stream.Write 直接把字节写回TCP 保证字节按序到达但对端拿到的可能不是一次完整消息这个问题第五章专门讲。对应的客户端最小实现也很直白// 客户端连接并发送一条消息后等待回显 using var client new TcpClient(); client.Connect(127.0.0.1, 9000); var stream client.GetStream(); byte[] data Encoding.UTF8.GetBytes(hello server); stream.Write(data, 0, data.Length); var buffer new byte[1024]; int len stream.Read(buffer, 0, buffer.Length); Console.WriteLine($收到回显: {Encoding.UTF8.GetString(buffer, 0, len)});using var 声明会在作用域结束时自动调用 Dispose释放底层 Socket。注意这里 Connect 没有设置超时如果服务器 IP 不通或者端口被防火墙挡了Connect 会按系统默认策略等很久这在第四章会给出带超时的写法。2.2 端口、backlog 与缓冲区的四个参数决定性能TcpListener 能直接定的参数就这么几个IP 地址、端口、backlog 队列长度。端口好理解0 表示由系统自动分配调试多个服务端实例时可以用。backlog 是操作系统为这个监听 Socket 维护的已完成握手但还没被 Accept 的队列长度数值太小客户端连得急了会直接收到 connection refused。常见做法是内网服务给 50 到 100公网业务给 128 到 512不要盲目给到几千因为每个连接本身还要占内存。缓冲区大小是另一个容易忽略的参数。NetworkStream 的 Read 调用会尝试读取 buffer.Length 个字节但如果底层缓冲区没攒够它会返回当前已经可读的字节数而 Write 调用则把数据交给内核函数返回只代表数据进了发送缓冲区不代表对方已经收到。缓冲区给多大直接关系到高频小包的吞吐一般 1024 到 8192 之间。工控报文通常 256 字节就够视频流或大文件传输则建议 64 KB 起步。TcpListener 构造时还可以指定 IP 地址为 IPAddress.Any这会同时监听本机所有网卡。如果只需要某个内网网卡比如只让 Wi-Fi 网段访问、隔离有线网口就改成对应网卡的 IP。这个选择在部署到有多网卡的服务器上时是实际发生过的事故源头后面排查章节会提到。2.3 同步模型为什么扛不住多客户端把上面的服务端改成 Accept 一个客户端就进入处理循环然后回来再 Accept 下一个就出问题了处理第一个客户端的 while 循环会一直阻塞在 stream.Read第二个客户端连接请求虽然在内核里完成了握手但用户态没有调用 Accept它就一直躺在 backlog 队列里。等第一个客户端断开之后第二个才被 Accpet 出来——这就是所谓的新客户端被旧客户端饿死。直接开一个线程去处理每个客户端是个朴素思路但也不对一台设备当上百个客户端接入时就等于开上百个线程每个线程默认栈空间 1 MB还没算上下文切换开销这种写法在正式环境一定会翻车。好在 .NET 早就给了 async/await 这一套真正的异步 IO接下来的并发模型就围绕它来搭。3. 用 TcpListener 做并发服务器异步模型与连接管理3.1 不要用 for 循环串行 Accept错误示范与后果有一个常见错误是这么写的listener.Start 之后在 for 循环里 Accept 然后处理处理完再回到 for 下一条 Accept。跑起来的效果就是第一个客户端连上之后一直占着服务器第二个客户端的 ConnectAsync 能成功但只要第一个客户端不发数据服务器就永远不处理第二个。现场表现为设备上报数据偶尔正常一旦有设备长连接保活其他设备全部超时。如果每个客户端开一个 Thread 来应付从功能上能跑通但收益是暂时的。每个线程即使空闲也要占内核对象和栈内存线程切换和 Socket 阻塞也不是免费的。业内测试几十个客户端加简单心跳就足以让这种写法出现延迟抖动。这种方案只在客户端总数个位数、且每个连接每秒才收发几笔的业务里勉强能用我一般不建议把它当多客户端方案交付。3.2 用 async/await 改造AcceptTcpClientAsync 与连接管理正确的并发模型核心就一句话接受连接是异步的每一个已接受的连接也是异步读写整个服务器不靠线程数撑并发。代码如下// 服务端异步接受多客户端 using System.Net; using System.Net.Sockets; using System.Text; var listener new TcpListener(IPAddress.Any, 9000); listener.Start(100); Console.WriteLine(服务已启动等待客户端...); while (true) { TcpClient client await listener.AcceptTcpClientAsync(); _ HandleClientAsync(client); // 不 await立即回到 Accept 下一个 } async Task HandleClientAsync(TcpClient client) { try { using (client) { Console.WriteLine($接入: {client.Client.RemoteEndPoint}); var stream client.GetStream(); var buffer new byte[4096]; while (true) { int len await stream.ReadAsync(buffer, 0, buffer.Length); if (len 0) break; Console.WriteLine($[{client.Client.RemoteEndPoint}] {Encoding.UTF8.GetString(buffer, 0, len)}); } } Console.WriteLine($断开: {client.Client.RemoteEndPoint}); } catch (Exception ex) { Console.WriteLine($[{client.Client.RemoteEndPoint}] 异常: {ex.Message}); } }这段代码的核心是_ HandleClientAsync(client)。HandleClientAsync 内部全部是 await ReadAsync这是真正的异步 IO在 Windows 上由 IOCP 完成不会占据线程池线程所以调用之后不需要 Task.Run直接丢弃 Task 让它在后台跑即可。ACCEPT 循环本身的 await AcceptTcpClientAsync 也是异步的整个服务器从启动到运行只需要一个线程在事件循环上流转。如果处理流程里有 CPU 密集计算或者同步阻塞的第三方库比如解析大文件或调一个阻塞 API才需要在这些局部位置用 Task.Run 包一下不要把整个 HandleClient 包进去。用 Task.Run 包整个处理流程相当于又回到了一个连接一个线程的老路线程池会按连接数扩张遇到突发连入会持续增大线程池。3.3 连接管理记录在线客户端与主动踢掉失效连接多客户端服务端除了收发数据还要知道当前哪些客户端还活着。常见做法是维护一个静态字典键用 client.Client.RemoteEndPoint 或者自己分配的会话 ID值存 TcpClient 和会话状态// 连接管理线程安全字典保存在线会话 ConcurrentDictionarystring, TcpClient _online new(); async Task HandleClientAsync(TcpClient client) { string id client.Client.RemoteEndPoint.ToString(); _online[id] client; try { // ... 读循环 ... } finally { _online.TryRemove(id, out _); // 无论正常断开还是异常都要清理 } }这里要注意几个细节。字典的读写要用 ConcurrentDictionary 或者加锁HandleClient 是在多个线程上下文里交替执行的普通 Dictionary 会抛集合已修改异常。清理操作必须放在 finally 里而不是只在正常退出路径上。客户端断开不一定是先通知服务器异常被 catch 之后 finally 才有机会执行。主动踢掉失效连接是多客户端服务器里很实际的需求。客户端异常断电时服务器这边可能长时间收不到任何信号检测手段是心跳超时加上一条断线判断记录最后心跳时间用一个定时器每 30 秒扫一遍字典把超过 60 秒没有心跳的 TcpClient 关掉并移除。定时器用 System.Threading.Timer 就可以扫描别在 UI 线程里跑。4. 客户端侧 TcpClient 的关键实现连接超时、重连与心跳4.1 Connect 超时避免界面卡死的三种实现TcpClient.Connect 是同步阻塞的服务器 IP 不存在时在 Windows 上可能要等几十秒才抛异常。桌面程序里这会直接卡死 UI常见的解决方式有三种。第一种最省事设置 Socket 连接超时配合同步 Connect很多场合不够精确第二种是用 ConnectAsync 配合 CancellationTokenSource 控制等待时长第三种更常见自己用 Task.WhenAny 把 ConnectAsync 和一个延迟任务赛跑谁先完成算谁。三种里面我推荐第二种代码最直接// 客户端带超时的连接方法 public async Taskbool ConnectWithTimeoutAsync(string host, int port, TimeSpan timeout) { try { using var cts new CancellationTokenSource(timeout); _client new TcpClient(); await _client.ConnectAsync(host, port, cts.Token); Console.WriteLine($连接成功: {host}:{port}); return true; } catch (OperationCanceledException) { Console.WriteLine(连接超时); _client?.Dispose(); return false; } catch (SocketException ex) { Console.WriteLine($连接失败: {ex.SocketErrorCode}); _client?.Dispose(); return false; } }注意 ConnectAsync 带 CancellationToken 的重载在 .NET 5 才有.NET Framework 4.7.2 以及 .NET Standard 2.0 下只能用 Task.WhenAny 方案兜底。SocketException 要单独捕获因为服务器拒绝连接、网络不可达、防火墙 RST 都表现为这个异常靠 SocketErrorCode 才能区分是 ConnectionRefused 还是 HostUnreachable。连接失败之后要立即 Dispose 掉这个 TcpClient否则异常的分支里会留下一个半初始化 Socket。4.2 断线重连与指数退避客户端的基本教养客户端断线自动重连是设备端最核心的求生技能。我最常用的写法是一个 while 循环里面做四件事尝试连接、连接成功后进入收发循环、收发循环退出后清理资源、按退避策略等待下次重连。指数退避的意思是重连间隔随失败次数增加避免服务端恢复瞬间被一堆客户端同时涌入打挂// 客户端自动重连 指数退避 public async Task RunAsync(CancellationToken token) { int failCount 0; while (!token.IsCancellationRequested) { bool ok await ConnectWithTimeoutAsync(127.0.0.1, 9000, TimeSpan.FromSeconds(3)); if (!ok) { failCount; int delay Math.Min(1000 * (1 failCount), 15000); // 1s, 2s, 4s... 封顶 15s Console.WriteLine($第 {failCount} 次连接失败{delay / 1000}s 后重试); await Task.Delay(delay, token); continue; } failCount 0; await HeartbeatLoopAsync(token); // 内部循环断开时返回 } }1000 * (1 failCount)是 1 秒、2 秒、4 秒、8 秒的指数增长15 秒封顶是因为超过这个间隔现场人员会认为设备已经死了。连接成功后要立刻清零 failCount否则一次抖动恢复后还要等几秒才重连。Task.Delay 要传 token否则整个程序关掉时重连循环还赖着不退。设备端还有个大坑Wi-Fi 断开再恢复时TcpClient 对象可能已经处于半失效状态直接 New 一个新的比复用旧对象更可靠。所以重连循环里每次连接都重新 new TcpClient而不是尝试把旧的 Connected 属性判断一下继续用。Socket 的 TCP 状态机不像文件系统Connected 属性只代表最后一次发送时内核看到了什么并不能真正代表链路健康。4.3 心跳包让服务器知道你还活着心跳是 TCP 通信里绕不开的玄学。TCP 本身有 KeepAlive 机制但默认探测周期是 2 小时等到系统发现死链黄花菜都凉了。业务层心跳的做法是客户端每隔固定时间发一条极小的消息服务器收到后刷新该客户端的时间戳。设备端实现一般和接收循环配合// 客户端接收循环 定时心跳 private async Task HeartbeatLoopAsync(CancellationToken token) { var stream _client!.GetStream(); var readTask ReadLoopAsync(stream, token); // 后台接收发现断开就结束 byte[] heartbeat Encoding.UTF8.GetBytes({\type\:\heartbeat\,\ts\:\\}); try { while (!token.IsCancellationRequested) { await stream.WriteAsync(heartbeat, token); await Task.Delay(5000, token); // 每 5 秒一次 } } catch (IOException) { Console.WriteLine(心跳发送失败链路已断); } }这里的 write 是带 token 的 WriteAsync如果服务器已经断链WriteAsync 会抛 IOException 而不是静默失败正好把控制权交还给上层重连逻辑。心跳间隔 5 秒是个保守值对服务器造成的压力很小如果服务器要求更快的失效检测比如 3 秒超时踢人客户端心跳间隔就要小于 3 秒一般取超时时间的三分之一到一半。心跳包和业务包不要混淆服务器应该允许业务包也刷新心跳时间戳否则高频率发业务的客户端会被误踢。ReadLoopAsync 里要注意一点读完一次数据后检查_client.Client.Poll(0, SelectMode.SelectRead)判断对端是否已经断开这个检查在日常开发里很常用但要留意它只能判断接收缓冲区内是否有数据可读不能作为唯一的断线依据真正可靠的还是 Read 返回 0 或抛异常。5. 多客户端通信的避坑记录粘包、线程安全与资源释放5.1 粘包与半包两次发送变成一次接收现象服务端明明先收到字符串 A 再收到字符串 B打印出来却是 AB 或者只有 A 的一半。原因TCP 是字节流协议它只保证字节顺序不认为一次 Write 就是一条消息。网络层会把多个 Write 的数据合并进一个 TCP 段也会把一个大数据拆成多个段。解决必须自己定义消息边界最常见的是在每个包前面加 4 字节长度字段接收端先读长度再读内容缓冲攒够了才算一个完整包。别指望每个循环里的 Read 一次拿到整条消息这个错觉是我见过最多的翻车点。5.2 Read 返回 0 与“连接被远程主机重置”现象客户端拔了网线服务端的 Read 一直不返回等了十几分钟像死了一样另一种情况是 Read 直接抛 SocketException提示连接被重置。原因正常关闭时 Close 会走四次挥手服务端 Read 返回 0异常断电或进程崩溃则不会发 FIN对端收到的是 RSTRead 抛异常或者卡在等待取决于系统 TCP 栈的实现。解决Read 循环里不能只依赖返回 0 判断退出必须 catch SocketException并且根据 SocketErrorCode 里的 ConnectionReset、ConnectionAborted 做不同处理。还要设置 TCP KeepAlive 或业务心跳让服务器在客户端失联后能主动收尸。5.3 “通常每个套接字地址只允许使用一次”与 TIME_WAIT现象服务端崩溃后很快重启报错 AddressAlreadyInUse同时客户端立刻重连也失败。原因服务端监听 Socket 被 Dispose 后之前接受过的连接会进入 TIME_WAIT 状态占用本地端口二元组。解决在 TcpListener 构造前通过 Socket 选项设置地址复用然后传给 TcpListener// 服务端设置 SO_REUSEADDR 后再启动监听 var socket new Socket(SocketType.Stream, ProtocolType.Tcp); socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); socket.Bind(new IPEndPoint(IPAddress.Any, 9000)); socket.Listen(100); var listener new TcpListener(socket); listener.Start();不过要注意ReuseAddress 开启后如果服务端异常重启新的服务进程可以占住端口但旧连接还是 TIME_WAIT 的某些场景下可能导致旧连接的数据发到新服务上。生产环境建议在服务端优雅关闭时先 Stop 监听再关闭所有客户端连接给 TIME_WAIT 一点消散时间。5.4 async 方法里用了 Task.Run 包整个循环线程池被打满现象客户端一多CPU 某个核被打满线程池线程数飙到几百服务端吞吐反而下降。原因HandleClientAsync 内部虽然是异步 IO但有人习惯写成Task.Run(() HandleClientAsync(client))以为这样才能并发。实际上因为异步 IO 不阻塞线程Task.Run 只是多创建了一个线程来执行状态机调度连接越多线程越多纯属浪费。解决直接_ HandleClientAsync(client)只有遇到同步阻塞的库调用时才局部 Task.Run。判断标准是看 Task.Run 里面有没有阻塞调用——没有的话就剥掉。5.5 客户端断开时服务端读写和连接管理同时操作同一个 TcpClient现象某个客户端断开瞬间服务端的 Read 循环和清理逻辑同时访问 client.Client.RemoteEndPoint抛 ObjectDisposedException连带整个服务器进程退出。原因Read 循环里 try-finally 的清理和另一个线程的踢人逻辑没有同步。解决给每个会话封装一个带 lock 的对象Dispose 前先标记会话已关闭其他路径看到标记后不再触碰。伪代码思路是// 会话封装关闭状态用 lock 保护 class Session { public TcpClient Client; public bool Closing; public void Close() { lock (this) { if (Closing) return; Closing true; Client.Client.Shutdown(SocketShutdown.Both); } } }暴力但有效任何想关连接的地方只调用 Session.Close而不是直接 Close 底层的 TcpClient。这样即使竞态条件下重复 Close第二次也会因为 Closing 标志直接返回不会抛双重释放异常。这个模式在自己管理多客户端连接时特别有用。6. 把 Demo 变成可用的服务消息协议与压测验证6.1 用消息协议代替裸字符串4 字节长度前缀的解析器Demo 里最让人想省掉的就是协议设计但裸字符串是没法上线的。通用做法是定义一个最简单的帧格式前 4 字节存放消息体长度后跟消息体。长度用大端字节序避免不同机器解析字节序不一致。解析器要处理半包和粘包下面这个分包器是缓存 循环解析的思路// 分包器处理半包与粘包 using System.Buffers.Binary; public class PacketParser { private readonly MemoryStream _buffer new(); public void Append(byte[] data, int offset, int count) { _buffer.Write(data, offset, count); } public byte[]? TryReadPacket() { if (_buffer.Length 4) return null; _buffer.Position 0; Spanbyte lenBytes stackalloc byte[4]; _buffer.Read(lenBytes); int length BinaryPrimitives.ReadInt32BigEndian(lenBytes); if (length 0 || length 1024 * 1024) // 防御异常长度 throw new InvalidDataException($非法包长度: {length}); if (_buffer.Length - 4 length) return null; // 还没攒够 var body new byte[length]; _buffer.Position 4; _buffer.Read(body, 0, length); // 消费掉这一帧 var rest _buffer.ToArray().AsSpan(4 length).ToArray(); _buffer.SetLength(0); _buffer.Write(rest); return body; } }调用时机在每轮 ReadAsync 之后先 Append再循环 TryReadPacket能读到几帧就处理几帧。length 阈值上限要设一个长度字段损坏成几 GB 的数据包如果不拦截会让服务端内存立刻爆掉。BinaryPrimitives 在 .NET Standard 2.1 和 .NET Core 3.0 可用老 Framework 下自己动手移位转换字节序。这个解析器是 C# 网络服务的标准形态比逐字节解析或者按分隔符切分都可靠得多。6.2 本地压测与连接数检查30 个客户端同时连协议写好之后本地验证多客户端能力是最容易做又最容易出效果的一步。常见做法是把服务端和客户端写在同一个解决方案里客户端用并行任务拉起 N 个实例// 压测模拟 30 个客户端同时连接并各发一帧 var tasks Enumerable.Range(0, 30).Select(i Task.Run(async () { using var c new TcpClient(); await c.ConnectAsync(127.0.0.1, 9000); var stream c.GetStream(); var body Encoding.UTF8.GetBytes($client-{i}); var packet new byte[4 body.Length]; BinaryPrimitives.WriteInt32BigEndian(packet, body.Length); Buffer.BlockCopy(body, 0, packet, 4, body.Length); await stream.WriteAsync(packet); // 读一帧回复验证服务端确实处理了 var resp new byte[4]; await stream.ReadAsync(resp); Console.WriteLine($client-{i} 收到回复); })); await Task.WhenAll(tasks);连接数不是用代码看的靠系统命令最直观。Windows 上打开 PowerShell 执行Get-NetTCPConnection -LocalPort 9000能看到 Listen 和 Established 的连接Linux 上一般用ss -ant | grep 9000。重点看 Established 数量是否等于客户端数量以及有没有大量 TIME_WAIT 堆积。TIME_WAIT 大量堆积说明客户端频繁 Connect 然后快速 Dispose如果持续增长且不消散服务端性能会随会话数增长而劣化。6.3 交付前必做的三个验证每次本地验证通过后我一般还会做三件更能暴露问题的事顺序固定十次连续重启服务端确认没有端口被占用确认新客户端能在服务端启动后立刻连上把客户端断开方式从优雅 Close 改成直接杀掉进程观察服务端最多多少秒能发现并清理会话再发一万帧数据校验收到的帧数没有丢、没有错序。第三件最容易出问题因为既测粘包也测半包只要解析器把一次 Append 里可能包含多帧的场景覆盖了基本就稳了。最后一件事是和业务方对一下“在线”的定义。服务器把客户端从字典里移除之前它还能不能算在线客户端心跳停了但业务包还在发到底踢不踢。这决定了会话清理逻辑的边界。把这些问题在交付前想清楚比调再多的超时参数都重要。这个方案我前前后后在好几个项目里改过每次翻车点都集中在协议边界和连接清理上没一次是 TcpListener 本身出问题。希望帮到你。本文还有配套的精品资源点击获取
返回列表