从keep-alive原理 分析TCP游戏服务端心跳包的实用功能
来源:知乎,文/郭无心
游戏服务器常常会设计心跳包。我们不禁会问,心跳包的作用是防止 Socket 断开连接,或是防止 TCP 连接断开吗?答案是否定的。TCP 连接的通道是虚拟的,连接的维持依赖于两端 TCP 软件对连接状态的维护。TCP 连接自身具备维护连接的机制,即在长时间没有数据包的情况下,它能判断连接是否还存在,清除死连接。在启动该功能的前提下,即使没有数据来往,TCP 也会自动发包检测连接是否正常,这无需我们额外处理。
服务端设计心跳包的目的
服务端设计心跳包主要是为了探知对端应用是否存活。服务端和客户端都可以发送心跳包,但通常是客户端发送,服务端据此判断客户端是否在线,进而清理服务端内存缓存数据(如玩家下线等)。然而,通过 TCP 四次握手断开的设定,我们也可以利用 Socket 的 read 方法判断 TCP 连接是否断开,从而清理内存。那么,为什么还需要客户端发送心跳包来判断呢?
第一种判断客户端是否在线策略:直接监控 TCP 传输协议的返回值
通过返回值处理应用层的存活判断,下面分别介绍不同编程语言中的实现方式。
C++ 实现
使用 poll 的 IO 复用方法时:
if(fds.revents & POLLERR)
if(fds.events & POLLDHUP)
通过上述判断可以探知 TCP 连接的正确性,从而在服务器关闭对应的连接,此时调用 close() 函数会释放相关资源。示例代码如下:
/*如果客户端关闭连接,则服务器也关闭对应的连接,并将用户总数减1*/
users[fds.fd] = users[fds[user_counter].fd];
close(fds.fd);
fds = fds[user_counter];
i--;
user_counter--;
printf("a client left\n");
Java 实现
阻塞编程
ServerSocket ss = new ServerSocket(10021);
Socket so = ss.accept();
// 获取相关流对象
InputStream in = so.getInputStream();
byte[] bytes = new byte[1024];
int num = in.read(bytes);
if (num == -1) { // 表明读到了流的末尾,即 client 端断开了连接,如调用 close()
so.close();
}
非阻塞编程
SelectionKey key = selector.register(socketChannel, ops, handle);
SocketChannel socketChannel = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
int num = socketChannel.read(buffer);
if (num == -1) {
key.channel().close();
}
上述连接处理方式,返回 -1 或收到 POLLERR、POLLDHUP,都是收到客户端的 fin 或者 rst 之后的反应。根据四次分手原则,我们调用 close 方法,发送 fin 给客户端。这种策略通过 TCP 协议的返回值得知客户端 TCP 断开,进而得知客户端掉线。前提是提前根据 ip 或者 mac 做了记录,服务器端收到 TCP 连接中断的消息后,调用 close,并通过 socket 得到玩家 socket 数据(如 IP 地址),从而获得 user 信息并清除数据。
该方式的缺陷
TCP 的断开可能无法瞬时探知,甚至可能无法探知,还可能有很长时间的延迟。如果前端没有正常断开 TCP 连接,四次握手没有发起,服务端就无法得知客户端掉线。此时需要开启 TCP 的 keep alive 机制,但 TCP 协议不建议开启该机制,即使开启,默认时间间隔为 2 小时。若服务端维持 2 小时的死链接,这是不可接受的;调整时间间隔也存在问题,因为 TCP 本身不建议在 TCP 层进行心跳检测,这可能导致在中间网络中断的情况下,一个完好的 TCP 连接重启。
TCP 的 keep-alive 机制介绍
什么是 keepalive 定时器
在空闲的 TCP 连接上,若两端没有进程向对方发送数据,则不会有数据交换。这意味着客户端和服务器端的应用程序都没有应用程序级的定时器来探测连接的不活动状态。然而,服务器有时需要知道客户端主机是否崩溃或重启,许多实现提供了存活定时器来完成这个任务。
存活定时器是一个存在争议的特征。很多人认为,对对方的轮询应由应用程序完成,而非在 TCP 中实现。此外,若两个终端系统之间的中间网络暂时中断,存活选项可能导致一个良好的连接终止。例如,在中间路由器崩溃、重启时发送存活探测,TCP 可能会认为客户端主机已经崩溃,但实际并非如此。
存活(keepalive)并非 TCP 规范的一部分。Host Requirements RFC 罗列了不使用它的三个理由:
- 在短暂的故障期间,它们可能引起一个良好连接被释放。
- 它们消耗了不必要的宽带。
- 在以数据包计费的互联网上它们会额外花费金钱。
不过,许多实现中提供了存活定时器。一些服务器应用程序可能代表客户端占用资源,需要知道客户端主机是否崩溃,存活定时器可为这些应用程序提供探测服务。例如,Telnet 服务器和 Rlogin 服务器的许多版本默认提供存活选项。
个人计算机用户使用 TCP/IP 协议通过 Telnet 登录一台主机,若用户使用结束时只是关掉电源,而没有注销,就会留下一个半打开的连接。如果客户端消失,留给服务器端半打开的连接,且服务器在等待客户端的数据,那么等待将永远持续下去。存活特征的目的就是在服务器端检测这种半打开连接。
keepalive 如何工作
通常,我们将使用存活选项的一端称为服务器,另一端称为客户端。也可以在客户端设置该选项,但一般设置在服务器。若连接两端都需要探测对方是否消失,则可在两端同时设置(如 NFS)。
若在一个给定连接上,两小时之内无任何活动,服务器便向客户端发送一个探测段。客户端主机可能处于以下四种状态之一:
- 客户端主机依旧活跃:客户端 TCP 正常响应,服务器知道对方仍然活跃。服务器的 TCP 为接下来的两小时复位存活定时器,若在这两小时内连接上有应用程序通信,则定时器重新复位,并继续交换数据。
- 客户端已经崩溃、关闭或正在重启:客户端的 TCP 不会响应。服务器没有收到对其发出探测的响应,75 秒后超时。服务器将总共发送 10 个这样的探测,每个探测间隔 75 秒。若未收到任何响应,服务器将认为客户端主机已经关闭并终止连接。
- 客户端曾经崩溃,但已经重启:服务器会收到对其存活探测的响应,但该响应是一个复位,从而导致服务器终止连接。
- 客户端主机活跃运行,但从服务器不可到达:这与状态 2 类似,TCP 无法区分,仅表明未收到对其探测的回复。
服务器无需担心客户端主机被正常关闭然后重启的情况,因为当系统被操作员关闭时,所有应用程序进程(即客户端进程)都将被终止,客户端 TCP 会在连接上发送一个 FIN。收到这个 FIN 后,服务器 TCP 会向服务器进程报告一个文件结束,以允许服务器检测这种状态。
在第一种状态下,服务器应用程序不知道存活探测是否发生,一切由 TCP 层处理,存活探测对应用程序透明。在后面三种状态下,服务器的 TCP 会返回错误信息给服务器应用程序。在状态 2,错误信息类似于“连接超时”;状态 3 为“连接被对方复位”;第四种状态可能看起来像连接超时,或者根据是否收到与该连接相关的 ICMP 错误信息,返回其他错误信息。
具体来说,在 TCP 协议的机制中,本身的心跳包机制(即 TCP 协议中的 SO_KEEPALIVE),系统默认的心跳频率为 2 小时。需要使用 setsockopt 将 SOL_SOCKET.SO_KEEPALIVE 设置为 1 才会打开该机制,并且可以设置三个参数:tcp_keepalive_time(连接闲置多久开始发 keepalive 的 ACK 包)、tcp_keepalive_probes(发几个 ACK 包不回复才当对方死了)、tcp_keepalive_intvl(两个 ACK 包之间间隔多长)。
TCP 协议会向对方发送一个带有 ACK 标志的空数据包(KeepAlive 探针),对方收到 ACK 包后,若连接正常,应回复一个 ACK;若连接出现错误(如对方重启,连接状态丢失),则应回复一个 RST;若对方没有回复,服务器会每隔一定时间再发 ACK,若连续多个包都被无视,则说明连接被断开。
“心跳检测包”属于 TCP 协议底层的检测机制,上位机软件通常只解析显示网口的有用数据包,收到的心跳包报文属于 TCP 协议层的数据,一般软件不会在应用层直接显示,所以看不到。以太网中的“心跳包”可以通过“以太网抓包软件”分析 TCP/IP 协议层的数据流看到,报文名称为“TCP Keep-Alive”。一些可靠的以太网转串口模块,如致远电子的 ZNE - 100TL 模块,具备心跳包检测功能,可配置“心跳包检测”间隔时间,使用“wireshark”抓包软件查看 TCP/IP 协议层“心跳包”数据。
应用层心跳检测机制的必要性
虽然使用 TCP 自己的 keep-alive 机制可以实现连接维持,通过 TCP 传输层的心跳包探知两端 TCP 连接的正确性,从而得知应用层的情况,但这并非最优选择。那么,既然有 TCP 的心跳机制,为什么还要在应用层实现自己的心跳检测机制呢?
评论中 @Raynor 提到,tcpip 详解卷 1 有网络异常中断的 3 种情况,比如 os 回收端口时发送的 rst 包,若 os 挂了就不会发这个消息。另外,若网络异常,可能收到路由器返回的 icmp 主机不可达消息从而关闭连接。keepalive 不靠谱是因为需要从 socket error 获知连接断开,且是被动断开。而应用层自己实现的 heartbeat 可以自主检测超时,更方便修改超时时间和断开前处理。
@李乐 也表示,keepalive 设计初衷是清除和回收死亡时间长的连接,不适合实时性高的场合,且它要求连接在一定时间内没有活动,周期长,不能及时处理断开情况。此外,keepalive 好像还不能主动通知应用层,需要主动调用 api 去检测异常。
应用层的心跳包
心跳包的定义
心跳包通常是客户端每隔一小段时间向服务器发送的一个数据包,用于通知服务器自己仍然在线。服务器与客户端之间定期进行交互,以判断连接是否有效,并传输一些必要的数据。在建立 TCP 的 socket 连接后,无法保证连接持续有效,此时两边应用会通过定时发送心跳包来确保连接有效。由于按照一定时间间隔发送,类似于心跳,因此称为心跳包。为了保持长连接(建立一次 TCP 连接后,认为连接有效,利用该连接不断传输数据,不断开 TCP 连接),包的内容没有特别规定,一般是很小的包,或者只是包含包头的空包。
心跳包的意义
心跳包方便服务端管理客户端的在线情况,防止 TCP 的死连接问题,避免长时间不在线的死链接仍出现在服务端的管理任务中。
举例说明 TCP 自身的四次握手断开机制的不足
- 手动强制关闭客户端进程测试:做游戏客户端和服务器端的测试,手动强制关闭客户端进程,服务器往往能收到客户端关闭的事件,但这种测试忽略了异常中断事件,如网络中断。
- 应用层强制结束进程的影响:手动关闭客户端进程不能测试出想要的结果,因为进程在应用层,这种测试方法不能保证网络驱动层不发送数据报文给服务器。测试发现,当应用层强制结束进程时,对于 TCP 连接,驱动层会发送 reset 数据包,服务器收到该数据包可正常关闭。
- 网络异常情况:若网络异常甚至直接拔掉网线,服务器收不到数据包,就会导致死连接存在。
- 心跳包的必要性:所以,心跳包是必要的,或者使用 TCP 协议本身的 Keep - alive 来设置,但 keep - alive 不够好。我们不能认为 TCP 连接如同一条绳子,一方断开另一方必然知道。实际上,TCP 连接是一个虚拟的概念,是通过 ACK、SEQ 等机制模拟实现的,如差错重传、拥塞控制。
心跳包对服务器资源的耗费判断
心跳包对服务器资源的耗费取决于发送频率,我们可以自行设置。这里有个模拟 socket 心跳包的 C 语言实现例程:http://m.blog.csdn.net/blog/mfye1121/37697001。