ARTICLE DETAIL

资讯详情

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

C# WinForm TCP通信实战:TcpListener与TcpClient双端示例及粘包处理

C# WinForm TCP通信实战:TcpListener与TcpClient双端示例及粘包处理 简介这份资源面向具备一定C#基础、希望入门网络编程的开发者聚焦WinForm环境下TCP服务端与客户端的完整实现。包内以两个独立窗体项目分别演示TcpListener与TcpClient的搭建流程涵盖监听启动、连接建立、NetworkStream数据读写以及UI线程与后台通信的配合方式帮助读者理解可靠连接传输协议在实际桌面应用中的落地思路。资源共61个文件以cs源码、config配置、resx与resources界面资源、exe可执行文件及pdb调试符号为主另有sln解决方案与csproj工程文件压缩包约104KB结构紧凑便于直接打开运行调试。目前已有963人学习下载。通过对照服务端与客户端两套窗体代码读者可掌握套接字创建、连接管理、消息收发与资源释放等关键环节并在此基础上扩展群发、心跳或文件传输等功能。1. 从两个类名说起TcpListener 和 TcpClient 到底能搭出什么很多人第一次在 C# WinForm 里做 TCP 通信都是被两个类名带进来的TcpListener和TcpClient。名字直白到几乎不用查文档——一个负责监听端口等连接一个负责发起连接收发数据。但真动手写的时候问题往往不在“怎么连”而在“连上之后怎么不卡界面、怎么知道对方断了、粘包了怎么办、多客户端怎么管”。这份TcpListenrAndTcpClient.rar就是围绕这几个真实问题组织的一套 WinForm 双端示例服务端用TcpListener起监听客户端用TcpClient连上来界面线程和网络线程分开跑消息能双向收发。它适合两类人一是刚接触 Socket 编程、想找一个能跑起来的最小闭环的 WinForm 开发者二是已经会写控制台 TCP demo但一搬到窗体就卡死、不知道怎么把异步和 UI 更新接起来的人。下面按“先跑通、再拆解、最后避坑”的顺序把这份资源里的关键实现和参数选择讲清楚。2. 把双端跑起来监听、连接、收发的最小闭环2.1 服务端为什么用 TcpListener 而不是裸 SocketTcpListener本质上是对Socket的一层封装内部帮你处理了绑定、监听、接受连接这些固定动作。裸Socket当然也能写但你要自己Bind、自己Listen、自己Accept代码量和出错点都更多。对于 WinForm 这种以界面交互为主、网络只是其中一个模块的场景用TcpListener更划算它把“监听”这件事收敛成Start()和AcceptTcpClientAsync()两个调用你只需要关心拿到TcpClient之后怎么读怎么写。服务端启动监听的典型写法如下// 服务端启动监听 private TcpListener _listener; private CancellationTokenSource _cts; private async void btnStart_Click(object sender, EventArgs e) { int port int.Parse(txtPort.Text); // 端口从界面读取默认建议 8888 _listener new TcpListener(IPAddress.Any, port); _listener.Start(); // 开始监听此时端口被占用 _cts new CancellationTokenSource(); AppendLog($服务端已启动监听端口 {port}); // 循环接受连接直到取消 while (!_cts.IsCancellationRequested) { try { TcpClient client await _listener.AcceptTcpClientAsync(); AppendLog($客户端接入{client.Client.RemoteEndPoint}); _ HandleClientAsync(client); // 不 await避免阻塞接受下一个连接 } catch (ObjectDisposedException) { break; // 监听器已关闭正常退出 } } }这段代码里有三个参数值得说清楚。IPAddress.Any表示监听本机所有网卡地址如果你只想本机调试可以换成IPAddress.Loopback这样外部机器连不进来安全性更好。端口选 8888 只是习惯实际要避开 1024 以下的系统保留端口也要避开已被占用的端口否则Start()会直接抛SocketException。AcceptTcpClientAsync()是异步接受不会卡住 UI 线程这是 WinForm 里必须坚持的写法——用同步AcceptTcpClient()会让窗体直接假死。_ HandleClientAsync(client)这里用丢弃符不等待是为了让接受连接的循环继续转能同时接多个客户端。每个客户端交给独立的任务去处理互不阻塞。2.2 客户端用 TcpClient 连接与发送客户端这边就一个TcpClient指定服务端 IP 和端口去连// 客户端连接服务端 private TcpClient _client; private NetworkStream _stream; private async void btnConnect_Click(object sender, EventArgs e) { string ip txtServerIp.Text; // 例如 127.0.0.1 int port int.Parse(txtServerPort.Text); _client new TcpClient(); try { await _client.ConnectAsync(ip, port); // 异步连接不卡界面 _stream _client.GetStream(); AppendLog(已连接到服务端); _ ReceiveLoopAsync(); // 启动接收循环 } catch (SocketException ex) { AppendLog($连接失败{ex.SocketErrorCode}); } }ConnectAsync同样是异步的连接超时或对方没开监听时会抛SocketExceptionSocketErrorCode能告诉你具体原因比如ConnectionRefused就是目标端口没人监听。这里把NetworkStream存成字段后面发送和接收都复用它不要每次发消息都重新GetStream()。发送消息的写法// 客户端发送一条文本消息 private async Task SendAsync(string message) { if (_stream null || !_client.Connected) return; byte[] data Encoding.UTF8.GetBytes(message); // 先发 4 字节长度头再发正文解决粘包 byte[] lengthPrefix BitConverter.GetBytes(data.Length); await _stream.WriteAsync(lengthPrefix, 0, 4); await _stream.WriteAsync(data, 0, data.Length); }这里用了“长度前缀”方案先写 4 字节的 int 表示正文长度再写正文。接收端先读 4 字节拿到长度再按长度读正文就不会出现两条消息粘在一起分不开的情况。Encoding.UTF8保证中文不乱码BitConverter.GetBytes默认小端序两端一致即可。2.3 接收循环与 UI 线程更新接收端要循环读直到对方关闭连接// 接收循环按长度前缀读取完整消息 private async Task ReceiveLoopAsync() { byte[] lengthBuffer new byte[4]; while (true) { try { // 读满 4 字节长度头 int read await ReadExactAsync(_stream, lengthBuffer, 4); if (read 0) break; // 对方正常关闭 int bodyLength BitConverter.ToInt32(lengthBuffer, 0); byte[] bodyBuffer new byte[bodyLength]; await ReadExactAsync(_stream, bodyBuffer, bodyLength); string message Encoding.UTF8.GetString(bodyBuffer); // 跨线程更新 UI BeginInvoke(new Action(() AppendLog($收到{message}))); } catch (IOException) { break; // 连接被重置 } } AppendLog(连接已断开); } // 辅助方法确保读满指定字节数 private async Taskint ReadExactAsync(NetworkStream stream, byte[] buffer, int count) { int offset 0; while (offset count) { int n await stream.ReadAsync(buffer, offset, count - offset); if (n 0) return offset; // 流结束 offset n; } return offset; }ReadExactAsync是关键。NetworkStream.ReadAsync不保证一次读满你要的字节数可能只返回一部分所以必须循环读到够为止。少了这个循环长度头可能只读到 2 字节BitConverter.ToInt32就会读出错误长度后面全乱。UI 更新用BeginInvoke因为接收循环跑在后台线程直接改控件会抛跨线程异常。3. 多客户端管理与连接状态判断别等崩了才想起来3.1 用字典管理多个客户端服务端一旦支持多连接就得有个地方存住每个TcpClient否则发广播、踢人、统计在线数都无从下手。常见做法是用ConcurrentDictionary// 服务端在线客户端集合 private readonly ConcurrentDictionarystring, TcpClient _clients new(); private async Task HandleClientAsync(TcpClient client) { string key client.Client.RemoteEndPoint.ToString(); _clients[key] client; NetworkStream stream client.GetStream(); byte[] lengthBuffer new byte[4]; try { while (true) { int read await ReadExactAsync(stream, lengthBuffer, 4); if (read 0) break; int bodyLength BitConverter.ToInt32(lengthBuffer, 0); byte[] bodyBuffer new byte[bodyLength]; await ReadExactAsync(stream, bodyBuffer, bodyLength); string msg Encoding.UTF8.GetString(bodyBuffer); BeginInvoke(new Action(() AppendLog($[{key}] {msg}))); // 广播给其他客户端 await BroadcastAsync(key, msg); } } catch (Exception ex) { BeginInvoke(new Action(() AppendLog($[{key}] 异常{ex.Message}))); } finally { _clients.TryRemove(key, out _); client.Close(); BeginInvoke(new Action(() AppendLog($[{key}] 已下线))); } }ConcurrentDictionary比普通Dictionary加锁更省心多线程增删不会崩。finally块里一定要做清理移除字典项、关闭连接否则断开的客户端会一直占着资源时间长了内存和句柄都涨。3.2 怎么判断对方是不是断了这是 TCP 编程里最容易翻车的地方。TcpClient.Connected这个属性并不可靠——它反映的是上一次 I/O 操作时的状态对方拔网线、断电这个值可能还是true。真正靠谱的判断方式是读操作返回 0或者写操作抛IOException。所以接收循环里if (read 0) break;就是断线信号。发送时如果对方已断WriteAsync会抛异常捕获后走清理逻辑。不要依赖定时去查Connected那是自欺欺人。如果业务需要心跳就自己定一个协议客户端每隔 N 秒发一个空消息或特定标记服务端超过 2N 秒没收到就判定超时主动关闭。3.3 界面卡死的根因与异步改造WinForm 里网络代码导致界面卡死九成是因为在 UI 线程上跑了同步阻塞调用AcceptTcpClient()、Read()、Connect()这些同步版本都会卡住消息循环。改造原则就一条所有网络 I/O 用Async版本UI 更新回到BeginInvoke。另外注意async void只用在事件处理器上内部逻辑一律async Task否则异常没法捕获出了问题就是黑匣子。4. 避坑与排查这几处不踩一遍不算写过 TCP4.1 端口被占用Start 直接抛异常现象点“启动服务”按钮程序弹SocketException提示“通常每个套接字地址只允许使用一次”。原因端口已被其他进程占用或者上一次调试的程序没退干净还占着。解决换一个端口或者用netstat -ano | findstr :8888找到占用进程 PID在任务管理器里结束它。调试阶段建议把端口做成可配置别写死。4.2 中文乱码现象发出去是“你好”收到是“浣犲ソ”之类。原因两端编码不一致一端 UTF-8 一端 GBK或者用了Encoding.Default在不同机器上表现不同。解决两端统一用Encoding.UTF8发送和接收都显式指定不要用默认编码。4.3 粘包导致消息错位现象连续快速发几条消息接收端把两条拼成一条或者长度读出天文数字。原因TCP 是字节流没有消息边界不处理就会粘。解决用长度前缀接收端严格按“先读 4 字节长度、再读正文”的顺序并且用ReadExactAsync保证读满。不要用Thread.Sleep去“等一等”那是玄学不可靠。4.4 关闭窗体后后台线程还在跑现象关掉窗体进程还在任务管理器里或者再启动时报端口占用。原因接收循环还在后台跑TcpListener没停TcpClient没关。解决在窗体的FormClosing事件里取消CancellationTokenSource、调用_listener.Stop()、关闭所有客户端连接。把这些清理动作集中写在一个Shutdown方法里退出前统一调一次。4.5 跨线程操作控件抛异常现象收到消息更新文本框时抛InvalidOperationException提示“线程间操作无效”。原因接收循环在后台线程直接给控件赋值。解决所有 UI 更新都走BeginInvoke或Invoke。BeginInvoke是异步投递不阻塞后台线程更适合高频日志场景。5. 进阶技巧把这份示例改成能用的工具跑通最小闭环之后这份资源真正的价值在于它是个可扩展的骨架。我一般会先做三件事把它变成顺手的调试工具。第一把消息协议从纯文本升级成带类型字段的结构比如前 4 字节长度、接着 1 字节消息类型、再是正文这样能区分普通消息、心跳、文件传输指令。第二给服务端加一个在线列表用ListBox绑定_clients.Keys选中某个客户端就能单独给它发消息调试时比广播好用得多。第三把日志区改成带时间戳和方向的格式发送标→、接收标←排查时序问题时一眼能看出谁先谁后。验证改造是否成功可以用一个笨办法但很有效开三个客户端实例同时往服务端发消息观察服务端日志是否每条都带正确的来源标识、广播是否排除了发送者自己、关掉其中一个客户端后在线列表是否及时移除。如果这三步都对说明连接管理和清理逻辑是扎实的。还有一个容易被忽略的点TcpClient的NoDelay属性。默认情况下 TCP 会攒一批小包再发Nagle 算法调试时你会觉得消息“慢半拍”。在连接建立后设置_client.NoDelay true;可以禁用这个行为消息即时发出。代价是网络包数量变多局域网调试无所谓公网大量小消息场景要权衡。// 连接成功后禁用 Nagle降低小消息延迟 _client.NoDelay true;最后说一个我自己的习惯每次改完网络相关的代码不急着测业务逻辑先跑一遍“连接→发一条→收一条→断开→再连接”这个最短路径。这个路径能过再往上叠功能这个路径都过不了后面全是白搭。从那以后我每次动 Socket 代码都强制走一遍这个最短路径省下了大量在复杂场景里找低级错误的时间。希望帮到你。本文还有配套的精品资源点击获取
返回列表