心跳检测在网络程序中常常被用到,在客户端和服务器之间暂时没有数据交互时,就需要心跳检测对方是否存活。心跳检测可以由客户端主动发起,也可以由服务器主动发起。在网上看了一下心跳的讲解,大多是千遍一律只是给出了客户端十分简单的Heartbeat。这里提供了三种Echo服务器的HeartBeat 实例可供参考。来对比它们各自的优缺点。 https://github.com/BambooAce/MyEvent/tree/master/heartbeat
完整测试代码在上述github连接中: 其中服务器是epoll模型的,测试客户端是python写的。测试时别忘了把client的目标server IP地址改了。
hb_msg_oob 带外数据类型心跳 hb_send_recv 正常数据的心跳 keepalive TCP 的keepalive选项实现heartbeat hb_test_client/hb_oob.py带外数据类型的client hb_test_client/hb.py 发送正常数据的client 也可用来测试keepalive类型服务器
下面我们依次看一下这三种模型:
TCP KeepAlive实现心跳检测:
在TCP协议中提供了保活计时器,这个计时器默认是两个小时,可以看一下它们的相关内核参数:
hb_send_recv]$ cat /proc/sys/net/ipv4/tcp_keepalive_time 最后一次数据发送与探测的间隔时间 2h 7200 hb_send_recv]$ cat /proc/sys/net/ipv4/tcp_keepalive_intvl 一直未有数据交互,连续探测时间间隔 75 hb_send_recv]$ cat /proc/sys/net/ipv4/tcp_keepalive_probes 到检测断开,发送探测没有回复要坚持探测多少次 9
套接字选项提供了对它的控制
void setkeepalive(int lisfd, unsigned int begin, unsigned int cnt, unsigned int intvl) { if(lisfd){ int keepalive = 1; if(setsockopt(lisfd, SOL_SOCKET, SO_KEEPALIVE,(const void *)&keepalive, sizeof(keepalive)) == -1) { fprintf(stderr, "SO_KEEPALIVE %s\n", strerror(errno));//开启调整keepalive的选项 } if(setsockopt(lisfd, IPPROTO_TCP, TCP_KEEPIDLE, (const void *)&begin, sizeof(begin)) == -1) { fprintf(stderr, "TCP_KEEPIDLE %s\n", strerror(errno)); //距离上次发送数据多长时间后开始探测 } if(setsockopt(lisfd, IPPROTO_TCP, TCP_KEEPCNT, (const void *)&cnt, sizeof(cnt))==-1) { fprintf(stderr, "TCP_KEEPCNT %s\n", strerror(errno));//探测没有回应要坚持多少次 } if(setsockopt(lisfd, IPPROTO_TCP, TCP_KEEPINTVL, (const void *)&intvl, sizeof(intvl))==-1) { fprintf(stderr, "TCP_KEEPINTVL %s\n", strerror(errno));//无数据交互下 每隔多长时间探测一次 } } }
再看一下服务器端实现
setsockopt(lisfd, SOL_SOCKET, SO_REUSEADDR, (const void *)&re, re); setsockopt(lisfd, SOL_SOCKET, SO_REUSEPORT, (const void *)&re, re); setnonblock(lisfd); setkeepalive(lisfd, 10, 2, 10);//将监听描述符号设置了这个属性 accept后返回的文件描述符都会继承此属性 void loop(int lisfd) { int epfd = epoll_create(MAXCLIENT); if(epfd < 0) return; struct epoll_event events[MAXCLIENT]; memset(events, 0, sizeof(struct epoll_event) * MAXCLIENT); struct epoll_event ev; ev.data.fd = lisfd; ev.events = EPOLLIN | EPOLLET; epoll_ctl(epfd, EPOLL_CTL_ADD, lisfd, &ev); struct sockaddr_in cliaddr; socklen_t len = sizeof(cliaddr); int clifd = -1; char buff[SIZE] = {0}; while(1) { lable: int ready = epoll_wait(epfd, events, MAXCLIENT, -1); if(ready == -1) { if(errno == EINTR) goto lable; } if(ready) { int i; for(i = 0;i < ready; ++i) { if(events[i].data.fd == lisfd) { lable2: while((clifd = accept(lisfd, (struct sockaddr *)&cliaddr, &len)) > 0) { setnonblock(clifd); ev.data.fd = clifd; ev.events = EPOLLIN | EPOLLPRI | EPOLLERR; epoll_ctl(epfd, EPOLL_CTL_ADD, clifd, &ev); } if(clifd == -1) { if(errno != EAGAIN){ goto lable2; } } }else if(events[i].events & EPOLLIN) { size_t rd = read(events[i].data.fd, buff, SIZE); if(rd) { printf("%s\n", buff); write(events[i].data.fd, buff, rd); memset(buff, 0, SIZE); } if (rd == 0) { fprintf(stderr, "closed\n"); close(events[i].data.fd); epoll_ctl(epfd, EPOLL_CTL_DEL, events[i].data.fd, &(events[i])); } if (rd == -1)// 这里在《UNIX 网络编程》中 说到 如果探测包长时间没有收到反馈,可能就会由路由器发送icmp的错误,errno=EHOSTUNREACH 或者 ETIMEDOUT { //但是我的试验结果是并没有走到这里 而是rd== 0,服务器向客户端IP 发送了一个RST 。 这里如果服务器继续往这个客户端写的话,那么就会
//造成路由器发送ICMP给服务器 服务器send 返回-1 errno=EHOSTUNREACH 或者 ETIMEDOUT。 if (errno ==ECONNRESET ) { fprintf(stderr, "connect reset\n"); } else if (errno == EHOSTUNREACH) { fprintf(stderr, "host unreach\n"); } else if (errno ==ETIMEDOUT ) { fprintf(stderr, "timeout\n"); } } } else if(events[i].events & EPOLLPRI) { // } else if(events[i].events & EPOLLOUT) { // } else if(events[i].events & EPOLLERR) { fprintf(stderr, "have error\n" ); } } } } }
我将客户端与服务器之间的网络断开,抓取服务器端的数据包如下
192.168.179.128.ircu-2 > 192.168.179.129.msfw-array: Flags [.], cksum 0xe879 (incorrect -> 0xd8db), seq 15, ack 16, win 114, options [nop,nop,TS val 4778016 ecr 5041318], length 0 15:53:21.668755 IP (tos 0x0, ttl 64, id 43342, offset 0, flags [DF], proto TCP (6), length 52) 192.168.179.128.ircu-2 > 192.168.179.129.msfw-array: Flags [.], cksum 0xe879 (incorrect -> 0xb1bb), seq 15, ack 16, win 114, options [nop,nop,TS val 4788032 ecr 5041318], length 0 15:53:31.684278 IP (tos 0x0, ttl 64, id 43343, offset 0, flags [DF], proto TCP (6), length 52)//以上是坚持探测 但是并没有回应。 192.168.179.128.ircu-2 > 192.168.179.129.msfw-array: Flags [R.], cksum 0xe879 (incorrect -> 0x8a96), seq 16, ack 16, win 114, options [nop,nop,TS val 4798048 ecr 5041318], length 0
最后服务器端向客户端发送了 RST复位要断开这个连接。
最终服务器在read返回0,表明客户端已断开。注意这里与客户端正常关闭返回没有什么区别,从应用层是没有办法知道与客户端断开的(除非继续向客户端send 直至发生重传放弃)。那么对后续的处理就不知道是正常close还是因网络原因临时断开。如果服务器并不关心这些那么服务器就可以很好处理不断重传问题,它会在超时后主动切断网络。客户端最后再连接上时也会被服务器拒绝。
正常数据交互探测:
在这里的实例客户端与服务器实现简单的回射,一段时间未交互时,服务器主动发送HEARTBEAT,然后客户端收到后回应HEARTBEAT,当多次未回应时表示网络已经断开。主要代码如下:
time_t cache; int n; int maxfd = -1; while(1) { lable: int ready = epoll_wait(epfd, events, MAXCLIENT, 300); if(ready == -1) { if(errno == EINTR) goto lable; } time_t now = time(NULL); cache = now; if(ready) { int i; for(i = 0;i < ready; ++i) { if(events[i].data.fd == lisfd) { lable2: while((clifd = accept(lisfd, (struct sockaddr *)&cliaddr, &len)) > 0) { maxfd = clifd > maxfd ? clifd : maxfd; setnonblock(clifd); ev.data.fd = clifd; ev.events = EPOLLIN | EPOLLPRI | EPOLLERR; epoll_ctl(epfd, EPOLL_CTL_ADD, clifd, &ev); base->cli_map[clifd].fd = clifd;//以文件描述符为数组标号 base->cli_map[clifd].idle = now; //记录时间 base->cli_map[clifd].times = 0; //已经探测了多少次 base->cli_map[clifd].interval= 20; //探测间隔时间 base->cli_map[clifd].flags = CONNECTED; //状态 (base->cliNum)++; //客户端个数 } if(clifd == -1) { if(errno != EAGAIN){ goto lable2; } } }else if(events[i].events & EPOLLIN) { size_t rd = read(events[i].data.fd, buff, SIZE); if(rd) { printf("%s\n", buff); if (strcmp(buff, hb) == 0) //收到的是HEARTBEAT { //if(base->cli_map[events[i].data.fd].times){ // (base->cli_map[events[i].data.fd].times)--; //探测次数 //} }else{ write(events[i].data.fd, buff, rd); } base->cli_map[events[i].data.fd].idle = cache; //更新交互时间 base->cli_map[events[i].data.fd].times = 0; //无反馈坚持次数至0 memset(buff, 0, SIZE); } if (rd == 0) { fprintf(stderr, "client close normally\n" ); close(events[i].data.fd); memset(&(base->cli_map[events[i].data.fd]),0, sizeof(struct Event)); } } else if(events[i].events & EPOLLPRI) { // } } } for (n = 0; n <= maxfd; ++n) { if ((base->cli_map[n].flags == CONNECTED) && (cache >= (base->cli_map[n].idle +base->cli_map[n].interval)) && (base->cli_map[n].times < 3)) { //如果超时没有数据交互 且探测次数小于3次 则发送HB write(base->cli_map[n].fd, hb, strlen(hb)); base->cli_map[n].idle = cache; //更新时间 (base->cli_map[n].times)++; //次数+1 } else if (base->cli_map[n].times == 3) //如果是3次了则表明可能断开了 { fprintf(stderr, "%d may be offline \n", base->cli_map[n].fd); base->cli_map[n].flags = OFFLINE; base->cli_map[n].times = 0; ev.events = EPOLLIN; ev.data.fd = base->cli_map[n].fd; epoll_ctl(epfd, EPOLL_CTL_DEL, base->cli_map[n].fd , &ev); close(base->cli_map[n].fd); //memset(&(base->cli_map[i]), 0, sizeof(struct Event)); } } } }
这里只是在单个线程中简单的实现,如果长时间没有交互会定时的发送HB,如果网络断开的话那么在规定时间没有相应就认为是网络断开了,这时能够知道可能网络断开这件事可以对此连接进行下一步的处理,这里可以将HB放在单个线程中去实现。
带外数据实现心跳检测:
带外数据的HB的实现和上面大致相同,只是注意紧急数据只用一个字节,发送和接收都为单个字节,否则多于字节会当成正常数据来接收。在EPOLL中EPOLLPRI表示接收到了紧急数据,在select异常表示收到了紧急数据。
}else if(events[i].events & EPOLLIN) { size_t rd = read(events[i].data.fd, buff, SIZE); if(rd) { printf("%s\n", buff); write(events[i].data.fd, buff, rd); base->cli_map[events[i].data.fd].idle = cache; base->cli_map[events[i].data.fd].times = 0; memset(buff, 0, SIZE); } if (rd == 0) { fprintf(stderr, "client close normally\n" ); close(events[i].data.fd); memset(&(base->cli_map[events[i].data.fd]),0, sizeof(struct Event)); } } else if(events[i].events & EPOLLPRI) //收到了HB { size_t oob_msg = recv(events[i].data.fd, buff, SIZE, MSG_OOB); if (oob_msg) { if (strcmp(buff, OOB_ACK) == 0) { fprintf(stderr, "Recv MSG_OOB\n"); } base->cli_map[events[i].data.fd].idle = cache; base->cli_map[events[i].data.fd].times = 0; memset(buff, 0, SIZE); } } } }
这样做的好处在于有利用读取时的数据分离,但整体来说和正常数据HB相同。
总结:
心跳检测的各种实现方式 | 各自的优点 | 各自的缺点 |
TCP keepalive实现 | 实现简单方便,只需要两次数据交互即可 另一端无需专门实现(适用与不在乎对方的断线的状态) |
很难知道对方是正常断开还是处于断线状态,若对于断线还有专门的处理那么无法知道对方的真正状态(read == 0此种情况) 其中有一个套接字选项可以知道是否断线 可参考: http://blog.liyiwei.cn/tcp-keep-alive/ |
send/recv 的实现 | 能够知道对端断线或者是正常断开 有利于后续对断线类的单独处理(如游戏中 断线正常连上 不扣积分,强制断开逃跑扣积分等等) |
实现比TCP keepalive要复杂,需要彼此交互,探测过程四次数据交互(捎带确认下三次即可),数据与正常的数据要做分离,需 要额外不同的应用层协议实现。 |
紧急数据的实现 | 优点和上述正常数据交互的HB相同,另外它可以做到数据的分离,方便分开处理 实现比正常数 据交互稍微简单点 |
缺点就是不确定紧急数据会不会对网络造成影响,接收到紧急数据后优先处理等等? |
另外:要注意的是TCP keepalive的心跳机制,在《unix网络编程》一书中提到,对于大多数内核这个参数是基于整个内核维护时间参数的,而不是基于每个套接字的维护的,因此如果修改了keepalive时间,可能会影响到该主机上所有开启这个选项的套接字。但是对于一般服务器内只有一个server下无影响,再者这种情况下是无需对端特别去实现的。 这里如果只是避免一端在断开网络的情况下 不断尝试重传并且不在乎与另一端断开网络的状态还是使用TCP keepalive较为方便,但是如果十分关心一端断线的状态那就使用应用层自己实现的心跳机制。
完整测试代码在: https://github.com/BambooAce/MyEvent/tree/master/heartbeat