TCP中已有SO_KEEPALIVE选项,为什么还要在应用层加入心跳包机制?
本文解释了 SO_KEEPALIVE 选项,和为什么要在应用层加入心跳包机制,以及心跳包机制如何设计的方方面面。
在实际开发中,我们需要处理下面两种情形中遇到的问题:
情形一: 一个客户端连接服务器以后,如果长期没有和服务器有数据来往,可能会被防火墙程序关闭连接,有时候我们并不想要被关闭连接。例如,对于一个即时通讯软件,如果服务器没有消息时,我们确实不会和服务器有任何数据交换,但是如果连接被关闭了,有新消息来时,我们再也没法收到了,这就违背了“即时通讯”的设计要求。
情形二:通常情况下,服务器与某个客户端一般不是位于同一个网络,其之间可能经过数个路由器和交换机,如果其中某个必经路由器或者交换器出现了故障,并且一段时间内没有恢复,导致这之间的链路不再畅通,而此时服务器与客户端之间也没有数据进行交换,由于 TCP 连接是状态机,对于这种情况,无论是客户端或者服务器都无法感知与对方的连接是否正常,这类连接我们一般称之为“死链”。
情形一:中的应用场景要求必须保持客户端与服务器之间的连接正常,就是我们通常所说的“保活“。如上文所述,当服务器与客户端一定时间内没有有效业务数据来往时,我们只需要给对端发送心跳包即可实现保活。
情形二中的死链,只要我们此时任意一端给对端发送一个数据包即可检测链路是否正常,这类数据包我们也称之为”心跳包”,这种操作我们称之为“心跳检测”。顾名思义,如果一个人没有心跳了,可能已经死亡了;一个连接长时间没有正常数据来往,也没有心跳包来往,就可以认为这个连接已经不存在,为了节约服务器连接资源,我们可以通过关闭 socket,回收连接资源。
根据上面的分析,让我再强调一下,心跳检测一般有两个作用:
- 保活
- 检测死链
TCP keepalive 选项
操作系统的 TCP/IP 协议栈其实提供了这个的功能,即 keepalive 选项。在 Linux 操作系统中,我们可以通过代码启用一个 socket 的心跳检测(即每隔一定时间间隔发送一个心跳检测包给对端),代码如下:
//on 是 1 表示打开 keepalive 选项,为 0 表示关闭,0 是默认值
int on = 1;
setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &on, sizeof(on));
但是,即使开启了这个选项,这个选项默认发送心跳检测数据包的时间间隔是 7200 秒(2 小时),这时间间隔实在是太长了,不具有实用性。
当然,我们可以通过继续设置 keepalive 相关的三个选项来改变这个时间间隔,它们分别是 TCP_KEEPIDLE、TCP_KEEPINTVL 和 TCP_KEEPCNT,示例代码如下:
//发送 keepalive 报文的时间间隔
int val = 7200;
setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &val, sizeof(val));
//两次重试报文的时间间隔
int interval = 75;
setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &interval, sizeof(interval));
int cnt = 9;
setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &cnt, sizeof(cnt));
TCP_KEEPIDLE 选项设置了发送 keepalive 报文的时间间隔,发送时如果对端回复 ACK。则本端 TCP 协议栈认为该连接依然存活,继续等 7200 秒后再发送 keepalive 报文;如果对端回复 RESET,说明对端进程已经重启,本端的应用程序应该关闭该连接。
如果对端没有任何回复,则本端做重试,如果重试 9 次(TCP_KEEPCNT 值)(前后重试间隔为 75 秒(TCP_KEEPINTVL 值))仍然不可达,则向应用程序返回 ETIMEOUT(无任何应答)或 EHOST 错误信息。
我们可以使用如下命令查看 Linux 系统上的上述三个值的设置情况:
[root@iZ238vnojlyZ ~]# sysctl -a | grep keepalive
net.ipv4.tcp_keepalive_intvl = 75
net.ipv4.tcp_keepalive_probes = 9
net.ipv4.tcp_keepalive_time = 7200
在 Windows 系统设置 keepalive 及对应选项的代码略有不同:
//开启 keepalive 选项
const char on = 1;
setsockopt(socket, SOL_SOCKET, SO_KEEPALIVE, (char *)&on, sizeof(on);
// 设置超时详细信息
DWORD cbBytesReturned;
tcp_keepalive klive;
// 启用保活
klive.onoff = 1;
klive.keepalivetime = 7200;
// 重试间隔为10秒
klive.keepaliveinterval = 1000 * 10;
WSAIoctl(socket, SIO_KEEPALIVE_VALS, &klive, sizeof(tcp_keepalive), NULL, 0, &cbBytesReturned, NULL, NULL);
应用层的心跳包机制设计
由于 keepalive 选项需要为每个连接中的 socket 开启,由于这不一定是必须的,可能会产生大量无意义的带宽浪费,且 keepalive 选项不能与应用层很好地交互,因此一般实际的服务开发中,还是建议读者在应用层设计自己的心跳包机制。那么如何设计呢?
从技术来讲,心跳包其实就是一个预先规定好格式的数据包,在程序中启动一个定时器,定时发送即可,这是最简单的实现思路。但是,如果通信的两端有频繁的数据来往,此时到了下一个发心跳包的时间点了,此时发送一个心跳包。这其实是一个流量的浪费,既然通信双方不断有正常的业务数据包来往,这些数据包本身就可以起到保活作用,为什么还要浪费流量去发送这些心跳包呢?
以我写的 NIO 网络库 netpoll-cpp 为例,为了检测死链,防止TCP短连接浪费大量资源,使用的策略是:把连接关闭资源释放这件事用一个回调来做,然后每次连接一旦有可读消息或者主动写入消息,都通过定时器添加一个回调,比如一有消息收发就添加30s后关闭连接的回调。
很明显如果直接每次定时器任务一触发就关闭连接,并不能起到延长生命周期的效果(因为后续添加的事件无法取消前面的事件),在 netpoll-cpp 中,这个定时任务其实是一个通过RAII封装的类,每次定时器里的任务队列只需要把该元素pop出来实现回调(调用析构函数),而析构函数是否调用就是另一个妙处了,我是通过引用计数来进行判断的,也就是说一个定时任务用 shared_ptr
进行封装,然后成员变量中留一份 weak_ptr
,这样就能够感知到该回调是否已经调用,如果调用了,那么引用计数将会为0,如果还没有调用,也就是延迟30s还没到,那么将 weak_ptr
升级为 shared_ptr
继续插入一个 30s 的任务,就算之前的任务触发,也不会调用回调,因为引用计数没有下降到0,这就是整个基于定时器的 C++ 中比较妙的心跳设计。
基于这个回调,我们只需要在这个类中加入一个 TCP 连接类的 weak_ptr
实例,只要 TCP 连接还存在,则关闭,否则啥也不做,这样同时实现了内存安全。
我们发现有了这样的底层回调的设计,灵活性和维护性将大大增加,但是定时器任务的触发存在比较大的消耗,但很明显,这个任务其实不需要很高精度的定时,所以 netpoll-cpp 中基于底层的定时器又加了一个时间轮实现的定时器,旨在将定时器任务的添加和任务的触发的性能消耗降到最低,具体可以看看我的博客 实现高性能时间轮用于踢出空闲连接 。
讲了这么多,最后总结具体检测死链和保活的心跳设计大家应该有很好的思路了,我的想法就是,把心跳的底层维护转化思路为延长连接的生命周期。比如需要检测死链,就只需要在每次收发数据包的时候延长生命周期即可,如果很久没有收发数据,那么该连接自然会被销毁。而保活也只需要你自己设计对应的心跳包,然后每次心跳包一旦触发就延长生命周期。
延长生命周期的代码逻辑如下:
void TcpConnectionImpl::extendLife()
{
if (m_idleTimeout > 0)
{
auto now = Timestamp::now();
if (now < (m_lastTimingWheelUpdateTime + 1.0)) return;
m_lastTimingWheelUpdateTime = now;
auto entry = m_kickoffEntry.lock();
if (entry)
{
auto ptr = m_timingWheelWeakPtr.lock();
if (ptr) ptr->insertEntry(m_idleTimeout, entry);
}
}
}
具体源代码可以查看:https://github.com/ACking-you/netpoll-cpp/blob/master/netpoll/net/inner/tcp_connection_impl.cc