心跳机制tcp keepalive的讨论及其应用---断网检测的C代码实现(Windows环境下)
之前很多网友都问过一个类似这样的问题: tcp连接ok后,网络如果断了, 怎么检测断网? 其实, 说白了, 也就是检测tcp死链接。 在本文中, 我们来详细讨论一下, 并尝试用C代码实现这个断网检测功能。
本文的讨论还是以Windows为例, 程序也是在Windows上进行调试的。 我也调试了很久, 看着这个辛苦份上,如有要喷的朋友 ,请轻点喷哈!
在本文中, 我们来聊聊心跳机制。 首先自然会问: 什么是心跳机制? 为什么需要心跳机制? 怎么来实现它? 在本文中, 我会和大家一起来学习一下。说明, 本文中涉及到的实战的服务端程序和客户端程序需要运行在两个不同的pc上, 为了简便起见, 我用pc1做服务端(ip为172.18.18.20), 用pc2做客户端(ip为172.18.18.29)。并且要注意,每做完一组实验后, 都要将服务端进程和客户端进程关闭, 免得影响后面的实验。
首先, 我们看看普通的, 没有心跳机制的服务端和客户端, pc1 server的程序为:
#include <stdio.h>
#include <winsock2.h> // winsock接口
#pragma comment(lib, "ws2_32.lib") // winsock实现
int main()
{
WORD wVersionRequested; // 双字节,winsock库的版本
WSADATA wsaData; // winsock库版本的相关信息
wVersionRequested = MAKEWORD(1, 1); // 0x0101 即:257
// 加载winsock库并确定winsock版本,系统会把数据填入wsaData中
WSAStartup( wVersionRequested, &wsaData );
// AF_INET 表示采用TCP/IP协议族
// SOCK_STREAM 表示采用TCP协议
// 0是通常的默认情况
unsigned int sockSrv = socket(AF_INET, SOCK_STREAM, 0);
SOCKADDR_IN addrSrv;
addrSrv.sin_family = AF_INET; // TCP/IP协议族
addrSrv.sin_addr.S_un.S_addr = inet_addr("0.0.0.0"); // socket对应的IP地址
addrSrv.sin_port = htons(8888); // socket对应的端口
// 将socket绑定到某个IP和端口(IP标识主机,端口标识通信进程)
bind(sockSrv,(SOCKADDR*)&addrSrv, sizeof(SOCKADDR));
// 将socket设置为监听模式,5表示等待连接队列的最大长度
listen(sockSrv, 5);
// sockSrv为监听状态下的socket
// &addrClient是缓冲区地址,保存了客户端的IP和端口等信息
// len是包含地址信息的长度
// 如果客户端没有启动,那么程序一直停留在该函数处
SOCKADDR_IN addrClient;
int len = sizeof(SOCKADDR);
unsigned int sockConn = accept(sockSrv,(SOCKADDR*)&addrClient, &len);
while(1); // 卡住
closesocket(sockConn);
closesocket(sockSrv);
WSACleanup();
return 0;
}
好, 启用服务端。
然后, 我们启动客户端:
#include <winsock2.h>
#include <stdio.h>
#pragma comment(lib, "ws2_32.lib")
int main()
{
WORD wVersionRequested;
WSADATA wsaData;
wVersionRequested = MAKEWORD(1, 1);
SOCKET sockClient = 0;
WSAStartup( wVersionRequested, &wsaData );
sockClient = socket(AF_INET, SOCK_STREAM, 0);
SOCKADDR_IN addrSrv;
addrSrv.sin_addr.S_un.S_addr = inet_addr("172.18.18.20");
addrSrv.sin_family = AF_INET;
addrSrv.sin_port = htons(8888);
connect(sockClient, (SOCKADDR*)&addrSrv, sizeof(SOCKADDR));
while(1); // 卡住
closesocket(sockClient);
WSACleanup();
return 0;
}
当服务端与客户端建立tcp连接后, 我们分别在pc1和pc2上查一下, 可以看出, 两侧的socket均处于Established状态。 此时, 我们将pc1和pc2之间的网络连线拔掉, 然后, 我们再去看看两边socket的状态, 可以看到, 两侧的socket仍然处于Established状态, 可见:服务端和客户端对断网事件是没有任何感知能力的。 那这样不符合我们的需求啊, 我们希望能监测到啊! 莫急莫急, 一切合理的需求都可以满足, 为此, 我们来介绍一下心跳机制。
想一下, 服务端和客户端怎样才能知道信息能不能到达对方呢? 很自然的想法是, 不断地给对方发探测信号, 看有没有回应, 这就是心跳机制的直白原理。 所谓的心跳即是数据包, 发心跳就是一方向另一方发送的数据包, 不断地发送, 如果收不到回应, 那么就有理由认为是网络出了问题。 那为什么要叫心跳呢? 你摸一下你的心, 你看它是不是均匀在跳? 理解了吧, 均匀发出去的数据包就类似于均匀的心跳信号。 所以, 我要说: 心跳就是(探测性的)数据包。
到此为主, 我们算是搞懂了什么是心跳机制, 为什么需要心跳机制这两个问题。
下面, 我们会更深入地讨论心跳机制, 并在最后会写个程序来实战感受一下(以Windows环境为例)。
要注意, 如果在服务端没有启用心跳机制, 而在客户端启用了心跳机制, 那么断网后, 服务端仍然是没有感受到断网事件的, 而客户端是可以检测到断网事件的。 由于服务端的心跳机制和客户端的心跳机制完全一致, 而且彼此独立, 因此, 为了简便起见, 我们仅仅在客户端启用心跳机制, 然后让客户端感受断网事件(再次强调一下, 因为服务端没有心跳机制, 所以无法感受断网事件)。
虽然我们说心跳就是数据包, 且我们也可以抓包看到, 但其实这个包的报文段是不含有任何数据的, 因此, 即使你用recv函数, 也不会接收到什么值, 也就是说,如果没有应用层数据通信的话, 即使有循环心跳发送接收, recv也会阻塞在那里, 静静地等待。
既然说到心跳, 我们就不得不说说心跳发送的频率, 根据RFC的定义, TCP/IP协议栈需要等待的默认时间间隔是2小时。 但是, 对于大多数应用程序来说说, 2个小时后才能检测到断网又有什么意义呢? 我就不明白了, RFC的作者难道傻么? 为什么要定义这么长的一个时间? 翻阅资料后才得知: 原来, RFC作者是为了弱化用户使用心跳机制。关于心跳机制, 一直存在这么两派争论, 支持派:可以简化应用程序的设计, 让客户端或者服务端检测到断网。 反对派:心跳机制浪费了带宽, 而且可能会拆掉某个相对良好的tcp连接/通道。
好吧, 现在要解决问题, 要检测断网, 我们还是要继续介绍心跳机制, 好在, 是有接口可以改变心跳参数的。 让我稍微有点不太乐意的是: 为什么心跳机制检测断网后, 不指定一个回调的函数接口呢? 不过, 也没关系, 既然你不提供, 那我就开个线程来检测。
当客户端将心跳发给服务端后, 眼巴巴地期望得到服务端的反馈, 如果没有收到反馈, 则之后的任何I/O操作或者待处理的I/O操作都将失败。 所以, 自然可以用recv去检测啊, 用recv函数去偷窥接收的内核缓冲区中的数据, 如果反馈-1, 那就表明通信断了。 顺便说一句, 之前说过, 如果服务端主动关闭通信的socket, 客户端的recv函数会返回0, 所以, 综合起来说, 为了检测出连接的异常, 我们用<=0进行判断。
也啰嗦不少了, 下面给出带有心跳机制的客户端代码吧(说明, 在本文中, 我们认为检测和监测是同义词):
#include <winsock2.h>
#include <stdio.h>
#include <WS2tcpip.h>
#pragma comment(lib, "ws2_32.lib")
#define SIO_KEEPALIVE_VALS _WSAIOW(IOC_VENDOR, 4)
// tcp keepalive结构体
typedef struct tcp_keepalive
{
u_long onoff;
u_long keepalivetime;
u_long keepaliveinterval;
}MY_TCP_KEEPALIVE;
// 通信的socket
SOCKET sockClient = 0;
// 监测线程
DWORD WINAPI monitorThread(LPVOID pM)
{
while (1)
{
char szRecvBuf[10] = { 0 };
int nRet = recv(sockClient, szRecvBuf, 1, MSG_PEEK); // 注意, 最后一个参数必须是MSG_PEEK, 否则会影响主线程接收信息
if (nRet <= 0) // 实际上, 等于0表示服务端主动关闭通信socket
{
printf("监测到啦: nRet is %d\n", nRet);
closesocket(sockClient);
break;
}
Sleep(200);
}
return 0;
}
int main()
{
WORD wVersionRequested;
WSADATA wsaData;
wVersionRequested = MAKEWORD(1, 1);
WSAStartup(wVersionRequested, &wsaData);
sockClient = socket(AF_INET, SOCK_STREAM, 0);
// 启用tcp keepalive机制
#if 1
// 设置SO_KEEPALIVE
int iKeepAlive = 1;
int iOptLen = sizeof(iKeepAlive);
setsockopt(sockClient, SOL_SOCKET, SO_KEEPALIVE, (char *)&iKeepAlive, iOptLen);
MY_TCP_KEEPALIVE inKeepAlive = { 0, 0, 0 };
unsigned long ulInLen = sizeof(TCP_KEEPALIVE);
MY_TCP_KEEPALIVE outKeepAlive = { 0, 0, 0 };
unsigned long ulOutLen = sizeof(TCP_KEEPALIVE);
unsigned long ulBytesReturn = 0;
// 设置心跳参数
inKeepAlive.onoff = 1; // 是否启用
inKeepAlive.keepalivetime = 1000; // 在tcp通道空闲1000毫秒后, 开始发送心跳包检测
inKeepAlive.keepaliveinterval = 500; // 心跳包的间隔时间是500毫秒
/*
补充上面的"设置心跳参数":
当没有接收到服务器反馈后,对于不同的Windows版本,客户端的心跳尝试次数是不同的,
比如, 对于Win XP/2003而言, 最大尝试次数是5次, 其它的Windows版本也各不相同。
当然啦, 如果是在Linux上, 那么这个最大尝试此时其实是可以在程序中设置的。
*/
// 调用接口, 启用心跳机制
WSAIoctl(sockClient, SIO_KEEPALIVE_VALS,
&inKeepAlive, ulInLen,
&outKeepAlive, ulOutLen,
&ulBytesReturn, NULL, NULL);
#endif
SOCKADDR_IN addrSrv;
addrSrv.sin_family = AF_INET;
addrSrv.sin_port = htons(8888);
//addrSrv.sin_addr.S_un.S_addr = inet_addr("172.18.18.20");
InetPton(AF_INET, TEXT("192.168.31.177"), &addrSrv.sin_addr.s_addr);
int nConn = connect(sockClient, (SOCKADDR*)&addrSrv, sizeof(SOCKADDR));
// 开启监测线程
HANDLE handle = CreateThread(NULL, 0, monitorThread, NULL, 0, NULL);
char buf[1024];
while (1) {
//Sleep(100000);
memset(buf, 0, sizeof(buf));
recv(sockClient, buf, sizeof(buf)-1, 0);
Sleep(1000);
}; // 卡住
CloseHandle(handle);
closesocket(sockClient);
WSACleanup();
return 0;
}
先开启服务端, 再开启客户端, 建立tcp连接后, 我们拔掉客户端和服务端之间的网线, 过一会儿(这个一会儿的时间可以根据程序的设定值大致推算出来),结果发现, 客户端确实是有反应的。
如果大家在调试的时候出了问题, 请提出来。 也欢迎大家各抒己见。
未来, 路漫漫, 但必将继续勇敢前行!