Linux網絡編程 - 連接無效:使用 Keep-Alive 還是 應用心跳檢測?

從一個例子開始

有一個基於 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 的機制。

 

溫故而知新 !

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章