ARTICLE DETAIL

资讯详情

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

C#网络编程:TcpListener与TcpClient多客户端实战

C#网络编程:TcpListener与TcpClient多客户端实战 简介这是一份基于C#和WinForm的TCP服务端与客户端示例工程适合有一定C#基础、想要快速掌握网络通信开发的读者。压缩包内包含FrmTcpServerV2与FrmTcpClientV2两个完整WinForm项目对应TcpListener服务器和TcpClient客户端演示了从创建监听、等待连接、获取NetworkStream到收发数据的完整流程两个窗体分别对应服务端与客户端包含按钮、文本框等控件可直观观察连接状态及消息往来。包内共61个文件大小约104KB包括18个C#源码文件、6个可执行程序、6个配置文件、4个resx资源文件及工程解决方案等源码与编译后的exe同时提供便于直接运行和二次开发目前已有963人浏览学习。通过学习这两个项目可以掌握TCP通信中Socket核心类的使用、后台线程处理网络收发的思路以及如何在WinForm界面中实时显示连接状态和收发消息尤其适合作为网络编程入门或课设参考。1. 为什么说 TcpListenrAndTcpClient 这组类对是 C# 网络编程的第一道门拿到一个 TcpListener 加 TcpClient 的示例工程基本等于拿到了 C# Socket 编程的最小闭环。TcpListener 负责在一台机器上监听端口、等待别人来连TcpClient 负责主动去连那台机器连上之后两端通过 NetworkStream 互相读写字节流。很多从业者第一次在局域网里完成两台机器之间的数据收发就是从这对类组合开始的。这个方向适合两类人一类是要写网关、设备采集、桌面软件间通信的 C# 开发者需要快速理清谁是服务端谁是客户端另一类是刚接触网络编程的新手想知道 TCP 长连接到底怎么维持、多客户端过来服务端怎么接住、数据怎么拆包才不乱。下面按服务端、客户端、编码协议、排查、进阶验证的顺序把这个 rar 里的内容讲透。全文不依赖任何第三方框架用 .NET 自带的类就能跑通。2. 服务端先行用 TcpListener 撑起多客户端接入2.1 TcpListener 的监听模型Accept 循环才是服务端的心脏TcpListener 的工作流程其实只有三步创建监听器并绑定端口、调用 Start 开始监听、循环调用 Accept 方法接收客户端连接。很多第一次写的人会误以为 Accept 一次就能服务所有客户端实际上 Accept 每次只返回一个 TcpClient代表一个已经完成三次握手的连接。要服务多个客户端就必须在循环里反复 Accept拿到一个连接就交给一个处理单元然后立刻回来继续 Accept。最常见且最省心的写法是用 AcceptTcpClientAsync 配合 while 循环。下面这个代码片段是可以直接抄进 Program.cs 的最小服务端骨架// 一个简化的 TCP 服务端监听 8080接收多个客户端连接 using System.Net; using System.Net.Sockets; public class TcpServer { private readonly TcpListener _listener; private readonly CancellationTokenSource _cts new(); public TcpServer(int port 8080, int backlog 128) { // IPAddress.Any 表示监听本机所有网卡地址 _listener new TcpListener(IPAddress.Any, port); // backlog 是内核等待 Accept 的连接队列长度 _listener.Start(backlog); } public async Task RunAsync() { try { while (!_cts.Token.IsCancellationRequested) { // 每 accept 到一个客户端就交给一个独立任务处理 TcpClient client await _listener.AcceptTcpClientAsync(_cts.Token); _ Task.Run(() HandleClientAsync(client, _cts.Token)); } } catch (OperationCanceledException) { // 正常停止流程忽略取消异常 } finally { _listener.Stop(); } } }这段代码里创建 TcpListener 时传了 IPAddress.Any意味着不管客户端从哪个网卡过来都能接住。Start 里的 backlog 参数控制的是尚未被 Accept 取走的连接队列长度默认值 128 对大多数场景够用但局域网内同时发起几百个短连接时这个值值得调大否则客户端会感觉连不上。AcceptTcpClientAsync 会阻塞等待直到有连接进入拿到连接后立刻用 Task.Run 转到后台处理主循环继续监听新连接。需要留意的是 AcceptTcpClientAsync 带 CancellationToken 的重载需要 .NET 7 及以上版本。如果你的项目还停在 .NET Framework 4.8 或 .NET Core 3.1就用不带 token 的版本await _listener.AcceptTcpClientAsync()停止时直接走_listener.Stop()让它抛异常退出循环即可效果差别不大。2.2 多客户端连接来了怎么处理每连接一个 Task 的平衡点服务端接到多个客户端后最常见的处理模式是每连接开一个线程或 Task。C# 里 Task.Run 是线程池调度的不会像 new Thread 那样每连一个就占一个专用线程连接数量在几百以内时开销可以接受这正好贴合热词里说的 c# tcplistener 多客户端场景。下面补全 HandleClientAsync这是每个客户端连接从建立到断开期间的服务端行为private static async Task HandleClientAsync(TcpClient client, CancellationToken ct) { using (client) // TcpClient 内部持有 Socket用完必须释放 using (NetworkStream stream client.GetStream()) { byte[] buffer new byte[4096]; int read; // ReadAsync 返回 0 表示对端正常关闭返回 -1 或异常表示异常断开 while ((read await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), ct)) 0) { string received Encoding.UTF8.GetString(buffer, 0, read); Console.WriteLine($[{DateTime.Now:HH:mm:ss}] 收到: {received}); // 处理完成后回写一条应答让客户端知道服务端收到了 byte[] response Encoding.UTF8.GetBytes($服务端已收到 {read} 字节); await stream.WriteAsync(response.AsMemory(), ct); } } catch (IOException ex) { // 客户端强行断开时 Read 会抛 IOException这里做日志记录即可 Console.WriteLine($连接异常断开: {ex.Message}); } }ReadAsync 返回值的语义是核心返回 0 代表对端干净地关闭了连接此时服务端应该结束这个连接的处理流程并释放资源抛 IOException 则多半是网络被重置或者对端进程崩溃。缓冲区大小 4096 是起步值如果单条消息可能超过这个长度后面第 4 章会专门讲怎么处理拆包这里先记住一个原则一次 ReadAsync 读到的数据不一定是完整的一条消息。关于多客户端之间会不会互相影响只要每个 TcpClient 实例都只在独立的 Task 里被读写互不共享同一个 Stream就不会出现 A 客户端发来的数据跑到 B 客户端那边去。常见的翻车点反而是服务端在 HandleClientAsync 里用了共享的内存缓冲区或者静态集合去存连接多客户端一上来就出现数据串包。每个连接独立创建 byte[] 是最稳妥的做法。2.3 监听服务停止时怎么优雅退出别直接杀进程服务端程序停止时如果直接Environment.Exit或关了控制台窗口已经建立的连接会被操作系统强行重置客户端那边会第一时间看到 ConnectionReset。正规做法是停止 Accept 循环同时通知所有已建立的连接进入收尾流程。做法是在 RunAsync 的 finally 里先 Stop 监听器再遍历保存的客户端列表逐个 Close。注意 Stop 之后尚未 Accept 的连接会留在队列里被丢弃对端会表现为连接成功后第一次读写立刻失败所以停止服务前最好先给所有客户端推送一条下线通知等几百毫秒再 Stop这是实际部署时避免线上客户端长时间等超时的土办法。public void Shutdown() { if (_cts.IsCancellationRequested) return; _cts.Cancel(); // 这里可以在客户端连接集合里广播 服务端即将下线 _listener.Stop(); }Shutdown 方法里先 Cancel 让 Accept 循环退出再 Stop 释放端口。注意 Stop 之后 TcpListener 对象就不能再 Start 复用需要重新 new 一个实例这是很多人忽略的边界。3. 客户端补齐TcpClient 连接、读写与断线重连的正确姿势3.1 ConnectAsync 一定要套超时裸 Connect 会把线程卡死TcpClient 的同步 Connect 方法在执行时会阻塞线程目标 IP 不可达时阻塞时间可能长达几十秒界面程序直接卡死。正确姿势是用 ConnectAsync 加上取消机制。.NET 7 以上可以直接给 ConnectAsync 传 CancellationToken老版本可以用 WaitAsync 达到同样效果using var client new TcpClient(); using var timeoutCts new CancellationTokenSource(TimeSpan.FromSeconds(5)); try { // 5 秒内连不上就抛 OperationCanceledException await client.ConnectAsync(IPAddress.Parse(192.168.1.100), 8080, timeoutCts.Token); Console.WriteLine(连接成功); } catch (OperationCanceledException) { Console.WriteLine(连接超时); }IPAddress.Parse 用来把字符串 IP 转成 IPAddress。这里的超时尤其重要TcpClient 内部连接失败后会做 TCP 重传默认重传间隔会拖到很长时间不显式控制超时的话一旦对方主机关机或者防火墙丢弃 SYN客户端能卡到怀疑人生。3.2 客户端的读写模型记住 TcpClient 不是流NetworkStream 才是连上之后TcpClient 本身不具备 Read/Write 能力真正干活的是它暴露出的 GetStream()。读写规则和服务端完全对调客户端负责往 NetworkStream 里写请求然后读服务端返回的应答。一段最小可用读写逻辑如下using var client new TcpClient(); await client.ConnectAsync(IPAddress.Loopback, 8080); await using NetworkStream stream client.GetStream(); // 发送一段文本数据 byte[] request Encoding.UTF8.GetBytes(hello from client); await stream.WriteAsync(request.AsMemory()); await stream.FlushAsync(); // 读取服务端应答和接收文件一样要循环读因为一次读不够全量数据 byte[] buffer new byte[1024]; using var ms new MemoryStream(); int read; while ((read await stream.ReadAsync(buffer.AsMemory())) 0) { ms.Write(buffer, 0, read); if (read buffer.Length) break; // 粗略认为服务端已经发完 } Console.WriteLine($应答: {Encoding.UTF8.GetString(ms.ToArray())});这个例子里if (read buffer.Length) break是偷懒写法它假设服务端一次 WriteAsync 发送的数据量小于 1024 字节所以读完一块就能断定消息结束了。真实场景中消息边界不可靠必须用协议来界定第 4 章的长度前缀方案就是替代这个 break 的正规办法。这里先让读者跑通链路。注意 ReadAsync 会一直阻塞到读到数据或者对端关闭所以客户端和服务端约定“谁先发、谁先读”很重要。如果两端都先读后写就会出现经典的死锁各自都等着对方先发数据。3.3 断线重连不能只靠 Socket.Connected 判断客户端掉线之后要自动重连很多初学者会写一个检测循环不断访问client.Client.Connected这个属性来判断连接是否还在。实际这个属性只有在主动断开的场景下才是准确判断网络被拔线、对端断电时它可能仍然返回 true因为操作系统还没收到任何 RST 包。可靠的做法有两条路径第一条是读写时捕获 SocketException这是最真实的连接状态反馈第二条是应用层心跳服务端一段时间没收到客户端数据就主动断开客户端发现服务端断开后进入重连流程。推荐至少实现第二条因为它同时解决了“客户端假死”和“服务端半开连接”两个问题。private static async TaskTcpClient ConnectWithRetryAsync(string ip, int port) { int retryCount 0; while (true) { try { using var cts new CancellationTokenSource(TimeSpan.FromSeconds(3)); var client new TcpClient(); await client.ConnectAsync(IPAddress.Parse(ip), port, cts.Token); return client; } catch (OperationCanceledException) { retryCount; // 第一次失败等 1 秒第二次等 2 秒指数退避最多等 10 秒 int delay Math.Min(1000 * (int)Math.Pow(2, retryCount), 10000); Console.WriteLine($第 {retryCount} 次重连失败{delay / 1000} 秒后重试); await Task.Delay(delay); } } }指数退避的重连策略比固定间隔重试更实用服务端刚重启的十几秒内固定 1 秒重试会疯狂打端口指数退避能减轻服务端的半连接压力。这个循环体里没有资源清理的代码实际应用时要把上一次失败的 TcpClient 对象置空再 new 新的避免旧对象一直占着内存里的 Socket 句柄放不掉。4. 让字符串和二进制安全过网编码、粘包半包与 Nagle 的三道协议门槛4.1 TCP 是流不是消息粘包半包到底是哪来的TCP 是字节流协议它不保证一次 WriteAsync 的数据会被对端一次 ReadAsync 完整接收。比如一个字符串“你好世界”转成 12 字节可能出现三种情况服务端一次读到完整的 12 字节服务端先读到 7 字节、再读到 5 字节服务端一次读到 24 字节因为客户端连续发了两条消息被 TCP 合并了。第一种叫正常第二种叫半包第三种叫粘包。这不是 bug而是 TCP 的底层行为。Nagle 算法会把小包攒到一定大小再发出去接收方的内核缓冲区也可能把连续到达的数据合并成一个包交给应用程序。只要有一天你的程序要传超过缓冲区大小的数据或者要区分两条相邻消息的边界就必须自己设计应用层协议。4.2 长度前缀法最省心也最容易抄的报文格式业界最简单可靠的消息定界方式是“4 字节长度头 消息体”。发送方先将要发送的数据长度用 BitConverter 转成 4 字节整数拼在消息体前面一次性写入接收方先读满 4 字节解析出长度再按这个长度把剩下的消息体读完整。下面是一套可直接复用的封包代码public static byte[] BuildPacket(string message) { byte[] body Encoding.UTF8.GetBytes(message); // 用位转换把长度转成 4 字节网络字节序用大端这里默认本机字节序 byte[] header BitConverter.GetBytes(body.Length); byte[] packet new byte[4 body.Length]; Buffer.BlockCopy(header, 0, packet, 0, 4); Buffer.BlockCopy(body, 0, packet, 4, body.Length); return packet; }对应接收方的解包要做一个完整的读取器因为一次 ReadAsync 可能只拿到消息的头部或者只有半截消息体。下面这个循环会先把头凑齐再读 body// 从 stream 里精确读取 count 字节传闻读不满就一定继续读 public static async Taskbyte[] ReadExactlyAsync(NetworkStream stream, int count) { byte[] buffer new byte[count]; int offset 0; while (offset count) { int read await stream.ReadAsync(buffer.AsMemory(offset, count - offset)); if (read 0) throw new EndOfStreamException(对端提前关闭); offset read; } return buffer; } public static async Taskstring ReadPacketAsync(NetworkStream stream) { byte[] header await ReadExactlyAsync(stream, 4); int bodyLength BitConverter.ToInt32(header, 0); if (bodyLength 0 || bodyLength 10 * 1024 * 1024) { throw new InvalidDataException($非法消息长度: {bodyLength}); } byte[] body await ReadExactlyAsync(stream, bodyLength); return Encoding.UTF8.GetString(body); }读满逻辑里最忌讳的是ReadAsync返回不足 count 就继续有个巨大的坑它会把半包和粘包统一处理掉了。bodyLength上限 10MB 是必要的保护否则对端发一个巨大的长度字段会把内存吃光这是最基础的防呆设计。两个方法配合起来服务端和客户端在各自读循环里直接调用ReadPacketAsync就不用再手动处理read buffer.Length那种粗糙判断了。这套长度前缀方案有个额外好处长度头本身也让对端能够区分“一条消息”和“一阵数据”粘包问题直接在 ReadExactly 分发层消解掉业务层拿到的永远是一条完整的消息。4.3 Nagle 算法与 NoDelay小包流量别让它攒了TCP 默认启用 Nagle 算法它会把多个小包合并成一个再发送这能显著降低小包场景下的网络开销但会引入最多 200ms 的延迟。对实时性要求高的场景——比如键盘输入指令、鼠标遥测数据——这 200ms 是不可接受的。C# 里控制该行为非常直接client.NoDelay true;注意要设置成立刻生效是在 Connect 成功之后、开始读写之前。NoDelay true 会关闭 Nagle 合并每个小包都立刻送出。代价是小包数量多时网络帧数上升局域网内问题不大公网环境在高 QPS 场景要谨慎评估带宽消耗。这里还有个隐藏坑如果你自己实现了 4.2 的长度前缀封包发送时其实已经把多条消息的 body 拼在同一个 byte[] 里了等于在应用层自己做了粘包此时开不开 NoDelay 影响相对小但还是建议对交互类应用开启。4.4 编码选型文本用 UTF-8二进制别碰字符串这个 rar 里如果只需要传文本用Encoding.UTF8足够它兼容 ASCII 且支持中文。但要注意Encoding.UTF8.GetString和GetBytes的配对使用不能乱换发送用 UTF8 编码、接收用 GB2312 解码中文内容会变成乱码。如果传的是真正的二进制数据——图片、压缩包内容、序列化对象——就不要走字符串编解码直接对 byte[] 做操作。长度前缀协议里的 body 本身是 byte[]字符串只是其中的一种解释方式。取舍建议消息体是类对象时用 System.Text.Json 序列化成 UTF-8 字节再包长度头跨语言互操作性最好追求极致性能再考虑 MemoryPack、MessagePack 这类二进制序列化框架。示例工程里先以 UTF-8 文本起步后面需要升级协议时只管替换序列化那一层长度前缀的框架不用动。5. 落地排查TcpListener/TcpClient 最常见的五类翻车现场5.1 服务端 Start 直接抛 SocketException端口被占或权限不足现象是服务端程序启动瞬间抛SocketException: 以一种访问权限不允许的方式做了访问套接字的尝试。原因有两个方向一是端口被其他进程占用或者之前 Run 过的进程没有正确退出端口还在 TIME_WAIT 状态二是监听 1024 以下端口时没有管理员权限。解决先查占用再换端口Windows 上netstat -ano | findstr 8080找到占用进程 PID然后任务管理器结束它Linux 上ss -lntp | grep 8080。反复重启调试时可以在代码里 Start 之前加一行_listener.ExclusiveAddressUse false允许端口在 TIME_WAIT 期间被复用但这只是调试阶段的后悔药生产环境别用。5.2 Accept 循环抛异常导致整个服务退出现象某个客户端连接异常断开HandleClientAsync 里的异常一路冒泡到 RunAsync 的循环里Accept 循环退出了服务端所有客户端一起断线。解决要把 HandleClientAsync 的异常完完全全吞在该方法内部。Task.Run 启动的后台任务异常如果没有被捕获会在 Task 的 awaiter 上抛出来靠外层 while 是抓不住的。我在实际项目中是这样兜底的_ Task.Run(() HandleClientAsync(client, _cts.Token)) .ContinueWith(t Console.WriteLine($连接处理失败: {t.Exception}), TaskContinuationOptions.OnlyOnFaulted);5.3 多客户端时 A 客户端的数据被 B 客户端收到现象两个客户端同时连着服务端A 发了一条消息B 的控制台打印出了这条消息。原因几乎可以断定是服务端代码对 TcpClient 集合的管理出错了比如用 List 保存连接时读取循环用了共享的 byte[] 缓冲区A 读数据时把内容写进了全局 bufferB 的下一次读会拿到 A 的残渣。解决就是每个连接的读写缓冲、状态、上下文全部独立创建绝不放在类字段上共享。5.4 客户端连得上却收不到服务端的主动推送现象客户端连接成功但服务端主动 Write 数据客户端一直收不到卡在 ReadAsync。一个典型原因是服务端没有调用 Flush。NetworkStream 的 WriteAsync 不是每种情况下都会立刻把数据推到网络上即使写了也可能攒在内部缓冲区。虽然很多场景不 Flush 也能发出去但为了确定性服务端的每次 WriteAsync 后面跟一个 FlushAsync 几乎不会出错。另一个原因是客户端在读之前进了一个不该进的循环比如先发了数据又立刻进入了 Wait 模式。5.5 半包导致长度头解析出错程序抛 ArgumentOutOfRangeException现象解析长度头时BitConverter.ToInt32 读到一个巨大的负数或者超过缓存上限的数字。原因是 ReadExactly 没实现好拿到的是一个不完整的 4 字节长度头或者对端用的是高位字节序网络序而本机是小端解析把长度值解析成了完全不同的数字。解决分两层第一层按 4.2 的模式把读满补齐第二层做大小端统一用BinaryPrimitives.ReadInt32BigEndian或者发送时把整数直接转成网络字节序。不要在协议栈的 Teensy 细节上翻车这是 TCP 程序最容易踩但查到最后发现只是字节序写反的经典血泪案例。6. 从示例到产品心跳保活与本地压测的验证技巧6.1 心跳包怎么设计才既及时又不扰民应用层心跳是最直接的连接保活手段。约定一个特殊消息类型客户端每 15 秒发一次心跳服务端如果 60 秒内没收到来任何数据就判定客户端掉线主动 Close。心跳消息走单独的协议类型字段不需要把业务逻辑牵连进来最简单是用 JSON 序列化一个{type:ping}的长度前缀包。服务端的超时判定不建议在 ReadAsync 上加 CancellationToken 硬等更实用的是用 Timer 定期检查每个连接的最后活跃时间。在 HandleClientAsync 里记录_lastActive用一个后台循环检查now - _lastActive是否超过阈值超过就直接 Close。心跳和正常业务消息都可以刷新_lastActive因为网络通着就够了。6.2 本地压测用脚本开多个客户端看服务端是否稳定写完服务端先别急着上生产用几行 C# 控制台代码模拟 50 个客户端同时连接每个连接循环发消息。先确认一件事连接全建立后服务端的线程数或者 Task 数是否合理线性增长。// 压测脚本模拟 50 个并发客户端 var clients new ListTcpClient(); for (int i 0; i 50; i) { var client new TcpClient(); await client.ConnectAsync(IPAddress.Loopback, 8080); clients.Add(client); byte[] packet BuildPacket($client-{i}); await client.GetStream().WriteAsync(packet.AsMemory()); } Console.WriteLine($已建立连接: {clients.Count}); Console.ReadKey();50 个客户端全连上、服务端没有明显卡顿和未处理异常崩溃才说明 Task 模式在目标规模内是可行的。要做更长远的验证就测断线重连随机强杀一个客户端进程观察服务端是否在心跳超时后清掉连接再启动新的客户端能否立刻连进来并正常收发。6.3 最后两个工程习惯观察 TIME_WAIT 和设置 KeepAlive一是结束调试后用netstat -ano | find 8080看一眼端口状态大量 TIME_WAIT 说明你的服务端在频繁创建并关闭短连接这对长连接型应用是不正常的。二是生产环境建议把 TcpClient 的底层 Socket 设置为 TCP KeepAlive让操作系统在应用层心跳之外再做一层兜底检测client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);设置后内核会定时探测对端是否存活应用层心跳主要负责业务级超时两者分工不同。这么多年调试 TcpListener/TcpClient 踩的坑九成出在“消息边界”和“连接关闭的确定语义”上剩下的一成是编码与字节序。先把第 4 章的长度前缀和第 5 章的异常兜底做扎实这个示例工程就能从玩具走到可用的网关程序。希望帮到你。本文还有配套的精品资源点击获取
返回列表