简介使用vb.net与WinForm开发的简易多人聊天室示例借助Socket实现多客户端与服务端间的实时通信并重点引入心跳检测机制服务端定时检查客户端心跳包自动断开超时未发送心跳包的连接保障长连接稳定。项目源码完整覆盖Socket编程中服务器监听、客户端接入、消息转发与连接保活等核心环节界面虽朴素但逻辑清晰适合初学网络编程的开发者参考C#程序员也可借鉴实现思路。压缩包共54个文件以vb源文件、项目工程、可执行exe及配置文件为主体积仅185KB结构紧凑便于直接运行或对照学习。目前已有1233人学习下载通过源码可掌握多客户端并发处理、心跳包约定、超时踢线等具体写法是一份轻量实用的Socket入门素材。1. 为什么一个“聊天室”最考验的是心跳检测机制VB.NET 通过 Socket 实现简易多人聊天室这类项目我前后做过好几版。消息广播、客户端列表、输入框发消息这些功能一个晚上就能跑通真正让项目“翻车”的往往是一个不起眼的细节对方电脑拔了网线或直接断电聊天室这边依然显示他“在线”发给他的消息像丢进了无底洞。原因在于 TCP 是沉默的——连接建立之后它不会主动告诉你对端是否还活着尤其是遇到断电、断网、系统休眠这类没有正常 Four-Way Handshake 的断开场景。所以标题里最值得深挖的不是 Socket 收发而是括号里的“心跳检测机制”。它不只是用来踢人更是让服务器在几十秒内感知连接的真实状态清理资源、刷新在线名单、避免继续给死连接写数据。这篇文章我会按自己的项目习惯把协议、服务端、客户端、心跳、踩坑一条线讲完新手能照着抄熟手也能看看我的参数取舍。2. 从 TCP 到封包服务端先解决“消息边界”再谈聊天2.1 为什么选 TCPUDP 可以做聊天室但状态管理全得自己来Socket 通信在 VB.NET 里最常见的两条路是 TcpClient/TcpListener 和 UdpClient。UDP 在这种小场景里不是不能跑我在模拟项目X里也试过广播效率高、实现简单但从写第一行代码开始你就得自己处理乱序、丢包重传、对端离线判断这些恰好是多人聊天室里最麻烦的部分。TCP 把这一切交给了协议栈连接本身就是身份断开时有异常或读完返回 0状态非常清晰。所以我的结论是简易多人聊天室TCP 是省事的那条路。心跳检测同样依赖连接语义——一个从未建立过连接的用户没有心跳记录也不会被广播出去。TCP 面向连接、消息有序、不会丢包适合聊天这种不能接受消息乱序的场景。UDP 想做在线列表和离线检测你得自己在应用层维护一套状态机等于把 TCP 已经做完的事重新做一遍。2.2 最小封包协议长度头、消息类型、用户ID 三个字段怎么摆多人聊天室不能只发字符串必须区分消息类型普通文本、心跳 Ping、系统通知有人上下线、在线列表刷新。我一般用这样一个封包布局[4字节长度(Int32)][1字节消息类型][4字节用户ID][N字节正文(UTF8)]长度头解决粘包半包。TCP 是字节流消息没有天然边界如果不带长度接收方收到 1024 字节时可能是一条消息都没凑齐也可能是三条消息挤在一起。长度头让接收方先读 4 字节算出消息体长度再精确读满这个长度消息边界就出来了。这里有个我踩过的坑长度头到底存“正文长度”还是“消息体总长度”我建议存消息体总长度也就是 1 字节类型 4 字节用户ID N 字节正文接收方就不用再做二次偏移。具体封包构造代码如下Public Shared Function PackMessage(ByVal msgType As Byte, ByVal userId As Integer, ByVal text As String) As Byte() Dim textBytes As Byte() Encoding.UTF8.GetBytes(text) 消息体 1字节类型 4字节用户ID N字节正文 Dim bodyLength As Integer 5 textBytes.Length 总包长 4字节长度头 bodyLength Dim packet As Byte() New Byte(3 bodyLength) {} 前4字节写入消息体总长度 BitConverter.GetBytes(bodyLength).CopyTo(packet, 0) 第5字节是消息类型 packet(4) msgType 第6-9字节是用户ID BitConverter.GetBytes(userId).CopyTo(packet, 5) 从第10字节开始放正文 textBytes.CopyTo(packet, 9) Return packet End Function逻辑说明第一条 CopyTo 把长度写进包头接收端用 BitConverter.ToInt32 读回来第二条赋值直接写字节下标 4第三条 CopyTo 写 userID第四条把 UTF8 正文接在后面。参数说明msgType 用 Byte 是为了省空间1 表示普通文本2 表示心跳 Ping3 表示系统通知userId 在服务端由会话 ID 自动分配客户端不需要自己选。BitConverter 在 Windows 平台默认小端内网两端都是 Windows 时不用额外转字节序以后如果跨平台用 Mono 跑长度头要手动转成 BigEndian很多深坑都藏在这里。2.3 服务端监听与接收循环不被粘包半包坑到的读法服务端骨架是三层TcpListener 负责监听每 Accept 一个客户端就建一个会话对象每个会话跑一个后台接收循环。监听回调要放在异步方法里不能阻塞 UI 线程Public Class ChatServer Private _listener As TcpListener Private _clients As New ConcurrentDictionary(Of Integer, ClientSession) Private _nextId As Integer 1 Public Sub Start(ByVal port As Integer) _listener New TcpListener(IPAddress.Any, port) _listener.Start(10) backlog10排队等待Accept的连接数 _listener.BeginAcceptTcpClient(AddressOf OnAccept, Nothing) End Sub Private Sub OnAccept(ByVal ar As IAsyncResult) Try Dim client As TcpClient _listener.EndAcceptTcpClient(ar) Dim session As New ClientSession(_nextId, client) _clients.TryAdd(session.Id, session) _nextId 1 每个客户端启动一个独立接收循环 Task.Run(Sub() ReceiveLoop(session)) Catch ex As Exception 监听被Stop时EndAccept会抛异常吞掉即可 Finally Try _listener.BeginAcceptTcpClient(AddressOf OnAccept, Nothing) Catch End Try End Try End Sub End Class说明BeginAcceptTcpClient 是异步回调回调末尾再发起一次 Accept形成循环。Task.Run 让每个客户端的接收循环跑在线程池上不阻塞 Accept 回调ConcurrentDictionary 是线程安全的后面广播和踢人不会因为它报错。backlog 参数 10 表示系统内核里排队的连接数内网 20 人以下的聊天室足够。IPAddress.Any 监听所有网卡局域网用户通过主机 IP 连接即可。接收循环里最关键的是“读满”函数。NetworkStream.Read 一次不一定读满你要的字节数必须循环累加Private Function ReadFull(ByVal stream As NetworkStream, ByVal buffer() As Byte, ByVal offset As Integer, ByVal length As Integer) As Boolean Dim total As Integer 0 While total length Dim count As Integer stream.Read(buffer, offset total, length - total) If count 0 Then Return False 对端正常关闭 End If total count End While Return True End Function Private Sub ReceiveLoop(ByVal session As ClientSession) Dim stream As NetworkStream session.TcpClient.GetStream() Try While True Dim lenBytes(3) As Byte If Not ReadFull(stream, lenBytes, 0, 4) Then Exit While Dim bodyLength As Integer BitConverter.ToInt32(lenBytes, 0) If bodyLength 5 OrElse bodyLength 65541 Then Exit While Dim bodyBytes As Byte() New Byte(bodyLength - 1) {} If Not ReadFull(stream, bodyBytes, 0, bodyLength) Then Exit While 解析消息第0字节类型第1-4字节用户ID第5字节起正文 Dim msgType As Byte bodyBytes(0) Dim userId As Integer BitConverter.ToInt32(bodyBytes, 1) Dim text As String Encoding.UTF8.GetString(bodyBytes, 5, bodyLength - 5) If msgType 2 Then session.LastPingUtc DateTime.UtcNow Else Broadcast(msgType, userId, text) End If End While Catch ex As Exception 拔线、断电、RST都走这里 Finally RemoveSession(session.Id, 连接已断开) End Try End Sub参数说明bodyLength 的校验区间设置在 5 到 65541。最小 5 是保证至少有一个类型和用户ID最大 65541 防止恶意客户端发一个超大长度把内存吃满——这是我自己吃过的亏不校验的话对方包头随便写个 2GB程序直接异常。收到心跳消息时不广播只更新时间戳这个字段会被心跳扫描线程用到。广播方法里注意不能边遍历边移除先快照再写Public Sub Broadcast(ByVal msgType As Byte, ByVal fromUserId As Integer, ByVal text As String) Dim data As Byte() PackMessage(msgType, fromUserId, text) Dim sessions _clients.Values.ToList() 快照 For Each session In sessions Try session.Stream.Write(data, 0, data.Length) Catch ex As Exception 写入失败先记下来由接收循环的异常兜底清理 End Try Next End Sub到这里服务端能跑通了接受连接、读取消息、广播。但光有这套还不够客户端还没写心跳也还没接上。3. 客户端收发模型异步回调与 UI 线程之间的桥3.1 连接与接收循环每个客户端一个后台任务客户端这边我也用 TcpClient连接成功后启动一个后台接收循环和服务端的 ReceiveLoop 结构几乎一样区别是它不广播只把消息通过事件抛给窗体。这里有一个重要的经验接收循环必须跑在工作线程不能跑在 UI 线程否则一条慢消息就能把窗口整个卡住。Public Class ChatClient Private _tcpClient As TcpClient Private _stream As NetworkStream Private _userId As Integer Private _isRunning As Boolean False Public Event MessageReceived(ByVal userId As Integer, ByVal text As String) Public Event SystemMessage(ByVal text As String) Public Sub Connect(ByVal ip As String, ByVal port As Integer) _tcpClient New TcpClient() _tcpClient.Connect(ip, port) _stream _tcpClient.GetStream() _isRunning True Task.Run(Sub() ReceiveLoop()) End Sub Public Sub Send(ByVal data As Byte()) If _stream IsNot Nothing Then _stream.Write(data, 0, data.Length) End If End Sub Private Sub ReceiveLoop() Try While _isRunning Dim lenBytes(3) As Byte If Not ReadFull(_stream, lenBytes, 0, 4) Then Exit While Dim bodyLength As Integer BitConverter.ToInt32(lenBytes, 0) If bodyLength 5 OrElse bodyLength 65541 Then Exit While Dim bodyBytes As Byte() New Byte(bodyLength - 1) {} If Not ReadFull(_stream, bodyBytes, 0, bodyLength) Then Exit While Dim msgType As Byte bodyBytes(0) Dim fromUserId As Integer BitConverter.ToInt32(bodyBytes, 1) Dim text As String Encoding.UTF8.GetString(bodyBytes, 5, bodyLength - 5) If msgType 1 Then RaiseEvent MessageReceived(fromUserId, text) ElseIf msgType 3 Then RaiseEvent SystemMessage(text) End If End While Catch ex As Exception 服务端关闭、网络异常都走这里 Finally _isRunning False RaiseEvent SystemMessage(连接已断开) End Try End Sub End Class参数说明客户端唯一标识 _userId 由服务端在 Accept 时分配登录后服务端会发一条系统消息告知这部分我放在 3.3 里讲。_isRunning 是退出开关配合 While 循环安全跳出。注意 ReceiveLoop 里的 ReadFull 和第 2 章那段代码是同一套逻辑不要各写各的不然很容易出现一边校验了长度上限另一边没校验的情况。3.2 事件上抛与 Invoke别在工作线程里碰控件接收循环在工作线程跑VB.NET 的 WinForms 控件有线程亲和性控件创建在哪个线程就只能由哪个线程直接访问。如果你在 Task 线程里直接写 txtChat.Text大概率会抛跨线程异常或者界面表现非常诡异。解决方式是事件到达 UI 层时做一次判断Private Sub OnMessageReceived(ByVal fromUserId As Integer, ByVal text As String) Handles _client.MessageReceived If Me.InvokeRequired Then Me.BeginInvoke(Sub() OnMessageReceived(fromUserId, text)) Return End If txtChat.AppendText(String.Format([{0}] {1}{2}, fromUserId, text, Environment.NewLine)) End Sub逻辑说明InvokeRequired 判断当前线程是否 UI 线程不是就把调用投递回 UI 线程。这里用 BeginInvoke 而不是 Invoke因为 Invoke 是同步等待如果 UI 线程正在忙接收循环会被一起堵住。参数说明fromUserId 是发送者的会话 IDtext 是消息正文AppendText 比直接拼 Text 效率高消息再多也不会闪烁。这套“事件 Invoke”模式是聊天室 UI 层的标准做法也适合新手理解委托和线程同步。很多刚上手的人会把接收循环直接写在 Form 里然后在循环里调用控件这种写法短时间能跑但窗口移动、关闭、弹窗时极易假死。3.3 发送消息封包构造里那个最容易算错的长度发送按钮的代码是整条链路里最容易出错的地方问题几乎都出在长度计算上。我自己的习惯是不在按钮事件里手动算字节而是调用统一的 PackMessagePrivate Sub btnSend_Click(sender As Object, e As EventArgs) Handles btnSend.Click Dim text As String txtInput.Text.Trim() If text.Length 0 Then Return Dim data As Byte() PackMessage(1, _myId, text) _client.Send(data) txtInput.Clear() End Sub这里 _myId 在连接建立后的第一条系统消息里由服务端下发。服务端在 Accept 完成时立即发一条类型 3 的消息内容类似“欢迎加入你的ID是 5”客户端解析后写入 _myId。这样客户端不需要自己选 ID也避免两个人撞 ID。发送时的另一个细节是一次 Write 调用在大多数情况下会把数据完整发出去但如果正文特别长或网络拥塞Socket 底层可能出现只写入一部分的情况。严谨的写法是记录偏移量循环写直到全部写完。我在避坑章节里专门展开这条。发送无需加锁服务端写了我们同步调用就会阻塞 UI 直到写完但当前这个量级问题不大。4. 心跳检测机制被动收 Ping 比服务端主动查更适合 VB.NET 聊天室4.1 客户端 Ping、服务端记时间为什么不做服务端主动查心跳检测目前有两条路线。一条是服务端定时主动发 Pong 请求客户端收到后回 Pong服务端等不到即判死另一条是客户端定时主动发 Ping服务端只负责把收到时间记录下来扫描线程发现超时就踢人。我强烈推荐第二条原因有三个服务端在异步模型下发起请求要额外维护“等待响应”的状态复杂度直接翻倍连接数多了之后服务端主动探测会放大网络负载VB.NET Written 的开发方式下服务端被动接收只要在已有 ReceiveLoop 里加一个分支就行完全不动发送逻辑。第一条路线我见过有人做成同步阻塞的在独立线程里逐个 Ping结果一个断开的客户端会卡住整个扫描线程后面的客户端全被拖累。被动收模式不存在这个问题每个客户端都在自己的接收循环里更新时间戳扫描线程只做一次字典遍历和比较。断开的客户端即使不更新 LastPingUtc扫描线程也不会阻塞。这里要引出一个底层概念TCP 本身不会主动通知对端消失。正常关闭连接会走四次挥手对端能通过 Read 返回 0 感知但拔网线、断电、系统强制休眠不会发任何包TCP 连接会一直挂在那里直到系统 TCP 栈的超时机制生效——这个时间通常以分钟甚至小时计算。心跳检测就是应用层自己实现的快速超时机制把“对方可能已经死了”的确认时间从几十分钟压缩到几十秒。4.2 两套定时器分工客户端用 Forms.Timer服务端用 Threading.Timer客户端发送 Ping 我用 WinForms 的 Timer服务端扫描超时我用 System.Threading.Timer两者差别很大Forms.Timer 依赖 UI 消息循环只在 UI 线程空闲时触发好处是回调里直接操作控件不用跨线程System.Threading.Timer 回调在线程池上执行精度高但回调里不能碰任何 UI 控件。选错的典型症状是窗口最小化或拖动时聊天室心跳停发或者服务端扫描线程里写控件直接崩。 客户端用 Forms.Timer 周期发送 Ping Dim _pingTimer As New Timer() 来自 System.Windows.Forms _pingTimer.Interval 5000 AddHandler _pingTimer.Tick, Sub() Dim data As Byte() PackMessage(2, _myId, ) _client.Send(data) End Sub _pingTimer.Start()逻辑说明这里发送的正文是空字符串封包仍然有完整的类型和用户ID。服务端在 ReceiveLoop 里遇到 msgType2 就更新会话的 LastPingUtc不做广播也不回发确认。客户端不需要等待确认因为只要连接还活着TCP 本身会保证 Ping 数据到达如果连接已经死了客户端下一次发送时要么抛异常要么 TCP 栈重传超时后抛异常两种情况都会走进接收循环的 Catch触发断开逻辑。 服务端用 Threading.Timer 做超时扫描 _heartCheckTimer New System.Threading.Timer(AddressOf HeartCheck, Nothing, 10000, 10000) Private Sub HeartCheck(ByVal state As Object) Dim now As DateTime DateTime.UtcNow Dim deadIds As New List(Of Integer) For Each session In _clients.Values If now.Subtract(session.LastPingUtc).TotalSeconds _heartbeatTimeout Then deadIds.Add(session.Id) End If Next For Each id In deadIds RemoveSession(id, 心跳超时) Next End Sub参数说明构造函数的三个参数含义是首次延迟 10 秒、周期 10 秒。为什么首次延迟 10 秒因为立即扫描时所有客户端可能还没发过第一条 Ping会误判全踢。_heartbeatTimeout 是超时阈值默认 20 秒这条阈值在 4.3 会细算。RemoveSession 里要做的三件事从字典移除、广播一条类型 3 的系统消息、关闭 Socket 并释放资源。只用 ConcurrentDictionary 还不够RemoveSession 内部要 TryRemove 检查返回值。两套定时器的对比可以整理成一张表定时器类型回调线程能否操作UI控件适合场景Forms.TimerUI线程可以直接操作客户端发心跳、刷新界面Threading.Timer线程池不能直接操作服务端扫描、定时清理Timers.Timer线程池不能直接操作服务端定时任务可替代上行经验服务端项目如果是 Windows 服务或控制台没有 UI 线程概念Forms.Timer 直接用不了必须用 Threading.Timer 或 Timers.Timer客户端反过来如果强行用 Threading.Timer 发心跳回调里调用 _client.Send 没大问题但一旦你在回调里顺手更新了状态栏文本就会踩跨线程异常。4.3 阈值怎么定5 秒发送、10 秒扫描、20 秒超时背后的算账这三个数字不是拍脑袋定的核心原则是超时阈值必须大于“接收端可能的最大静默期”否则会误踢。正常的心跳时间线是这样的第 0 秒客户端发送 Ping第 5 秒客户端发送下一个 Ping服务端每 10 秒扫描一次理论上最多 5 秒内必然收到一个 Ping但实际情况要打折扣客户端可能因为 UI 线程忙而漏发一次 Ping服务端扫描线程也可能因为线程池排队而延迟几秒执行。GC 停顿可以让整个应用卡住 2 到 3 秒笔记本休眠唤醒后网络栈重建也要时间。如果把超时阈值设成 6 秒一次 GC 停顿加一次 Ping 丢包就会造成误踢。我把阈值公式写成超时阈值 2 × 心跳发送周期 最大扫描间隔。按这个公式5 秒周期、10 秒扫描间隔阈值取 20 秒留出了 5 秒的余量。如果你把客户端周期调成 2 秒阈值至少要 14 秒如果客户端周期调到 10 秒阈值至少要 30 秒否则 TCP 本身还没有把死连接暴露出来心跳就先判断超时了。还有一个容易忽略的点时间戳必须用 DateTime.UtcNow不能用 DateTime.Now。原因很简单如果运行聊天室的电脑改了系统时区或者有人手动把系统时间改前几分钟DateTime.Now 会产生一个巨大的负数差值导致所有客户端被瞬间误踢UTC 时间不受时区影响是分布式系统里相对安全的时钟源。我把这套心跳做成服务端和客户端通用的配置项写在初始化代码里Public _heartbeatInterval As Integer 5000 客户端发送间隔毫秒 Public _heartbeatScan As Integer 10000 服务端扫描间隔毫秒 Public _heartbeatTimeout As Integer 20000 服务端超时阈值毫秒调试时把这三项分别调成 1000、2000、5000就能在几秒内看到踢人和重连效果正式跑再改回来。这个习惯帮我避免了无数次“改了代码看不出效果”的情况。5. 避坑指南五个让聊天室翻车的真实问题5.1 粘包半包的第二形态Read 返回 0 时别继续死循环现象程序跑一会儿后 CPU 占用率飙到接近 100%界面还能动但消息都收不到了。查看代码发现接收循环里没有处理 Read 返回 0 的情况Read 返回 0 表示对端已经正常关闭连接但循环还在继续 Read系统会立即返回 0形成一个无休眠的空转循环CPU 直接被吃满。原因严格来说这不是粘包半包而是对端正常关闭后没有跳出循环。很多人在写完第 1 个版本后只测试了“发消息”路径没有测试“关掉客户端窗口”路径。解决ReadFull 函数里 count 0 时返回 False调用方 Exit While。如果你用的是 BeginRead 异步模型EndRead 返回 0 的时候同样要停止继续 BeginRead。我见过有人把这个问题误判成粘包在报文解析逻辑里加各种奇怪的延时等待越改越乱。5.2 幽灵客户端拔线后广播还在写死 Socket现象客户端直接拔网线服务端没有立刻感知列表里还显示这个用户“在线”其他人发给他的消息也不报错因为 Write 到缓冲区后 TCP 栈还没发现对端不可达。过一会儿 Write 开始抛 SocketException服务器才反应过来。原因TCP 断开检测需要时间拔网线这种场景没有 FIN 包服务端要等 TCP 重传超时这段窗口期可能长达几十秒甚至按分钟计。消息“像丢进了黑洞”本质上是对端已经死了但服务端的 Socket 对象还没拿到死讯。解决靠心跳检测兜底。客户端停止 Ping 后服务端扫描线程在 20 秒内会发现 LastPingUtc 超时并调用 RemoveSession。注意 RemoveSession 必须在广播之前把会话从字典里移除否则广播还会再次写给这个死 Socket。我们这里的顺序是先 TryRemove再广播下线通知最后 Close。广播写失败只记录不立即处理把清理工作交给接收循环的 Catch 或心跳扫描避免两个线程同时 Close 同一个 Socket 导致 ObjectDisposedException。5.3 局域网里装上程序连不上先怀疑防火墙现象客户端和服务端运行在同一台电脑上完全正常换到局域网另一台电脑就连不上Connect 直接超时或报“目标计算机积极拒绝”。原因Windows 防火墙默认拦截入站连接。开发环境里第一遍测试都是本机回环走的是 127.0.0.1防火墙一般不拦截换成真实 IP 走局域网就触发了入站规则。解决在防火墙入站规则里放行聊天室所用的端口。开发调试时可以临时放行 8800 端口正式内网部署需要走正经的 IT 审批流程。我要多提醒一句端口不要用 80、443 这种常见端口也不要用 5000 这种容易被其他开发服务占用的端口8800 附近比较安全。如果部署环境中多个程序共用一个端口启动时会直接报“地址已被使用”排查顺序是先看端口占用再谈代码。5.4 集合并发修改一边广播一边踢人List 直接罢工现象程序运行一段时间后广播时报“集合已修改枚举操作可能不会执行”然后服务端就崩了。报错位置指向遍历 _clients 的 For Each 循环。原因广播线程正在 For Each 遍历客户端字典另一个线程接收循环的 Finally 或心跳扫描同时移除了一个会话导致枚举器检测到集合被修改。ConcurrentDictionary 能保证单次操作线程安全但不能保证“遍历期间其他线程不修改”。解决两种写法。一种是遍历前 ToList() 做快照在快照上循环修改操作走字典这是我用的写法。另一种是给所有修改操作加锁但加锁范围一旦没控制好广播和踢人互相等待就成了死锁。快照方案简单有效代价是每次广播复制一次列表聊天室几十个客户端完全没问题。类似的问题也出现在“边遍历边移除”的清理逻辑里统一用“先收集 ID 到 List再遍历 ID 列表删除”的模式。集合类型的选择上我建议直接上 ConcurrentDictionary不要用普通的 Dictionary 配 Lock。ConcurrentDictionary 的 TryAdd、TryRemove 都是原子操作配合快照遍历可以规避绝大多数并发问题。我这段话的适用边界是 100 人以下的聊天室上万连接的高并发场景要重新设计快照遍历的复制开销会变得不可忽略。5.5 点关闭按钮就假死Socket Close 的时机比想象中敏感现象聊天室窗口点关闭后没有任何反应窗口一直停在屏幕上等十几秒才退出有时直接“无响应”。原因常见的错误做法是在 FormClosing 事件里直接调用 client.Close()但接收循环线程可能正阻塞在 stream.Read 上Close 会抛异常可这个异常不是在 UI 线程抛的而是抛在线程池线程上UI 线程没有被唤醒如果接收循环里还有分发逻辑在通过 Invoke 等待 UI 线程执行而 UI 线程又在等接收线程退出就构成死锁。解决关闭顺序必须是“先停接收循环再关 Socket”。具体做法设置 _isRunning False调用 tcpClient.Client.Shutdown(SocketShutdown.Both)这会解除接收线程的 Read 阻塞Blocking 的 Read 返回 0 或抛异常接收循环进入 Finally 把 _isRunning 置回 False最后在 UI 线程再 Close。也就是说Close 之前一定要先 Shutdown这是很多人不知道的细节。Shutdown 发送 FIN 给对端对端 Read 返回 0双方都能干净退出。Public Sub Disconnect() _isRunning False If _tcpClient IsNot Nothing Then Try _tcpClient.Client.Shutdown(SocketShutdown.Both) Catch ex As Exception 连接已断开时Shutdown会抛异常忽略 End Try _tcpClient.Close() End If End Sub顺序不可颠倒先退出接收循环再 Close 释放资源。Close 要在 UI 线程做吗不一定但 Shutdown 必须优先执行否则接收线程永远阻塞在 Read 上后台线程无法结束程序就“关不掉”。6. 进阶玩法把心跳从定时器升级成状态机并加自动重连心跳检测做到“超时即踢”只能算及格。内网场景里最让人难受的是电脑休眠唤醒休眠期间所有 Ping 都没发服务端判定超时踢人唤醒后客户端并不知道自己被踢了界面还停留在“在线”发任何消息都会失败用户只能手动重启程序。所以我把心跳升级成了状态机配合自动重连实用性上了一大截。状态机分四个阶段在线Connected、探测中Probing、重连等待Reconnecting、离线Disconnected。客户端在发送 Ping 的同时维护一个本地计数器连续 3 次 Ping 没有得到任何服务端下发的数据就认为连接可能已死进入重连等待每隔 2 秒尝试重新连接连上后退回在线。如果重连超过 5 次才真正判定离线并提示用户手动检查网络。这套逻辑的意义在于不会因为一次 GC 停顿或路由器抖动就把用户踢下线而是给网络恢复留了窗口。服务端侧的建议是把“心跳超时踢人”和“客户端主动断开”区分开。客户端主动断开时接收循环能立刻感知心跳超时则适合做清理和通知。我一般把踢人动作设计成可配置的开发测试时打开自动踢人方便观察正式部署时关掉踢人只保留断线后的清理避免误踢造成用户投诉。自动重连的核心代码是Public Sub TryReconnect() If _isReconnecting Then Return _isReconnecting True _reconnectCount 1 If _reconnectCount 5 Then RaiseEvent SystemMessage(重连失败请检查网络) _isReconnecting False Return End If Task.Run(Sub() Try _tcpClient New TcpClient() _tcpClient.Connect(_serverIp, _serverPort) _stream _tcpClient.GetStream() _isRunning True _reconnectCount 0 Task.Run(Sub() ReceiveLoop()) RaiseEvent SystemMessage(重连成功) Catch ex As Exception Thread.Sleep(2000) 等2秒再试 TryReconnect() Finally _isReconnecting False End Try End Sub) End Sub重连成功后的第一件事是重新建立会话 ID。服务端每个新 TCP 连接都会分配新 ID所以客户端要等第一条系统消息来更新 _myId不能沿用旧 ID。最后讲一个我的习惯所有心跳参数都做成可在启动时通过配置文件读取而不是写死在代码里。有次我把超时阈值设成 6 秒想测试快速断线结果同事电脑休眠唤醒就被误踢后来加了状态机和重连才挽救回来。从那以后我坚持阈值按“两倍发送周期加扫描间隔”往上翻再小的项目也留 5 秒余量。这套方案帮我把聊天室的断线感知从几分钟压缩到了 20 秒内希望能帮到你。本文还有配套的精品资源点击获取