ARTICLE DETAIL

资讯详情

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

C# TCP通信实战:FrmTcpServer与TcpClient完整解析

C# TCP通信实战:FrmTcpServer与TcpClient完整解析 简介一份采用C#与WinForm编写的TCP通信双端示例工程面向正在学习Socket编程或需要快速搭建局域网交互原型的开发者。压缩包包含服务端FrmTcpServer与客户端FrmTcpClient两个完整项目分别演示TcpListener监听接入、TcpClient主动连接、NetworkStream读写数据以及连接关闭的完整流程适合理解三次握手、确认重传等可靠传输机制在真实代码中的体现。资源一共61个文件以18个cs源码为核心配套resx/resources界面资源、config配置文件、sln/csproj工程文件以及可直接运行的exe程序整包仅120KB非常轻量其中服务端与客户端目录各自独立便于对照学习目前已有2090人学习使用。阅读源码可以看清服务端如何绑定IP与端口、设置监听队列并接受连接客户端如何解析地址后发起连接运行程序则可直观看到双方收发消息的过程。读者还可在此基础上扩展多客户端管理、消息分包、文件传输等能力是WinForm网络编程一份实用的起步模板。1. 拿到 FrmTcpServer TcpClient.rar 之后这不只是一个 Demo而是一整套 TCP 通信的骨架如果你手头刚好有这份FrmTcpServer TcpClient.rar打开之后大概率会看到两个熟悉的命名一个带窗体的 TcpServer 工程一个相对轻量的 TcpClient 工程。剥掉“窗体程序”这层皮它本质上是把 TCP 通信里最难讲清楚的两端——服务端监听与客户端接入——做成了能直接跑起来的最小闭环。对于要写上位机、做设备联调、或者要在内网里快速搭一个消息通道的工程师来说这份代码的价值不是“能跑”而是它把TcpListener的接入循环、TcpClient的连接状态、收发缓冲与 UI 线程的交互都摆在了明面上。下面的内容我会顺着这个标题拆开讲这两端各自在设计上承担了什么、怎么逐行落地、参数怎么调以及那些不跑到超时才发现的坑。2. 拆开 FrmTcpServer 与 TcpClientTCP 服务端/客户端通信的场景与选型2.1 TcpClient 不等于 TcpListener类名背后是两套完全不同的设计思路很多第一次接触 C# 网络编程的人会把TcpClient当成“客户端专用的类”见到TcpListener就以为它是“服务端专用”这其实是个误区。这两个类命名上的差异不是“服务端/客户端”的差异而是“主动连接”与“被动监听”的差异。TcpClient既可以作为客户端去Connect远端也可以在服务端AcceptTcpClient之后拿到同一个类的实例来收发数据。换句话说一旦连接建立服务端和客户端手里握着的都是TcpClient读写方式完全对称。这也是为什么很多 Demo 里服务端代码和客户端代码看着很像——真正区分角色的只有“谁在 Listen谁在 Connect”。在FrmTcpServer这个工程里服务端做的事情本质上是三步绑定 IP 和端口、启动监听、进入一个循环等待接入。每一步都有对应的坑。比如绑定监听地址时很多人图省事写IPAddress.Any结果服务端启动后只能从本机连局域网里的其他设备找不到它这是因为客户端访问的地址必须是服务端所在机器网卡上真实存在的 IP回环地址只在同一台机器内有效。我一般会这样处理服务端的监听初始化// ServerForm.cs 片段 private TcpListener _listener; private readonly ListTcpClient _clients new ListTcpClient(); private void StartServer(int port) { // 0.0.0.0 表示监听本机所有网卡局域网内其他设备才能连进来 _listener new TcpListener(IPAddress.Any, port); _listener.Start(); _listener.BeginAcceptTcpClient(OnClientConnected, _listener); }这里的关键是IPAddress.Any它展开为0.0.0.0服务端会监听本机所有可用接口。如果只填写127.0.0.1那么只有本机能连其他机器的TcpClient会被拒绝或者超时。端口号的选择也要避开常见服务端口比如 80、443、3306最好选 1024 以上的高位端口并且要在防火墙里放行。端口是 TCP 通信里的“门牌号”换 IP 不换端口或者换端口不换 IP通信都建立不起来。2.2 为什么是 C# WinForms工具箱里的控件与线程模型的匹配如果只是一次性的 socket 验证用 Python 写一个 30 行的脚本就够了为什么还要专门做一个FrmTcpServer的窗体工程原因很简单演示场景里要在界面上看到一个实时变化的连接列表、消息回显和断开状态。WinForms 的ListBox、TextBox天然适合做这种展示而且 C# 的async/await配合Task处理网络 IO代码写起来逻辑链短不容易把自己绕晕。但 WinForms 有个绕不开的规则所有的 UI 控件只能在主线程UI 线程上更新。如果在一个异步回调里直接写listBox.Items.Add(...)大概率会抛InvalidOperationException提示“跨线程操作无效”。这不是玄学是 Windows 消息循环机制决定的。控件是在主线程创建的消息队列也在主线程任何其他线程想碰控件都得先通过调度器把操作封送到主线程去执行。常见的做法是用SynchronizationContext或者Control.BeginInvoke。我习惯用后者它简单直接void AppendLog(string msg) { if (txtLog.InvokeRequired) { txtLog.BeginInvoke(new Actionstring(AppendLog), msg); } else { txtLog.AppendText($[{DateTime.Now:HH:mm:ss}] {msg}{Environment.NewLine}); } }这段代码的要点是先用InvokeRequired判断当前线程是不是 UI 线程如果不是就通过BeginInvoke把消息排到 UI 线程的队列里。这里有细节值得注意BeginInvoke是异步的它不会阻塞当前的回调线程这在高频接收数据时至关重要。如果误用Invoke同步版本接收线程会等 UI 线程处理完才继续读下一个包网络缓冲很快会被塞满表现出来就是界面卡顿、接收变慢。2.3 通信模式选型短连接、长连接、粘包分包在 Demo 里的定位FrmTcpServer TcpClient.rar这种成对出现的工程默认场景就是长连接。短连接是“每次请求都建立连接、发完数据立刻断开”适合频率低、数据量小的场景比如 HTTP 早期就是按请求断开。但如果你用 TcpClient 做一个每隔几百毫秒就上报一条数据的设备端短连接会白白耗费大量时间在 TCP 三次握手和四次挥手上面而且频繁的端口分配会让系统资源枯竭。长连接则是“建立一次持续使用”。这种模式下连接本身要带心跳保活机制否则中间一个交换机把空闲连接清掉了两端都不知道。.NET 的TcpClient自带一个KeepAlive开关但默认没有打开。很多人不知道这一步写出来的长连接程序在局域网里跑几小时没问题一上生产环境隔半天就断。设置方法是在TcpClient.Client这个底层Socket上设置tcpClient.Client.SetSocketOption( SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);另一个在 Demo 里很容易被忽视、但实际生产中一定会面对的就是粘包和半包。TCP 是一个流协议它保证的是字节顺序不保证消息边界。服务端连着收到两个Send客户端可能一次Receive就把两个消息拼接在一起读到了这就是粘包反过来一个大消息分了两个包到达客户端一次Receive只读到一半这是半包。标题里既然出现了 TcpClient 和 TcpServer你就绕不开这两个问题。解决办法要在应用层做“消息分帧”常见的是在每个消息前面加四字节的长度头。我放到第 6 章展开讲这里只需要记住结论原生 TCP 的Receive很容易一包读一半或者两包读一起必须有帧协议。3. 在服务端 FrmTcpServer 里落地监听、接入、收发消息3.1 监听器初始化TcpListener 的地址绑定与端口选择到了真正动手写服务端的时候TcpListener的初始化远远不止“new 一个对象”那么简单。我见过不少同事直接把端口写死成一个常量比如listener new TcpListener(IPAddress.Any, 8888);这在演示环境里没问题但到了部署现场就会发现端口可能被别的服务占了、防火墙只放行了特定端口、或者机器上有多张网卡有线、无线、虚拟网卡有的 IP 根本不是你预期的那张网。所以端口地址和端口号最好从配置里读至少也要提供一个输入框在界面上手动填。private void StartListening(string ipStr, string portStr) { if (!IPAddress.TryParse(ipStr, out IPAddress? ip)) { ip IPAddress.Any; // 解析失败时兜底为所有网卡 } if (!int.TryParse(portStr, out int port) || port 1024 || port 65535) { port 9000; // 非法的端口就落到默认值 } _listener new TcpListener(ip, port); _listener.Start(100); // 最大挂起连接数超过则拒绝 AppendLog($监听已启动: {ip}:{port}); _listener.BeginAcceptTcpClient(OnClientAccepted, null); }这里要留意Start(int)方法里的参数它代表“挂起连接队列的最大长度”。在同一时刻如果大量客户端同时发连接请求而你的程序还来不及逐个Accept超出的请求会在内核的队列里排队。队列满之后新的连接请求会被直接丢弃。这个值不用设太大100 对一般的内网通信工具完全够用。需要说明的是它不能替代并发处理能力Accept之后你还是要为每个连接分配独立的收发通道队列长度只是让内核帮你把连接暂时存一下。3.2 接入循环与客户端管理一个 List 兜住所有连接BeginAcceptTcpClient的好处在于回调方式接入过程不会卡死 UI 线程。但接入完成之后你要做的第一件事不是马上Receive而是把TcpClient存进一个线程安全的容器。为什么要存因为你之后要广播、要主动断连、要在连接断开时清理资源没有一个全局的客户端列表这些操作全都做不了。private readonly object _lockObj new object(); private readonly ListTcpClient _clients new ListTcpClient(); private void OnClientAccepted(IAsyncResult ar) { if (_listener null) return; TcpClient newClient; try { newClient _listener.EndAcceptTcpClient(ar); } catch (ObjectDisposedException) { return; // 服务端在关闭过程中旧的监听器已被释放 } lock (_lockObj) { _clients.Add(newClient); } AppendLog($新客户端接入当前连接数: {_clients.Count}); // 继续等待下一个客户端接入 _listener.BeginAcceptTcpClient(OnClientAccepted, null); // 为这个客户端启动独立的接收循环 Task.Run(() ReceiveLoop(newClient)); }在OnClientAccepted里顺序很重要。先EndAcceptTcpClient拿到连接对象然后把它加入列表再重新启动下一次BeginAcceptTcpClient最后才为当前客户端创建接收线程。如果顺序反了先启动接收再继续监听那么在高并发连入时前一个连接的接收线程还没建好、后一个连接就被内核丢弃。这里用lock锁住的是一个ListTcpClient只在增删和枚举时加锁保持关键路径尽量短。用List而不是Dictionary的原因是这个场景不需要按客户端标识查找广播时全量遍历就够了。3.3 接收消息异步回调与 UI 线程回填接收循环是服务端最核心的部分。很多人第一次写 TCP 接收会这样在循环里调用client.GetStream().Read(buffer, 0, buffer.Length)这是个同步方法它会阻塞当前线程直到有数据到达。如果客户端一直不发送数据这个线程就卡死在那里。对于单客户端 Demo 无所谓但服务端如果有多个客户端你就需要同时维护多个线程。虽然这样也能成但线程上下文切换的开销非常大。我建议用异步接收。private async Task ReceiveLoop(TcpClient client) { byte[] buffer new byte[4096]; NetworkStream stream client.GetStream(); try { while (client.Connected) { int bytesRead await stream.ReadAsync(buffer, 0, buffer.Length); if (bytesRead 0) { // 对端正常关闭收到 FIN 包ReadAsync 返回 0 AppendLog(客户端正常断开); break; } string receivedMsg Encoding.UTF8.GetString(buffer, 0, bytesRead); AppendLog($收到消息: {receivedMsg}); } } catch (IOException ex) { // 对方强关通常会抛异常而不是返回 0 AppendLog($连接异常: {ex.Message}); } catch (ObjectDisposedException) { AppendLog(连接已释放); } finally { RemoveClient(client); client.Close(); } }ReadAsync返回 0 是个容易误判的点它表示对端已经完成了正常的关闭流程四次挥手中的第一个 FIN 包这时候不能再把 0 当成“没数据”继续空转而是应该退出循环并清理资源。异常路径也要单独处理比如对端直接断电或者强杀进程TCP 栈来不及发 FINReadAsync会抛IOException内部错误码是ConnectionReset。很多新手只判断bytesRead 0结果对端强杀后服务端一直留着这个死连接连接数越堆越多。RemoveClient的清理逻辑也很重要private void RemoveClient(TcpClient client) { lock (_lockObj) { _clients.Remove(client); } AppendLog($客户端断开剩余连接数: {_clients.Count}); }这里用锁包裹集合操作而不是用List.Remove直接删是为了防止同一瞬间广播线程在遍历_clients访问被修改的集合导致抛异常。4. 把 TcpClient 客户端跑起来连接、发送、断线重连4.1 客户端的连接与心跳保活服务端写完了客户端这边相对省事但省事不意味着没坑。TcpClient连接远端其实就是一个Connect调用但要注意Connect在很多重载版本里是没有超时参数的。如果目标 IP 不可达Connect可能卡十几秒甚至更久才抛异常——这在界面上表现为“点击连接按钮后整个窗体假死”。解决办法是不要直接调用同步Connect改用ConnectAsync加上Task.WhenAny做超时控制。private async Taskbool ConnectWithTimeout(string ip, int port, int timeoutMs 3000) { using var tcpClient new TcpClient(); tcpClient.Client.SetSocketOption( SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); Task connectTask tcpClient.ConnectAsync(ip, port); Task timeoutTask Task.Delay(timeoutMs); Task completedTask await Task.WhenAny(connectTask, timeoutTask); if (completedTask timeoutTask) { AppendLog(连接超时请检查 IP 和端口); return false; } await connectTask; // 这里会抛异常如果连接失败 AppendLog(连接成功); // 连接成功后把 tcpClient 赋值给成员变量供后续使用 _client tcpClient; _ Task.Run(() ReceiveLoop(_client)); return true; }流程说明Task.WhenAny返回最先完成的任务如果超时任务先完成就说明ConnectAsync还卡着此时直接返回false如果连接任务先完成再await connectTask把可能的异常抛出来。这里有一个坑ConnectAsync在内部已经绑定了一个 Socket超时后不能直接丢掉这个TcpClient再用同一个实例做第二次ConnectAsync因为它的底层 Socket 状态已经脏了。最稳妥的做法是每次重连都new一个全新的TcpClient。这个细节帮我在设备联调时避免了很多“第一次连不上、第二次却连接成功”的灵异现象。4.2 发送数据的编码问题UTF-8 与 GBK 踩过的坑发送数据看似只是stream.Write一行代码但编码问题能让两个程序明明“接通了”却互相看不懂。你服务端用Encoding.UTF8.GetBytes发送接收端用Encoding.UTF8.GetString解析这没问题。问题是很多老设备模块尤其是国内 PLC、串口服务器透传默认用 GBK/GB2312 编码你这边用 UTF-8 发过去一个中文字符串对方解析出来就是乱码。public void Send(string message) { if (_client null || !_client.Connected) { AppendLog(未连接发送失败); return; } byte[] data Encoding.UTF8.GetBytes(message); NetworkStream stream _client.GetStream(); // 通过 Stream.WriteAsync 写入避免阻塞 UI stream.WriteAsync(data, 0, data.Length); _ stream.FlushAsync(); }这里两个async方法都用_ 丢弃了返回值在 WinForms 中只做“发送后不管”不等待发送完成。严格来说这种方式不保证数据已经写到网卡但对消息量不大的场景已经够用。要注意的是发送前必须检查_client.Connected属性。这个属性判断的是“上一次通信时 Socket 是否还活着”不是实时状态所以它会给出假阳性结果——连接早就断了对端但Connected还是true。要拿到真正的实时状态只能通过发送或接收数据时的异常来感知。所以发送时最好包一层try-catchtry { _ stream.WriteAsync(data, 0, data.Length); _ stream.FlushAsync(); } catch (IOException ex) { AppendLog($发送失败连接已断开: {ex.Message}); CloseConnection(); }发送和 UI 线程的交互也有讲究WriteAsync虽然不阻塞 UI但如果你连续点击发送按钮几十次所有写入操作会被 OS 缓冲短时间内网卡发不出去内存飙升。客户端这边应该加一个“发送中”的禁用状态防止高频连点。4.3 断开连接的判断与重连策略断线重连是标题里 TcpClient 这个角色最常见的生产需求但 Demo 里往往没有。客户端连上服务端之后如果不做任何心跳一旦中间链路静默断开——比如交换机关电、网线松了、服务端进程崩溃——客户端要等到下一次发送数据时才发现异常。所以要么定期发送心跳包要么不做心跳、把重连逻辑交给用户手动点击。考虑到成熟方案我会在客户端写一个半小时级的心跳任务每 15 秒发送一个约定的短消息服务端不需要回复只要发送成功就说明链路还通。重连策略要注意“退避重试”。如果服务端没起来客户端会每隔 3 秒重连一次反复失败反复重试日志瞬间刷满。更稳的做法是第一次重连等 1 秒失败后等待时间翻倍到 30 秒封顶并且支持手动重置private async Task ReconnectLoop() { int retryDelayMs 1000; const int maxDelayMs 30000; while (!_isClosed) { bool connected await TryConnectAsync(); if (connected) { retryDelayMs 1000; // 成功后重置退避时间 return; } AppendLog($重连失败{retryDelayMs / 1000} 秒后重试); await Task.Delay(retryDelayMs); retryDelayMs Math.Min(retryDelayMs * 2, maxDelayMs); } }指数退避的优势是避免“风暴式重连”把自己和服务端同时拖垮。这行Math.Min(retryDelayMs * 2, maxDelayMs)就是边界控制的关键。有些场景需要客户端断线后主动重新订阅服务端的消息通道比如服务端推送配置那么重连成功之后要补一个“重新注册/重新订阅”的动作这在 Demo 里如果没见过在实际项目中很容易漏。5. 排查与避坑TCP 通信里最容易翻车的 5 个地方5.1 现象服务端启动后局域网内其他机器连不上原因监听地址写成了127.0.0.1或者IPAddress.Loopback。这个地址只绑定在本机环回网卡上来自局域网任何其他机器的 SYN 包都会直接被内核丢弃。同时也要检查 Windows 防火墙是否放行了对应端口很多系统自带安全策略默认拦截入站连接。解决监听地址改成IPAddress.Any即0.0.0.0然后在控制面板里为程序或端口添加入站规则。开发环境下可以用netsh advfirewall firewall add rule来临时放行但生产环境建议只放行指定程序路径而不是放行整个端口范围。5.2 现象客户端能连上服务端但收不到任何消息或者收到的是乱码原因十有八九出在编码不一致上。服务端用Encoding.UTF8.GetBytes发送对端用Encoding.Unicode.GetString接收中文一定会乱码还有一些嵌入式设备用Encoding.ASCII发送但你用Encoding.UTF8解析多字节字符会直接丢字节。另外如果一端用了StreamWriter内部自带缓冲写了数据没有Flush()数据一直留在用户态缓冲区里。解决统一编码建议在项目里把Encoding的实例抽成一个公共静态只读字段不要在每个方法里写死。如果涉及第三方设备先发一个纯英文字符串验证链路是否通再逐步引入中文和其他字符集。发送后记得Flush尤其是用NetworkStream配合StreamWriter时。5.3 现象服务端接收循环偶尔报SocketException错误码 10054原因这个错误码对应ConnectionReset说明对端进程崩溃、断电或网线拔出时没有走正常 TCP 关闭流程。TCP 栈会发送一个 RST 包服务端在Receive时抛出异常而不是返回 0。很多代码里只判断bytesRead 0没处理异常就会让连接资源一直挂在那里线程池的线程也被占用。解决在接收循环的catch里区分错误码。10054直接视为对端强制断开走清理流程10060对应连接超时可能出现在Connect阶段而不是接收阶段需要单独提示。建议把错误码打点打到日志别只输出异常消息因为ex.Message在不同 .NET 版本中是本地化字符串排障时不如错误码直接。5.4 现象接收到的消息是一条超长拼接字符串两条消息粘在一起原因TCP 是流协议应用层的Send方法并不对应网络上的一个“包”。操作系统可能把两次WriteAsync的数据合并成一个 TCP 段发送接收端一次Receive就会读出这个合并后的字节流。这就是第 2 章里提到的粘包问题。如果只有一个客户端且在局域网内跑它不会必然出现但一旦网络环境复杂经过路由器、带宽受限粘包就会频繁出现。解决给每个消息加帧边界。最简单的是在消息末尾加特殊分隔符但这要求消息内容本身不能含有该分隔符更稳的是在消息开头加四字节长度头接收端先读满长度头、再按长度读完整消息体。具体代码在下一章给。5.5 现象WinForms 窗体关闭后后台线程还在运行进程不退出原因Task.Run启动的接收循环是后台线程但它调用了ReadAsync这会让线程池的 IO 线程被占用。窗体的FormClosing事件里如果没有关闭监听器、没有遍历断开所有客户端连接那些阻塞在ReadAsync上的线程就不会结束进程也就一直挂在后台。加上任务管理器能看到进程但窗体早就没了。解决在窗体关闭事件里做有序释放先停止接受新连接_listener.Stop()然后遍历_clients逐个Close()最后再调用Environment.Exit(0)兜底强制退出。前两步是给远端发送 FIN 包第三步是防止漏网线程阻塞退出。顺序不能反如果先Close客户端再Stop监听器新连接可能会在停止前的一瞬间进来但已经没有接收循环在等它了。6. 进阶技巧用四字节长度头解决粘包/半包把 Demo 变成能扛生产压力的协议到了这一步FrmTcpServer和TcpClient已经能完成最基本的收发闭环了。但如果你把这个套路上到真实的设备联调或生产环境里第一个挡路的就是粘包和半包。演示环境下网络干净、消息频率低、数据量小粘包几乎不发生实际现场一旦有多个设备同时上报、或者单条消息超过几 KB接收端就会开始看到“两条消息拼成一条”“一条消息只读了一半”这种让人头疼的现象。所以这一章我给出一个足够简单、又能彻底解决这个问题的“四字节长度头”方案。方案本身不复杂发送端在每个消息前附加 4 个字节的 Int32 整数表示消息体长度接收端先读满 4 字节头部解析出长度 L再继续读 L 字节消息体。这样一个完整的消息永远是“4 字节头部 L 字节内容”边界清晰就不会串。发送端封装一个方法public static byte[] WrapMessage(string msg) { byte[] body Encoding.UTF8.GetBytes(msg); byte[] header BitConverter.GetBytes(body.Length); // 小端序 return header.Concat(body).ToArray(); }注意这里的字节序。BitConverter.GetBytes(int)在 .NET 上默认输出小端序低字节在前。如果你将来要跟 C 或 Python 写的服务端对接要统一约定字节序。C# 端建议显式处理不要隐式依赖默认行为。接收端循环里这样解析private async Task ProcessMessages(NetworkStream stream) { byte[] headerBuf new byte[4]; while (true) { // 1. 先读满 4 字节长度头 int headerRead 0; while (headerRead 4) { int n await stream.ReadAsync(headerBuf, headerRead, 4 - headerRead); if (n 0) throw new IOException(对端关闭); headerRead n; } int msgLength BitConverter.ToInt32(headerBuf, 0); if (msgLength 0 || msgLength 1048576) // 1MB 上限保护 { throw new InvalidDataException($非法长度: {msgLength}); } // 2. 按长度读满消息体 byte[] bodyBuf new byte[msgLength]; int bodyRead 0; while (bodyRead msgLength) { int n await stream.ReadAsync(bodyBuf, bodyRead, msgLength - bodyRead); if (n 0) throw new IOException(对端关闭); bodyRead n; } string message Encoding.UTF8.GetString(bodyBuf); AppendLog($收到完整消息: {message}); } }这个接收逻辑里有三个细节值得注意。第一ReadAsync可能只读到头部的一部分就比如先返回 2 个字节所以内层循环必须坚持读到满 4 字节为止绝对不能直接假设一次ReadAsync就能凑够包头。第二msgLength要做合法性检查网络另一端是可能有脏数据的如果读到负值或者超大值直接分配一个几 GB 的字节数组会立刻 OOM。第三循环里我用了两层 while第一次是避免半包第二次是避免半包但两者面向的边界不同——第一次是在长度头上半包第二次是在消息体上半包。这是一个很容易被忽略的细节很多人的代码只处理了“读消息体不够”的情况但没处理“读头部不够”的情况。发送端的WrapMessage把带长度头的字节流推给WriteAsync每一步都很直白。这样客户端和服务端只要都遵守同一个“封包/解包”约定粘包和半包就不再影响消息完整性。落地到FrmTcpServer工程里我会把WrapMessage和ProcessMessages抽成一个独立的PacketCodec.cs静态类而不是塞进 Form 代码里。这样做的好处是一旦以后要切换到Socket的SendAsync或者换成 UDP 实现只需要改这一个类。最后说一个我自己的习惯每次写完收发逻辑我都会开两个窗口一个跑服务端、一个跑客户端故意把客户端循环发送的频率调到Thread.Sleep(1)这个极限级别再故意在发送端调用Send后立刻断开。这时候如果协议解析有边界漏洞立刻就会暴露。跑这个压力测试跑三分钟没问题再上现场我才有底。这套流程帮我避过不少只在“真实网络”里才会出现的坑也希望帮到你。本文还有配套的精品资源点击获取
返回列表