TcpClient默认长连接但不自动保活,需应用层实现双向心跳、超时检测与指数退避重连;Connected属性不可靠,必须异步读写+定时器+消息类型区分来保障连接有效性。

TcpClient 默认就是长连接,但不自动保活
TcpClient 建立后底层 socket 是长连接状态,只要双方都不调用 Close() 或 Dispose(),连接就一直维持。但操作系统和中间设备(如 NAT、防火墙)会因超时清理空闲连接,导致“看似连着,实则断了”。所以必须主动做心跳和检测——不是 TcpClient 的责任,而是你代码的责任。
常见错误现象:client.GetStream().Read() 长时间阻塞、Write() 突然抛 IOException(“远程主机强迫关闭了一个现有连接”)、发包无响应但没异常。这些都说明连接已失效,但你没及时发现。
- 不要依赖
TcpClient.Connected属性判断状态——它只反映上次 I/O 后的缓存值,几乎总是true即使连接已断 - 启用系统级保活(
Client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true))仅作辅助,不能替代应用层心跳,且默认超时时间过长(2小时),对大多数业务无效 - 心跳必须双向:客户端发 ping,服务端必须回 pong;单向发包无法确认对端是否存活
用异步读写 + 定时器实现可靠心跳
核心思路是:用 NetworkStream.ReadAsync() 持续监听服务端消息,同时用独立 Timer 定期发送心跳包,并设置超时等待响应。一旦读失败、写失败或心跳超时,立即触发重连。
关键参数建议:
- 心跳间隔设为 30 秒(太短增加负载,太长无法及时发现断连)
- 心跳超时设为 15 秒(小于心跳间隔,确保下一次心跳前能判定失败)
- 读操作必须设
CancellationToken并关联心跳超时,避免ReadAsync卡死
示例片段(简化版):
private async Task StartHeartbeatAsync()
{
var timer = new Timer(_ => SendPing(), null, TimeSpan.Zero, TimeSpan.FromSeconds(30));
_heartbeatTimer = timer;
}
private void SendPing()
{
if (!_client.Connected) return;
var ping = Encoding.UTF8.GetBytes("PING\n");
try
{
// 使用带超时的 WriteAsync
await _stream.WriteAsync(ping, _heartbeatCts.Token);
_lastPingSent = DateTime.UtcNow;
}
catch
{
Disconnect();
}
}
断线重连必须带退避策略,不能死循环重试
网络抖动或服务端重启时,立刻重连大概率失败。直接 while (!Connected) Connect() 会打满 CPU、触发服务端限流、甚至被防火墙拦截。
正确做法是指数退避 + 上限限制:
- 首次失败后等待 1 秒,第二次 2 秒,第三次 4 秒……最多不超过 30 秒
- 连续失败超过 5 次,暂停重连,等待用户干预或降级处理(如弹提示、切离线模式)
- 每次重连前检查网络连通性(如
Ping服务端 IP),避免在本地断网时白忙 - 重连成功后,要重新初始化状态(清空接收缓冲、重置心跳计时、重发未确认消息)
注意:TcpClient 对象不可复用。断连后必须 Dispose() 旧实例,新建 TcpClient() 再 ConnectAsync(),否则可能复用已关闭的 socket 句柄,引发 ObjectDisposedException。
接收逻辑必须区分心跳包和业务数据
服务端返回的 PONG 和真实业务消息混在同一个 NetworkStream 中,如果按行或固定长度解析,容易把心跳响应当成业务数据处理,造成协议错乱。
推荐做法:
- 定义明确的消息头(如 4 字节长度 + 类型字节),类型字段区分
MSG_TYPE_PONG/MSG_TYPE_DATA - 心跳响应必须由专用回调处理(如
OnPongReceived()),不进入主业务解析流程 - 接收缓冲区要支持粘包/拆包:
ReadAsync返回的字节数不等于你期望的整包长度,需累积、截取、校验 - 心跳超时判定不能只看“有没有收到 PONG”,还要看“收到的是否是本次发出的 PONG”(加序号或时间戳防乱序)
最容易被忽略的一点:心跳包本身也要走完整收发流程——发完要等回,回了要校验,校验失败仍算断连。很多实现只发不等,等于没做心跳。


















