TCP连接真实断开需主动探测:Socket.Connected不可靠,应结合Socket.Poll()与Available判断,或发心跳包并严格超时处理,禁用Nagle、启用KeepAlive仅作兜底,异步心跳须捕获异常并原子更新状态。

判断 TCP 连接是否真的断开,不能只看 Socket.Connected
Socket.Connected 只是缓存上次操作的状态,底层连接早已静默断开时它仍可能返回 true。真实掉线往往发生在对方突然断电、NAT 超时、防火墙中断等场景,此时本地 socket 不会主动触发 Disconnected 事件或抛异常。
必须主动探测:要么发数据看是否抛 SocketException(带 WSAETIMEDOUT 或 WSAENETRESET),要么用 Socket.Poll() + Socket.Available 组合试探:
-
socket.Poll(1000, SelectMode.SelectRead)返回true,且socket.Available == 0→ 对端已关闭连接(FIN) -
socket.Poll(1000, SelectMode.SelectRead)返回true,但读取时抛SocketException(错误码10054)→ 连接被重置 -
socket.Poll(1000, SelectMode.SelectError)返回true→ 底层异常,基本可判为断线
心跳包不能只发空字节,得带业务语义和超时控制
纯 Send(new byte[0]) 在某些中间设备(如企业级防火墙、云负载均衡)上会被直接丢弃;更糟的是,即使发了,对方不响应也不等于断线——可能只是卡在业务逻辑里。
真正可用的心跳要满足三点:
- 使用固定格式的轻量协议帧(例如
0xFF 0x01表示心跳请求,0xFF 0x02表示响应),避免被中间件误判为无效流量 - 客户端发心跳后启动独立计时器(比如
System.Threading.Timer),超时(建议 3× 心跳间隔)未收到响应就标记连接异常 - 服务端收到心跳请求必须立即回响应,不能排队或延迟;且服务端也要对每个连接维护最后心跳时间戳,超过阈值(如 5× 客户端心跳间隔)主动 Close
TcpClient 和 Socket 层做心跳的区别很实际
用 TcpClient 封装看似简单,但它隐藏了底层 socket 的状态细节:TcpClient.Client.Poll() 可用,但 TcpClient.GetStream().ReadAsync() 在连接断开时可能长时间挂起(受 ReadTimeout 影响,而该属性默认为 0 即无限等待)。
所以生产环境推荐直操 Socket:
- 设置
socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true)开启系统级保活(但注意:Windows 默认 2h 才触发,太慢,仅作兜底) - 禁用
Nagle算法:socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.NoDelay, true),避免心跳包被缓冲合并 - 所有 I/O 操作必须绑定
CancellationToken,防止因网络卡死阻塞整个心跳线程
异步心跳任务容易漏掉异常捕获和状态同步
用 Task.Run(() => SendHeartbeat()).ContinueWith(...) 是常见写法,但问题在于:异常不会自动冒泡到主线程,ContinueWith 的 TaskScheduler 默认不捕获未处理异常,导致断线检测彻底失效。
更稳妥的做法是:
- 把心跳逻辑封装成一个长周期
async Task方法,在内部用try/catch捕获所有SocketException、ObjectDisposedException、OperationCanceledException - 心跳失败后,通过
Interlocked.CompareExchange(ref _connectionState, Disconnected, Connected)原子更新状态,避免多线程竞争 - 不要依赖“连续失败 N 次才断连”,而是单次超时即触发断连流程——真实网络抖动通常短暂,反复试探反而掩盖问题
最麻烦的其实是 NAT 超时与应用层心跳节奏不匹配:家用路由器常见 300 秒超时,若你心跳设成 30 秒但某次响应延迟了 310 秒,连接其实早被清掉了,但你的代码还傻等下一次响应。


















