從一個例子開始
有一個基於 NATS 消息系統的項目,多個消息的提供者 (pub)和訂閱者(sub)都連到 NATS 消息系統,通過這個系統來完成消息的投遞和訂閱處理。突然有一天,線上報了一個故障,一個流程不能正常處理。經排查,發現消息正確地投遞到了 NATS 服務端,但是消息訂閱者沒有收到該消息,也沒能做出處理,導致流程沒能進行下去。通過觀察消息訂閱者後發現,消息訂閱者到 NATS 服務端的連接雖然顯示是“正常”的,但實際上,這個連接已經是無效的了。
爲什麼呢?這是因爲 NATS 服務器崩潰過,NATS 服務器和消息訂閱者之間的連接中斷 FIN 包,由於異常情況,沒能夠正常到達消息訂閱者,這樣造成的結果就是消息訂閱者一直維護着一個“過時的”連接,不會收到 NATS 服務器發送來的消息。
這個故障的根本原因在於,作爲 NATS 服務器的客戶端,消息訂閱者沒有及時對連接的有效性進行檢測,這樣就造成了問題。
TCP Keep-Alive 選項
很多剛接觸 TCP 編程的人會驚訝地發現,在沒有數據讀寫的“靜默”的連接上,是沒有辦法發現 TCP 連接是有效還是無效的。比如客戶端突然崩潰,服務器端可能在幾天內都維護着一個無用的 TCP 連接。前面提到的例子就是這樣的一個場景。
那麼有沒有辦法開啓類似的“輪詢”機制,讓 TCP 告訴我們,連接是不是“活着”的呢?這就是 TCP 保持活躍機制所要解決的問題。實際上,TCP 有一個保持活躍的機制叫做 Keep-Alive。
這個機制的原理是這樣的:定義一個時間段,在這個時間段內,如果沒有任何連接相關的活動,TCP 保活機制會開始作用,每隔一個時間間隔,發送一個探測報文,該探測報文包含的數據非常少,如果連續幾個探測報文都沒有得到響應,則認爲當前的 TCP 連接已經死亡,系統內核將錯誤信息通知給上層應用程序。
上述的可定義變量,分別被稱爲保活時間、保活時間間隔和保活探測次數。在 Linux 系統中,這些變量分別對應 sysctl 變量net.ipv4.tcp_keepalive_time、net.ipv4.tcp_keepalive_intvl、 net.ipv4.tcp_keepalve_probes,默認設置是 7200 秒(2 小時)、75 秒和 9 次探測。
如果開啓了 TCP 保活,需要考慮以下幾種情況:
第一種,對端程序是正常工作的。探測報文正常交互,tcp保活機時間被重置,等待下一個保活時間的到來。
第二種,對端程序崩潰並重啓。當 TCP 保活的探測報文發送給對端後,對端是可以響應的,但由於沒有該連接的有效信息,會產生一個 RST 報文,這樣很快就會發現 TCP 連接已經被重置。
第三種,對端程序崩潰,或對端由於其他原因導致報文不可達。當 TCP 保活的探測報文發送給對端後,石沉大海,沒有響應,連續幾次,達到保活探測次數後,TCP 會報告該 TCP 連接已經死亡。
TCP 保活機制默認是關閉的,當我們選擇打開時,可以分別在連接的兩個方向上開啓,也可以單獨在一個方向上開啓。如果開啓服務器端到客戶端的檢測,就可以在客戶端非正常斷連的情況下清除在服務器端保留的“髒數據”;而開啓客戶端到服務器端的檢測,就可以在服務器無響應的情況下,重新發起連接。
爲什麼 TCP 不提供一個頻率很好的保活機制呢?我的理解是早期的網絡帶寬非常有限,如果提供一個頻率很高的保活機制,對有限的帶寬是一個比較嚴重的浪費。
應用層保活
如果使用 TCP 自身的 keep-Alive 機制,在 Linux 系統中,最少需要經過 2 小時 11 分 15 秒纔可以發現一個“死亡”連接。這個時間是怎麼計算出來的呢?其實是通過 2 小時,加上 75 秒乘以 9 的總和。實際上,對很多對時延要求敏感的系統中,這個時間間隔是不可接受的。
所以,必須在應用程序這一層來尋找更好的解決方案。我們可以通過在應用程序中模擬 TCP Keep-Alive 機制,來完成在應用層的連接探活。比如,我們可以設計一個 PING-PONG 的機制,需要保活的一方,比如客戶端,在保活時間達到後,發起對連接的 PING 操作,如果服務器端對 PING 操作有迴應,則重新設置保活時間,否則對探測次數進行計數,如果最終探測次數達到了保活探測次數預先設置的值之後,則認爲連接已經無效。
這裏有兩個比較關鍵的點:第一個是需要使用定時器,這可以通過使用 I/O 複用自身的機制來實現;第二個是需要設計一個 PING-PONG 的協議。下面嘗試完成這樣的設計。
消息格式設計
我們的程序是客戶端來發起保活,爲此定義了一個消息對象。這個消息對象是一個結構體,前 4 個字節標識了消息類型,爲了簡單,這裏設計了MSG_PING、MSG_PONG、MSG_TYPE 1和MSG_TYPE 2四種消息類型。
typedef struct {
u_int32_t type;
char data[1024];
} messageObject;
#define MSG_PING 1
#define MSG_PONG 2
#define MSG_TYPE1 11
#define MSG_TYPE2 21
客戶端程序設計
客戶端完全模擬 TCP Keep-Alive 的機制,在保活時間達到後,探活次數增加 1,同時向服務器端發送 PING 格式的消息,此後以預設的保活時間間隔,不斷地向服務器端發送 PING 格式的消息。如果能收到服務器端的應答,則結束保活,將保活時間置爲 0。這裏我們使用 select I/O 複用函數自帶的定時器,select 函數將在後面詳細介紹。
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/select.h>
#include <unistd.h>
#define MESSAGE_SIZE 10240000
#define MAXLINE 4096
#define KEEP_ALIVE_TIME 10
#define KEEP_ALIVE_INTERVAL 3
#define KEEP_ALIVE_PROBETIMES 3
typedef struct {
u_int32_t type;
char data[1024];
} messageObject;
#define MSG_PING 1
#define MSG_PONG 2
#define MSG_TYPE1 11
#define MSG_TYPE2 21
int main()
{
int sockfd;
int connect_rt;
struct sockaddr_in serv_addr;
sockfd = socket(PF_INET, SOCK_STREAM, 0);
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(7878);
inet_pton(AF_INET, "192.168.133.131", &serv_addr.sin_addr);
connect_rt = connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
if (connect_rt < 0)
{
fprintf(stderr, "Connect failed !\n");
exit(0);
}
char recv_line[MAXLINE + 1];
int n;
fd_set readmask;
fd_set allreads;
FD_ZERO(&allreads);
FD_SET(sockfd, &allreads); // add sockfd to addreads
struct timeval tv;
int heartbeats = 0;
tv.tv_sec = KEEP_ALIVE_TIME;
tv.tv_usec = 0;
messageObject messageObject;
for(;;)
{
readmask = allreads;
/*
調用 select 函數,感知 I/O 事件。這裏的 I/O 事件,除了套接字上的讀操作之外,前面設置的超時事件。當 KEEP_ALIVE_TIME 這段時間到達之後,select 函數會返回 0.
*/
if(select(sockfd + 1, &readmask, NULL, NULL, &tv) == 0){
if(++heartbeats > KEEP_ALIVE_PROBETIMES){
printf("connection dead\n");
exit(0);
}
printf("sending heartbeat #%d\n", heartbeats);
messageObject.type = htonl(MSG_PING);
/*
客戶端已經在 KEEP_ALIVE_TIME 這段時間內沒有收到任何對當前連接的反饋,
於是發起 PING 消息,嘗試問服務器端:”喂,你還活着嗎?“這裏我們通過傳送一個類型爲 MSG_PING 的消息對象來完成 PING 操作,之後我們會看到服務器端程序如何響應這個 PING 操作
*/
if(send(sockfd, (char*)&messageObject, sizeof(messageObject), 0) < 0)
printf("send failure\n");
tv.tv_sec = KEEP_ALIVE_INTERVAL;
continue;
}
/*
客戶端在接收到服務器端程序之後的處理。爲了簡單,這裏就沒有再進行報文格式的轉換和分析。在實際的工作中,這裏其實是需要對報文進行解析後處理的,只有是 PONG 類型的迴應,我們才認爲是 PING 探活的結果。這裏認爲既然收到服務器端的報文,那麼連接就是正常的,所以會對探活計數器和探活時間都置零,等待下一次探活時間的來臨
*/
if(FD_ISSET(sockfd, &readmask))
{
n = read(sockfd, recv_line, MAXLINE);
if(n < 0)
{
fprintf(stderr, "read error\n");
exit(0);
}
else if(n == 0)
{
fprintf(stderr, "server terminated\n");
exit(0);
}
recv_line[n] = 0;
printf("received heartbeat, make heartbeats to 0 \n");
heartbeats = 0;
tv.tv_sec = KEEP_ALIVE_TIME;
}
}
return 0;
}
服務端程序設計
服務器端的程序接受一個參數,這個參數設置的比較大,可以模擬連接沒有響應的情況。服務器端程序在接收到客戶端發送來的各種消息後,進行處理,其中如果發現是 PING 類型的消息,在休眠一段時間後回覆一個 PONG 消息,告訴客戶端:”嗯,我還活着。“當然,如果這個休眠時間很長的話,那麼客戶端就無法快速知道服務器端是否存活,這是我們模擬連接無響應的一個手段而已,實際情況下,應該是系統崩潰,或者網絡異常。
#include <sys/types.h> /* basic system data types */
#include <sys/socket.h> /* basic socket definitions */
#include <netinet/in.h> /* sockaddr_in{} and other Internet defns */
#include <arpa/inet.h> /* inet(3) functions */
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <string.h> /* for convenience */
static int count;
typedef struct {
u_int32_t type;
char data[1024];
} messageObject;
#define MSG_PING 1
#define MSG_PONG 2
#define MSG_TYPE1 11
#define MSG_TYPE2 21
int main(int argc, char **argv)
{
if (argc != 2) {
fprintf(stderr, "usage: tcpsever <sleepingtime>\n");
return 0;
}
int sleepingTime = atoi(argv[1]);
int listenfd, connfd;
socklen_t cli_addr_len;
struct sockaddr_in serv_addr, cli_addr;
listenfd = socket(PF_INET, SOCK_STREAM, 0);
bzero(&serv_addr, sizeof(serv_addr));
bzero(&cli_addr, sizeof(cli_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(7878);
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
bind(listenfd, (struct sockaddr*) &serv_addr, sizeof(serv_addr));
listen(listenfd, SOMAXCONN);
connfd = accept(listenfd, (struct sockaddr* )&cli_addr, &cli_addr_len);
messageObject message;
count = 0;
for(;;)
{
int n = read(connfd, (char*) &message, sizeof(messageObject));
if(n < 0)
{
printf("error read\n");
exit(0);
}
else if(n == 0)
{
printf("client closed\n");
exit(0);
}
printf("received %d bytes\n", n);
count ++;
switch (ntohl(message.type)){
case MSG_TYPE1:
printf("process MSG_TYPE1 \n");
break;
case MSG_TYPE2:
printf("process MSG_TYPE2 \n");
break;
case MSG_PING:
{
messageObject pong_message;
pong_message.type = MSG_PONG;
sleep(sleepingTime);
if(send(connfd, (char *) &pong_message, sizeof(pong_message), 0) < 0){
fprintf(stderr, "send failure\n");
exit(0);
}
break;
}
default:
fprintf(stderr, "unknown message type (%d)\n", ntohl(message.type));
exit(0);
}
}
return 0;
}
實驗
第一次實驗,服務器端休眠時間爲 60 秒。
我們看到,客戶端在發送了三次心跳檢測報文 PING 報文後,判斷出連接無效,直接退出了。之所以造成這樣的結果,是因爲在這段時間內沒有接收到來自服務器端的任何 PONG 報文。當然,實際工作的程序,可能需要不一樣的處理,比如重新發起連接。
$./tcpclient
sending heartbeat #1
sending heartbeat #2
sending heartbeat #3
connection dead
$./tcpserver 60
received 1028 bytes
received 1028 bytes
第二次實驗,我們讓服務器端休眠時間爲 5 秒。
我們看到,由於這一次服務器端在心跳檢測過程中,及時地進行了響應,客戶端一直都會認爲連接是正常的。
$./tcpclient
sending heartbeat #1
sending heartbeat #2
received heartbeat, make heartbeats to 0
received heartbeat, make heartbeats to 0
sending heartbeat #1
sending heartbeat #2
received heartbeat, make heartbeats to 0
received heartbeat, make heartbeats to 0
$./tcpserver 5
received 1028 bytes
received 1028 bytes
received 1028 bytes
received 1028 bytes
注意,客戶端的定時器設定爲每10秒鐘進行一次select返回0,此時進行探測包發送。而服務器端延遲5秒進行應答,爲什麼客戶端每次都能發送兩個包後才清零?不應該是隻進行一個包的發送,服務器端延遲5秒就給出應答,然後清零嗎?
當客戶端10秒鐘select超時時間到達,第一次進入heartbeat,發送報文給服務器端,同時客戶端把下一次select超時時間設置爲3秒(KEEP_ALIVE_INTERVAL);
由於服務器端是5秒之後纔回復,3秒之後,第二次heartbeat時間到,客戶端發送第二個heartbeat。5秒之後,第一次的heartbeat回覆到,客戶端把超時時間又重新設置爲10秒。再過5秒之後,第二次的heartbeat回覆到,客戶端把超時時間再次設置爲10秒。
如此反覆。
通過今天的文章,我們能看到雖然 TCP 沒有提供系統的保活能力,讓應用程序可以方便地感知連接的存活,但是,我們可以在應用程序裏靈活地建立這種機制。一般來說,這種機制的建立依賴於系統定時器,以及恰當的應用層報文協議。比如,使用心跳包就是這樣一種保持 Keep Alive 的機制。
溫故而知新 !