心跳檢測在網絡程序中常常被用到,在客戶端和服務器之間暫時沒有數據交互時,就需要心跳檢測對方是否存活。心跳檢測可以由客戶端主動發起,也可以由服務器主動發起。在網上看了一下心跳的講解,大多是千遍一律只是給出了客戶端十分簡單的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