为什么长连接需要心跳协议


一般长连接都会附带着心跳保活机制,直观的理由就是为了确保这个连接一直有效,但是以前却没有往深层次去想为什么要这么做。本文尝试进行一下梳理。

一般互联网应用是“请求/响应”模式,即只能客户端主动发起请求,服务端被动响应请求(因为NAT的原因,服务端无法主动找到客户端)。对于一些需要服务器主动下发数据到客户端的场景,如果使用“请求/响应”模式,那么只能由客户端轮询的方式来服务端拉数据,这样不仅占用网络带宽,还给通信双方增加压力。如果客户端先主动发起建立长连接,然后一直保持连接有效,那么服务端相当于有一条主动通往客户端的通道。

一、什么是tcp长连接

tcp长连接是指通信双方在三次握手之后一直不关闭socket而维持着这个连接,然后在需要通信时,可以立刻发送tcp数据流,不需要每次通信前都经过三次握手和四次挥手的流程。这样做的优点有:

(1)节省连接建立和终止的开销,提高网络带宽利用率。一个典型的例子是http1.1支持的pipeline。

(2)支持双向通信,比如实时通信或者消息推送之类的服务。

那么,如何保证长连接一直有效?如果网络链路中间某个节点断了,双方是否能感知到?

二、无法感知tcp连接断开的一些场景

(1)NAT

IPv4环境下,客户端一般位于NAT之后,NAT设备(比如家里的路由器)会对所有网络连接建立一个NAT映射表。设备的存储空间必定有限,所以需要对NAT映射条目有一定的淘汰策略(比如LRU)来保证新的网络连接能正常建立。所以会淘汰一些老的不活跃连接。一旦连接被淘汰,那么服务器就无法通过主动push的方式到达客户端了。

(2)系统故障

正常情况下即使应用程序不close连接,在进程退出时,操作系统也会进一步做资源回收(即帮忙发最后的FIN请求并完成四次挥手过程)。但是如果操作系统自己也崩溃了,这个事就永远没人去做,这就意味着如果不发送数据对端永远不可能知道这个连接已关闭。

(3)断电或者网线中断

通信双方断电、中间节点断网。比如家里的路由器断电了,那么同样,通信双方都无法知道这件事,如果过一会路由器又恢复了,连接其实还是有效的。

三、既然有那么多不能感知的场景,那所谓的TCP能感知断开是怎么原因

众所周知,TCP位于IP层之上,其实TCP连接只是存在于通信双方之间的虚拟连接,数据包最终还是要封装成IP包路由出去的,中间的节点其实并不知道TCP的存在,所以才有前面说的各种无法感知的场景。

而之所以说TCP能感知连接断开,是指正常情况下,任何一方只要正常关闭连接,协议栈会向对方发送一个FIN包,此时对方read调用会返回长度0来标志这个连接关闭。一般情况下,即使主动关闭方应用进程不显示关闭连接而直接退出进程,操作系统也会帮忙做善后工作完成四次挥手过程。这种情况下对端使能马上感知连接关闭的。

然而,现实往往很骨感,当出现前面罗列的异常情况时,read调用并不会返回任何信息。只有当write时,服务端才能从操作系统的错误码来判断各种异常。但是问题是服务器往客户端主动下发数据的频率可能并不高,或者是服务本身要求push成功率要尽量高。所以需要有一种手段来尽快发现各种连接异常并促使客户端重新建立连接,这便是应用层心跳。

四、为什么不使用keepalive选项来保活

(1)keepalive默认配置一般探测间隔比较长,难以满足应用层的灵敏度要求。即使通过修改配置也会有第二点问题。

1
2
3
net.ipv4.tcp_keepalive_intvl = 75
net.ipv4.tcp_keepalive_probes = 9
net.ipv4.tcp_keepalive_time = 7200

说明:这是linux默认的配置函数,含义是7200s发一次探测包,如果成功收到ACK则结束,应用程序无感知;如果超时则每隔75s重试,重试9次判断连接异常,此时关闭本地连接并把errno设置为ETIMEOUT;如果对方系统奔溃并恢复了,则会收到RST响应,此时关闭本地连接并把errno设置为ECONNRESET。

(2)配置keepalive可以修改操作系统全局的设置,但在高并发场景下会增加系统负担。一种解决方案是程序针对某个描述符进行单独设置,但这样依然无法避免第三点的问题。

(3)keepalive是协议栈层面的事情,即使正常也无法说明应用层正常。比如应用层程序死锁、负载很高等,这种情况下协议栈还能正常对keepalive请求作出响应,但应用程序已经无法正常服务了。使用应用层心跳能够让客户端及时感知各种服务异常并重新选择正常节点进行连接。

五、其他

关于TCP的三次握手和四次挥手过程,参考资料2的文章解释很清楚了,本文不再赘述。这里直接借用里面两个很直观的图,如有侵权请及时告知本人删除:

三次握手及状态(参考资料2)

四次挥手及状态(参考资料2)

关于read、write系统调用在各种异常情况下的表现(一般结合epoll使用):

(1)read:当对端进程关闭socket或者进程异常退出,epoll会返回可读事件并且read返回长度是0。本端能够第一时间关闭连接。

(2)read:当发生前文第二点的异常时,如果本端没有数据需要发送,那么read不会有任何返回。除非启用了keepalive(ETIMEOUT)或者使用应用层心跳才能发现这种问题并关闭连接。

(3)write:当对端进程关闭socket或者进程异常退出,如果继续发送数据,那么本端会收到RST响应并设置errno为ECONNRESET。如果继续发送数据则会收到协议栈的SIGPIPE信号,如果不捕获这个信号,默认处理是退出进程,所以一般服务进程都会捕获这个信号并忽略它。

(4)write:当发生前文第二点的异常时,返回错误并且errno为ETIMEDOUT或者EHOSTUNREACH。

参考资料:

(1)《UNIX网络编程》第一卷

(2)https://www.cnblogs.com/AhuntSun-blog/p/12028636.html