ARTICLE DETAIL

资讯详情

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

C# Winform Socket通信实战:TcpListener多客户端接入与心跳断线重连

C# Winform Socket通信实战:TcpListener多客户端接入与心跳断线重连 简介一个基于C# Winform的套接字通信完整项目面向初学网络编程与多线程开发的读者重点演示服务端与多个客户端同时连接的处理方式。压缩包内共59个文件主要由C#源码、窗体界面文件、工程配置文件以及可执行程序构成整体仅113KB其中源码负责通信逻辑窗体文件用于界面布局配置文件记录项目依赖exe便于直接运行体验。项目分离了服务端与客户端两个工程分别实现套接字对象的建立、绑定、监听、接受连接以及数据收发等关键流程服务端借助多线程为每个连接分配独立处理并通过锁机制规避共享资源的同步问题同时补充了网络中断、超时等常见异常的应对思路。已有111人学习下载可作为理解网络通信、多线程协作以及Winform界面开发的实用参考。1. C# winform Socket 通信服务端、客户端与多连接并存的第一步做 C# 上位机的迟早要碰一次 Socket 通信设备把数据推过来上位机要接住上位机要下发指令设备要收到。这份资源就是一个最直观的 C# winform Socket 例子——服务端和客户端都在独立窗口里跑服务端能同时接入多个客户端每个客户端的收发消息都在界面日志里看得见。它适合两类人一类是想看懂 Socket 服务端和客户端到底怎么协作的初学者另一类是手头有调试工具需求、想直接拿现成代码改改就用的从业者。支持多客户端同时连接这个特性正是它比教科书示例实用的地方。2. 服务端框架TcpListener 监听与多客户端接入2.1 选型为什么服务端先用 TcpListener 而不是裸 SocketC# 里写 TCP 服务端常见的姿势有三种直接用 System.Net.Sockets.Socket 从头写监听用 TcpListener 监听并接受连接再往上走直接套 Kestrel 或者第三方网络库。这套资源选的是 TcpListener TcpClient 的组合这个选择很务实TcpListener 内部操作的本质还是 Socket但它把 Bind、Listen、Accept 这一串底层动作封装好了AcceptTcpClientAsync 直接返回一个 TcpClient后续收发统一用 NetworkStream 读写跟客户端侧的代码风格也一致。如果用裸 SocketBind 地址、Listen 队列、Accept 之后还要自己包装 NetworkStream这些细节对 winform 调试工具型项目来说只是增加代码量没有额外收益。很多 socket 网络编程入门教程爱拿裸 Socket 讲原理不等于项目里也应该这么写。提示TcpListener 不是另一个协议栈它只是 Socket 的 TCP 服务端封装。选择它的理由是收编底层细节而不是绕过 TCP。还有一点要注意的是线程模型。这套服务端里每个客户端接入后就丢给一个 Task 去处理收发而接受新连接的主循环始终保持空闲。Task 由线程池调度比手动 new Thread 轻得多——Task 数量多时线程池会复用线程而 Thread 每个都要一套内核资源。C# 上位机普遍要挂几十个设备用 Task 方案在这个规模下毫无压力。2.2 服务端主循环Accept 到会话管理的完整流程先看服务端的核心骨架这个类可以直接搬进 winform 工程public class TcpServer { private TcpListener _listener; private readonly Dictionarystring, TcpClient _sessions new Dictionarystring, TcpClient(); private readonly object _lock new object(); public void Start(int port, int backlog 100) { _listener new TcpListener(IPAddress.Any, port); _listener.Start(backlog); _ Task.Run(AcceptLoop); // 接受循环丢到后台UI 线程立即返回 } private async Task AcceptLoop() { while (true) { TcpClient client await _listener.AcceptTcpClientAsync(); string sessionId Guid.NewGuid().ToString(N); lock (_lock) { _sessions[sessionId] client; } _ HandleClientAsync(client, sessionId); // 每个客户端一条独立接收协程 } } private async Task HandleClientAsync(TcpClient client, string sessionId) { using (var reader new StreamReader(client.GetStream(), Encoding.UTF8)) { try { string line; while ((line await reader.ReadLineAsync()) ! null) { // 这里就是一条完整应用层消息 Broadcast($[{sessionId}] {line}); } } catch (IOException) { // 客户端异常断开走 finally 统一清理 } finally { lock (_lock) { _sessions.Remove(sessionId); } client.Close(); } } } }几个关键参数先说清楚Start 的第二个参数 backlog 是操作系统层面“等待 Accept 的连接”队列长度超过这个数的新连接会被直接拒绝。对 winform 工具项目100 足够端口是 0~65535需要先确认没被占用。IPAddress.Any 表示绑定所有网卡地址这样局域网内的其他机器也能连进来。逻辑上AcceptLoop 是单线程只做“接受连接 登记会话 派发处理”绝不在这里做阻塞式收发。HandleClientAsync 里用 StreamReader.ReadLineAsync 按行读取读到 null 表示对方正常关闭抛 IOException 表示对端异常断开。finally 里把会话从字典移除并 Close 连接这一步是防止内存和句柄泄漏的关键很多“服务端越跑越慢”的问题就出在这里。主循环这个 while(true) 的写法有人会担心异常退出。只要 AcceptTcpClientAsync 本身不抛致命异常服务端就一直在等新连接如果真出现意外让它退出winform 界面状态和实际监听状态就会不一致这是调试时容易忽略的一点。2.3 backlog、IP 绑定与连接数量边界多客户端接入的服务端参数边界最好在一开始就定好。整理一张表方便对照参数建议值说明与边界backlog100未处理连接队列长度超过即拒绝新连接端口固定业务端口换端口要同步改客户端注意占用冲突会话容器Dictionary lock几十到几百连接无压力读缓冲区4096 字节按行读取时够用二进制协议再调整IP 绑定这个参数特别容易翻车如果服务端在 127.0.0.1 上启动本机回环连接没问题但局域网里另一台机器无论如何连不上。检查的时候优先看服务端到底是绑定 IPAddress.Any 还是 IPAddress.Loopback这是 socket 网络编程里的经典坑。连接数量边界方面Task 方案在几百个连接时依然可以跑但 winform 界面的日志控件会先成为瓶颈因为每个客户端的每条消息都要 AppendText 进去。这个资源里服务端收到消息就往文本框追加现场挂 20 个客户端、每客户端每秒一条消息界面刷新就会开始吃力。真到这一步常见做法是只保留最近 200~500 条日志或者把收发统计更新成数字而不是逐条打印。注意服务端 UI 和网络处理一定要分离。Start 方法立刻返回不要让按钮点击事件在监听循环里出不来否则窗口一拖就死。3. 客户端实现TcpClient 连接、收发与 winform 界面刷新3.1 连接服务端ConnectAsync 与超时控制客户端这侧相对简单但连接超时是个必须处理的问题。默认的 Connect 在目标 IP 不可达时可能要等几十秒才报错体验很差。用带 CancellationToken 的 ConnectAsync 重载可以控制等待时间private TcpClient _client; private NetworkStream _stream; private CancellationTokenSource _cts; private async Task ConnectAsync(string ip, int port) { _cts new CancellationTokenSource(); _client new TcpClient(); try { using (var timeoutCts new CancellationTokenSource(TimeSpan.FromSeconds(3))) using (var linkedCts CancellationTokenSource.CreateLinkedTokenSource(_cts.Token, timeoutCts.Token)) { await _client.ConnectAsync(IPAddress.Parse(ip), port, linkedCts.Token); } _stream _client.GetStream(); AppendLog($已连接 {ip}:{port}); _ Task.Run(ReceiveLoopAsync); } catch (OperationCanceledException) { AppendLog($连接 {ip}:{port} 超时请确认服务端已启动); _client.Dispose(); _client null; } }超时参数设为 3 秒内网环境足够如果客户端要跨网段甚至跨运营商网络访问可以放宽到 5~10 秒。注意两个 CancellationTokenSource 的用法timeoutCts 负责超时_cts 负责后续主动断开时联动取消CreateLinkedTokenSource 把两个 token 合并成一个任何一个触发都会取消等待。兼容性方面带 CancellationToken 的 ConnectAsync 重载在较新的 .NET 里才有老框架里没有。常见做法是用 Task.WhenAny 竞速var connectTask _client.ConnectAsync(IPAddress.Parse(ip), port); var doneTask await Task.WhenAny(connectTask, Task.Delay(3000)); if (doneTask ! connectTask) { throw new TimeoutException(连接超时); }无论哪种写法超时后 TcpClient 都要 Dispose否则底层的 socket 句柄会留在那里重复点击连接按钮时就会出现句柄数一直涨的问题。3.2 接收循环后台线程持续读与 UI 刷新连接建立之后客户端要持续接收服务端发来的消息。接收必须在后台线程做千万不能放在 UI 事件里等数据private async Task ReceiveLoopAsync() { byte[] buffer new byte[4096]; try { while (_client.Connected) { int n await _stream.ReadAsync(buffer, 0, buffer.Length); if (n 0) break; string message Encoding.UTF8.GetString(buffer, 0, n); AppendLog($收到: {message}); } AppendLog(连接已断开); } catch (IOException) { AppendLog(网络异常连接已中断); } finally { _cts?.Cancel(); _client?.Dispose(); } }ReadAsync 返回 0 的含义要记牢在 TCP 里这表示对端正常关闭了连接不是“没有数据”。如果把 n0 当成继续循环的触发条件程序就会忙等CPU 占用飚高。这也是 winform 项目案例里常见的挂死现场。跨线程更新 UI 是新手必踩的一步。winform 控件只能在创建它的 UI 线程里访问网络线程收到数据后直接操作文本框会抛 InvalidOperationException。正确做法是做一个统一的日志方法private void AppendLog(string text) { if (txtLog.InvokeRequired) { txtLog.Invoke(new Action(() AppendLog(text))); return; } txtLog.AppendText(text Environment.NewLine); }InvokeRequired 判断当前线程是不是 UI 线程不是就 Invoke 回 UI 线程执行是就直接追加。这个方法全项目通用服务端和客户端都这么写。注意 Invoke 是同步的如果 UI 线程正忙调用方会阻塞量小无所谓量大就换 BeginInvoke 异步投递。3.3 发送链路按键事件到字节流的衔接发送侧逻辑比较直接但有两个细节值得做private void SendMessage(string text) { if (_stream null) { AppendLog(未连接到服务端); return; } byte[] data Encoding.UTF8.GetBytes(text \n); try { _stream.Write(data, 0, data.Length); _stream.Flush(); AppendLog($发送: {text}); } catch (IOException ex) { AppendLog($发送失败: {ex.Message}); } }消息末尾加 \n 是这套代码里约定好的应用层协议客户端每条消息一行服务端按 ReadLineAsync 读天然避开消息粘连。这是最简单可靠的消息分帧方案没有之一。第二处是 Nagle 算法的取舍。TCP 默认把连续的小包合并发送对频繁发短消息的调试工具来说会造成明显的延迟感。如果客户端每几百毫秒就发一条十几字节的指令可以在连接后设置_client.NoDelay true;关闭 Nagle 合并。代价是网络包数量变大但局域网场景无所谓。很多 socket 网络编程的“为什么客户端发消息总是慢半拍”其实就是这个小参数在作怪。4. 多客户端并存会话管理、消息分发与心跳机制4.1 会话管理为什么是 Dictionary 加锁而不是 List服务端既然要支持多个客户端同时连接就一定有个地方保存当前所有在线会话。最简单的 List 也能存但删一个 client 要按索引移除找特定客户端要遍历多线程环境下还得防着一边遍历一边被删除的问题。这份资源用的是 Dictionarystring, TcpClientkey 是会话 IDvalue 是 TcpClient。如果后面要做心跳超时检测建议把 value 换成一个小的会话类把 TcpClient 和 LastActive 字段包在一起避免引入第二个并行字典。这也是我自己的习惯private class SessionClient { public TcpClient Client { get; set; } public DateTime LastActive { get; set; } }为什么用锁AcceptLoop 在登记新会话每个 HandleClientAsync 在处理完消息后要移除会话广播的时候还要遍历所有会话三个线程同时碰这个字典。虽然也可以用 ConcurrentDictionary但我们在广播时要做“写失败就删掉这个会话”的操作这在 ConcurrentDictionary 上拆两步并不能保证一致性。所以用 Dictionary 加一个锁对象代码直观几十个连接规模也不会是性能问题。会话 ID 不建议直接用客户端 IP 加端口。原因是同一台机器可能开多个客户端连同一个服务端它们 IP 一样端口是随机分配的但端口又不一定能稳定拿到用 Guid 做唯一 ID 最省心。显示日志时再把 IP 一起带出来即可。4.2 消息分发广播、定向发送与失败清理多客户端场景下的服务端至少要能回答两个问题消息要不要发给所有人要不要单独发给某一个客户端。广播先看public void Broadcast(string message) { byte[] data Encoding.UTF8.GetBytes(message \n); Liststring deadSessions null; lock (_lock) { foreach (var kvp in _sessions) { try { NetworkStream stream kvp.Value.Client.GetStream(); stream.Write(data, 0, data.Length); stream.Flush(); } catch (IOException) { // 写失败说明连接已死先记下来稍后统一删 deadSessions deadSessions ?? new Liststring(); deadSessions.Add(kvp.Key); } } if (deadSessions ! null) { foreach (string id in deadSessions) _sessions.Remove(id); } } }这里有个关键细节不能在 foreach 循环体里直接_sessions.Remove(id)会抛“集合已修改”异常。要把待删除的 ID 先收集到临时列表等遍历完成再删。锁放在整个遍历外面保证在发送过程中没有其他线程插进来增删会话。定向发送就是把循环改成用 TryGetValue 找单个会话public bool SendTo(string sessionId, string message) { lock (_lock) { if (!_sessions.TryGetValue(sessionId, out var session)) return false; try { byte[] data Encoding.UTF8.GetBytes(message \n); NetworkStream stream session.Client.GetStream(); stream.Write(data, 0, data.Length); stream.Flush(); return true; } catch (IOException) { _sessions.Remove(sessionId); return false; } } }返回值让调用方知道目标客户端是否还在线界面就可以把“发送失败”显示出来而不是默默丢掉。这种失败后的及时清理比等到心跳扫描再回收更及时能减少读到已死连接的次数。4.3 心跳与断线检测让服务端及时发现假连接TCP 连接有个特别误导人的现象客户端断电、拔网线、进程被杀服务端不会立刻收到通知因为 TCP 没有实时探活。连接对象看起来还在读写时才发现已经死了。winform 工具最常见的后果就是界面列表里挂着一堆“幽灵客户端”广播数据时逐个写失败日志满屏报错。服务端和客户端都不需要特别复杂的探活方案。常见做法是客户端每 5 秒发一个 PING 心跳包服务端记录每个会话最后一次收到消息的时间再开一个定时扫描线程超过 30 秒没动静的连接就强制清理// 客户端侧心跳循环 private async Task HeartbeatLoopAsync(CancellationToken token) { byte[] ping Encoding.UTF8.GetBytes(PING\n); while (!token.IsCancellationRequested) { await Task.Delay(5000, token); try { _stream.Write(ping, 0, ping.Length); _stream.Flush(); } catch (IOException) { break; } // 连接已断退出等重连 } } // 服务端侧清理超时会话 private void SweepDeadSessions() { DateTime deadline DateTime.Now.AddSeconds(-30); lock (_lock) { var dead _sessions.Where(s s.Value.LastActive deadline).ToList(); foreach (var item in dead) { item.Value.Client.Close(); _sessions.Remove(item.Key); } if (dead.Count 0) { AppendLog($清理超时会话 {dead.Count} 个当前在线 {_sessions.Count} 个); } } }服务端这条链路有两个配套动作一是在 HandleClientAsync 里每收到一行消息就更新该会话的 LastActive二是扫描任务的周期比超时阈值小一般是 5~10 秒扫一次。用 winform 自带的 System.Windows.Forms.Timer 或 Task.Delay 循环都可以前者简单后者更好控制“上一轮还没扫完不要重入”。心跳除了检测掉线还有一个实际作用防止 NAT 网关把长时间空闲的映射回收。内网工具不做也能凑合凡是跨网络部署的工具心跳必须开。这个机制看起来像玄学其实是所有长连接应用都依赖的基本保障。5. 避坑与排查端口冲突、跨线程、粘包与异常断开5.1 端口绑定失败通常每个套接字地址只允许使用一次现象服务端第二次启动时Start 抛 SocketException错误文本是“通常每个套接字地址(协议/网络地址/端口)只允许使用一次”。有时明明编辑过代码再启动还是报这个错。原因上次运行的服务端进程没有完全退出端口还被占用或者是地址绑定方式不对之前绑定了特定 IP现在换成 IPAddress.Any同样端口也会冲突。顺带提一个本质TCP 的四元组里服务端地址和端口必须唯一只要还有连接在 TIME_WAIT 状态占着这一对地址绑定就会失败。调试时在 VS 里停止调试但服务端进程没退出这个现象特别常见。解决先到任务管理器确认没有残留进程结束掉再启动也可以把端口设计成配置项运行时从配置文件读避免硬编码改端口要重编译。地址方面统一用 IPAddress.Any不要一条绑在 127.0.0.1另一条绑在局域网 IP。如果是自己调试可以做个“测试端口”下拉框启动前先探测端口是否被占用这比盯着异常猜原因快多了。5.2 跨线程访问控件InvalidOperationException现象网络线程里直接调 txtLog.AppendText抛“线程间操作无效: 从不是创建控件的线程访问它”。概率性的时好时坏很迷惑。原因winform 控件只能在创建它的线程访问网络收发线程和 UI 线程不是同一个。问题是它不必然每次都抛线程调度时机不同表现不同所以有人感觉像玄学。解决所有 UI 更新统一走 3.2 节那种 Invoke 模式。调试时可以临时把Control.CheckForIllegalCrossThreadCalls false;关掉检查但只建议在快速验证时用最终代码不要留这个开关它只是掩盖问题不是解决问题。5.3 粘包与半包ReadAsync 读回来的不一定是一条消息现象客户端一次发了两条短消息服务端一条日志里看到两段内容拼在一起或者服务端读到一半消息被截断。原因TCP 是字节流协议只保证字节顺序不保证消息边界。应用层发几次 Write网络层就可能合并或拆分。Nagle 算法还会加剧小包粘连。只要收发双方没有约定消息边界粘包半包迟早出现。解决应用层做消息帧。这份资源用的最简单方案是“每行一条消息”发的时候在末尾补 \n收的时候按行读。如果消息内容本身可能包含换行符就换成“长度前缀”方案先读 4 字节表示载荷长度再读固定长度的载荷。实战里看到两条消息挤在一起先确认是不是每条消息发送时都带了结束符再看 NoDelay 是否生效别只靠调大缓冲区应付——缓冲区调大只会让半包现象延后暴露不会根治问题。5.4 客户端强退导致服务端异常现象客户端直接结束进程或拔网线服务端接收循环里抛 IOException某个线程退出后会话还留在字典里界面显示的在线数不对。原因对端异常断开时 ReadAsync 会抛异常代码如果没有 try/catch/finally 保护会话清理逻辑就不会执行。解决接收循环严格按“try 读 → catch 记日志 → finally 清理”三段写。finally 里做三件事从字典移除会话、关闭 TcpClient、更新界面在线数量。异常类型注意区分 IOException 和 ObjectDisposedException前者是网络断开后者是连接已被主动关闭兜底 catch Exception 也可以但至少要记一条日志别吞异常。5.5 界面卡死同步阻塞与 UI 线程重活现象点击“连接服务端”按钮后窗口转圈拖不动几秒后恢复或者一直死。原因连接操作或接收循环在 UI 线程里同步执行。Connect 在目标不可达时会等很久还有人在 UI 线程里调用 Thread.Sleep 模拟延时这是血泪经验里最典型的自杀代码。解决所有网络初始化都走 async 方法按钮点击的事件只 await 调用不加 .Result 或 .Wait()。日志量大的时候把界面更新改成批量刷新或者限制日志条数避免 AppendText 被高频调用。winform 项目案例里绝大多数卡死问题都不在 Socket 本身而在 UI 线程被网络操作堵死。6. 从能跑到稳跑验证流程与两个值得做的加强6.1 验证流程本地、局域网再到多客户端压力拿到这套源码先别急着改业务按这个顺序过一遍本机同时启动服务端和客户端用 127.0.0.1 连确认收发正常再开两个客户端同时连服务端确认两个会话都在线最后到局域网另一台机器连服务端局域网 IP。局域网连不上时先关防火墙放行端口再用telnet 服务端IP 端口测一次——telnet 能连上但客户端连不上问题在客户端telnet 也连不上问题在监听地址和防火墙。压力验证用临时控制台程序模拟 20 个客户端同时连接每个客户端 1 秒一条持续几分钟看在线数和内存波动。6.2 加强一用长度前缀协议替换换行符换行协议虽简单但消息里一旦出现换行符就错乱。要支持任意文本和二进制改成长度前缀帧// 发送端 byte[] payload Encoding.UTF8.GetBytes(message); byte[] frame new byte[4 payload.Length]; BitConverter.GetBytes(payload.Length).CopyTo(frame, 0); payload.CopyTo(frame, 4); stream.Write(frame, 0, frame.Length); // 接收端 byte[] lenBuf new byte[4]; await ReadFullAsync(stream, lenBuf, 4); int payloadLen BitConverter.ToInt32(lenBuf, 0); byte[] payloadBuf new byte[payloadLen]; await ReadFullAsync(stream, payloadBuf, payloadLen); string message Encoding.UTF8.GetString(payloadBuf);ReadFullAsync 要自己写循环读直到读满指定字节数。注意 BitConverter 默认小端序Windows 上没问题跨平台时要和对方对齐大小端。这套协议下每条消息都能精确切出代价是代码量增加可靠性和扩展性都上来了。6.3 加强二客户端断线自动重连工具型客户端最常见的返工诉求是“服务端重启客户端要能自己恢复”。做法是把连接和接收包进一个大循环断线后延时重连延时用退避策略int retryDelay 2000; while (!_cts.IsCancellationRequested) { try { await ConnectAndRunAsync(); retryDelay 2000; // 连接成功后延时复位 } catch (Exception ex) { AppendLog($连接异常: {ex.Message}); } await Task.Delay(retryDelay, _cts.Token); retryDelay Math.Min(retryDelay * 2, 30000); // 2秒、4秒、8秒封顶30秒 }这个退避设计很重要。如果没有退避客户端卡死后每隔几百毫秒就疯狂重连服务端日志会被连接请求刷爆有退避后服务端恢复运行时客户端最多等 30 秒就能自动回来。那次是设备数据采集工具上线运行为了省事没做心跳和重连值班人员把显示器电源关了再开设备 IP 变了服务端还留着旧连接新数据全进不来。我抓包查了一下午才看清原因。从那以后凡是需要长期挂机跑的 socket 工具我第一件事就是先把心跳和断线重连写进去界面再朴素都不怕掉线至少能自己爬起来。希望帮到你。本文还有配套的精品资源点击获取
返回列表