1. 程式人生 > >TCP的定時器系列 — 保活定時器

TCP的定時器系列 — 保活定時器

主要內容:保活定時器的實現,TCP_USER_TIMEOUT選項的實現。

核心版本:3.15.2

原理

HTTP有Keepalive功能,TCP也有Keepalive功能,雖然都叫Keepalive,但是它們的目的卻是不一樣的。

為了說明這一點,先來看下長連線和短連線的定義。

連線的“長短”是什麼?

短連線:建立一條連線,傳輸一個請求,馬上關閉連線。

長連線:建立一條連線,傳輸一個請求,過會兒,又傳輸若干個請求,最後再關閉連線。

長連線的好處是顯而易見的,多個請求可以複用一條連線,省去連線建立和釋放的時間開銷和系統呼叫,

但也意味著伺服器的一部分資源會被長時間佔用著。

HTTP的Keepalive,顧名思義,目的在於延長連線的時間,以便在同一條連線中傳輸多個HTTP請求。

HTTP伺服器一般會提供Keepalive Timeout引數,用來決定連線保持多久,什麼時候關閉連線。

當連線使用了Keepalive功能時,對於客戶端傳送過來的一個請求,伺服器端會發送一個響應,然後開始計時,

如果經過Timeout時間後,客戶端沒有再發送請求過來,伺服器端就把連線關了,不再保持連線了。

TCP的Keepalive,是掛羊頭賣狗肉的,目的在於看看對方有沒有發生異常,如果有異常就及時關閉連線。

當傳輸雙方不主動關閉連線時,就算雙方沒有交換任何資料,連線也是一直有效的。

如果這個時候對端、中間網路出現異常而導致連線不可用,本端如何得知這一資訊呢?

答案就是保活定時器。它每隔一段時間會超時,超時後會檢查連線是否空閒太久了,如果空閒的時間超過

了設定時間,就會發送探測報文。然後通過對端是否響應、響應是否符合預期,來判斷對端是否正常,

如果不正常,就主動關閉連線,而不用等待HTTP層的關閉了。

當伺服器傳送探測報文時,客戶端可能處於4種不同的情況:仍然正常執行、已經崩潰、已經崩潰並重啟了、

由於中間鏈路問題不可達。在不同的情況下,伺服器會得到不一樣的反饋。

(1) 客戶主機依然正常執行,並且從伺服器端可達

客戶端的TCP響應正常,從而伺服器端知道對方是正常的。保活定時器會在兩小時以後繼續觸發。

(2) 客戶主機已經崩潰,並且關閉或者正在重新啟動

客戶端的TCP沒有響應,伺服器沒有收到對探測包的響應,此後每隔75s傳送探測報文,一共傳送9次。

socket函式會返回-1,errno設定為ETIMEDOUT,表示連線超時。

(3) 客戶主機已經崩潰,並且重新啟動了

客戶端的TCP傳送RST,伺服器端收到後關閉此連線。

socket函式會返回-1,errno設定為ECONNRESET,表示連線被對端復位了。

(4) 客戶主機依然正常執行,但是從伺服器不可達

雙方的反應和第二種是一樣的,因為伺服器不能區分對端異常與中間鏈路異常。

socket函式會返回-1,errno設定為EHOSTUNREACH,表示對端不可達。

選項

核心預設並不使用TCP Keepalive功能,除非使用者設定了SO_KEEPALIVE選項。

有兩種方式可以自行調整保活定時器的引數:一種是修改TCP引數,一種是使用TCP層選項。

(1) TCP引數

tcp_keepalive_time

最後一次資料交換到TCP傳送第一個保活探測報文的時間,即允許連線空閒的時間,預設為7200s。

tcp_keepalive_intvl

保活探測報文的重傳時間,預設為75s。

tcp_keepalive_probes

保活探測報文的傳送次數,預設為9次。

Q:一次完整的保活探測需要花費多長時間?

A:tcp_keepalive_time + tcp_keepalive_intvl * tcp_keepalive_probes,預設值為7875s。

如果覺得兩個多小時太長了,可以自行調整上述引數。

(2) TCP層選項

TCP_KEEPIDLE:含義同tcp_keepalive_time。

TCP_KEEPINTVL:含義同tcp_keepalive_intvl。

TCP_KEEPCNT:含義同tcp_keepalive_probes。

Q:既然有了TCP引數可供調整,為什麼還增加了上述的TCP層選項?

A:TCP引數是面向本機的所有TCP連線,一旦調整了,對所有的連線都有效。

而TCP層選項是面向一條連線的,一旦調整了,只對本條連線有效。

啟用

在連線建立後,可以通過設定SO_KEEPALIVE選項,來啟用保活定時器。

int keepalive = 1;

setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &keepalive, sizeof(keepalive));

int sock_setsockopt(struct socket *sock, int level, int optname, char __user *optval, 
    unsigned int optlen)
{
    ...
    case SO_KEEPALIVE:
#ifdef CONFIG_INET
        if (sk->sk_protocol == IPPROTO_TCP && sk->sk_type == SOCK_STREAM)
            tcp_set_keepalive(sk, valbool); /* 啟用或刪除保活定時器 */
#endif
        sock_valbool_flag(sk, SOCK_KEEPOPEN, valbool); /* 設定或取消SOCK_KEEPOPEN標誌位 */
        break;
    ...
}

static inline void sock_valbool_flag (struct sock *sk, int bit, int valbool)
{
    if (valbool)
        sock_set_flag(sk, bit);
    else
        sock_reset_flag(sk, bit);
}
void tcp_set_keepalive(struct sock *sk, int val)
{
    /* 不在以下兩個狀態設定保活定時器:
     * TCP_CLOSE:sk_timer用作FIN_WAIT2定時器
     * TCP_LISTEN:sk_timer用作SYNACK重傳定時器
     */
    if ((1 << sk->sk_state) & (TCPF_CLOSE | TCPF_LISTEN))
        return;

    /* 如果SO_KEEPALIVE選項值為1,且此前沒有設定SOCK_KEEPOPEN標誌,
     * 則啟用sk_timer,用作保活定時器。
     */
    if (val && !sock_flag(sk, SOCK_KEEPOPEN))
        inet_csk_reset_keepalive_timer(sk, keepalive_time_when(tcp_sk(sk)));
    else if (!val)
        /* 如果SO_KEEPALIVE選項值為0,則刪除保活定時器 */
        inet_csk_delete_keepalive_timer(sk);
}
 
/* 保活定時器的超時時間 */
static inline int keepalive_time_when(const struct tcp_sock *tp)
{
    return tp->keepalive_time ? : sysctl_tcp_keepalive_time;
}

void inet_csk_reset_keepalive_timer (struc sock *sk, unsigned long len)
{
    sk_reset_timer(sk, &sk->sk_timer, jiffies + len);
}

可以使用TCP層選項來動態調整保活定時器的引數。

int keepidle = 600;

int keepintvl = 10;

int keepcnt = 6;

setsockopt(fd, SOL_TCP, TCP_KEEPIDLE, &keepidle, sizeof(keepidle));

setsockopt(fd, SOL_TCP, TCP_KEEPINTVL, &keepintvl, sizeof(keepintvl));

setsockopt(fd, SOL_TCP, TCP_KEEPCNT, &keepcnt, sizeof(keepcnt));

struct tcp_sock {
    ...
    /* 最後一次接收到ACK的時間 */
    u32 rcv_tstamp; /* timestamp of last received ACK (for keepalives) */
    ...
    /* time before keep alive takes place, 空閒多久後才傳送探測報文 */
    unsigned int keepalive_time;
    /* time iterval between keep alive probes */
    unsigned int keepalive_intvl; /* 探測報文之間的時間間隔 */
    /* num of allowed keep alive probes */
    u8 keepalive_probes; /* 探測報文的傳送次數 */
    ...
    struct {
        ...
        /* 最後一次接收到帶負荷的報文的時間 */
        __u32 lrcvtime; /* timestamp of last received data packet */
        ...
    } icsk_ack;
    ...
};

#define TCP_KEEPIDLE 4 /* Start Keepalives after this period */
#define TCP_KEEPINTVL 5 /* Interval between keepalives */
#define TCP_KEEPCNT 6 /* Number of keepalives before death */
 
#define MAX_TCP_KEEPIDLE 32767
#define MAX_TCP_KEEPINTVL 32767
#define MAX_TCP_KEEPCNT 127
static int do_tcp_setsockopt(struct sock *sk, int level, int optname, char __user *optval,
    unsigned int optlen)
{
    ...
    case TCP_KEEPIDLE:
       if (val < 1 || val > MAX_TCP_KEEPIDLE)
           err = -EINVAL;
        else {
            tp->keepalive_time = val * HZ; /* 設定新的空閒時間 */

            /* 如果有使用SO_KEEPALIVE選項,連線處於非監聽非結束的狀態。
             * 這個時候保活定時器已經在計時了,這裡設定新的超時時間。
             */
            if (sock_flag(sk, SOCK_KEEPOPEN) && 
                !((1 << sk->sk_state) & (TCPF_CLOSE | TCPF_LISTEN))) {
                u32 elapsed = keepalive_time_elapsed(tp); /* 連線已經經歷的空閒時間 */

                if (tp->keepalive_time > elapsed)
                    elapsed = tp->keepalive_time - elapsed; /* 接著等待的時間,然後超時 */
                else
                    elapsed = 0; /* 會導致馬上超時 */
                inet_csk_reset_keepalive_timer(sk, elapsed);
            }
        }
        break;

    case TCP_KEEPINTVL:
        if (val < 1 || val > MAX_TCP_KEEPINTVL)
            err = -EINVAL;
        else
            tp->keepalive_intvl = val * HZ; /* 設定新的探測報文間隔 */
        break;

    case TCP_KEEPCNT:
        if (val < 1 || val > MAX_TCP_KEEPCNT)
            err = -EINVAL;
        else
            tp->keepalive_probes = val; /* 設定新的探測次數 */
        break;
    ...
}

到目前為止,連線已經經歷的空閒時間,即最後一次接收到報文至今的時間。

static inline u32 keepalive_time_elapsed (const struct tcp_sock *tp)
{
    const struct inet_connection_sock *icsk = &tp->inet_conn;

    /* lrcvtime是最後一次接收到資料報的時間
     * rcv_tstamp是最後一次接收到ACK的時間
     * 返回值就是最後一次接收到報文,到現在的時間,即經歷的空閒時間。
     */
    return min_t(u32, tcp_time_stamp - icsk->icsk_ack.lrcvtime,
        tcp_time_stamp - tp->rcv_tstamp);
}

超時處理函式

我們知道保活定時器、SYNACK重傳定時器、FIN_WAIT2定時器是共用一個定時器例項sk->sk_timer,

所以它們的超時處理函式也是一樣的,都為tcp_keepalive_timer()。

而在函式內部,可以根據此時連線所處的狀態,來判斷是哪個定時器觸發了超時。

Q:什麼時候判斷對端為異常並關閉連線?

A:分兩種情況。

1. 使用者使用了TCP_USER_TIMEOUT選項。當連線的空閒時間超過了使用者設定的時間,且有傳送過探測報文。

2. 使用者沒有使用TCP_USER_TIMEOUT選項。當傳送保活探測包的次數達到了保活探測的最大次數時。

static void tcp_keepalive_timer (unsigned long data)
{
    struct sock *sk = (struct sock *) data;
    struct inet_connection_sock *icsk = inet_csk(sk);
    struct tcp_sock *tp = tcp_sk(sk);
    u32 elapsed;

    /* Only process if socket is not in use. */
    bh_lock_sock(sk);

    /* 加鎖以保證在此期間,連線狀態不會被使用者程序修改。
     * 如果使用者程序正在使用此sock,那麼過50ms再來看看。
     */
    if (sock_owned_by_user(sk)) {
        /* Try again later. */
        inet_csk_reset_keepalive_timer(sk, HZ/20);
        goto out;
    }

    /* 三次握手期間,用作SYNACK定時器 */
    if (sk->sk_state == TCP_LISTEN) {
        tcp_synack_timer(sk);
        goto out;
    }    

    /* 連線釋放期間,用作FIN_WAIT2定時器 */
    if (sk->sk_state == TCP_FIN_WAIT2 && sock_flag(sk, SOCK_DEAD)) {
        ...
    }

    /* 接下來就是用作保活定時器了 */
    if (!sock_flag(sk, SOCK_KEEPOPEN) || sk->sk_state == TCP_CLOSE)
        goto out;

    elapsed = keepalive_time_when(tp); /* 連線的空閒時間超過此值,就傳送保活探測報文 */

    /* It is alive without keepalive.
     * 如果網路中有傳送且未確認的資料包,或者傳送佇列不為空,說明連線不是idle的?
     * 既然連線不是idle的,就沒有必要探測對端是否正常。
     * 保活定時器重新開始計時即可。
     * 
     * 而實際上當網路中有傳送且未確認的資料包時,對端也可能會發生異常而沒有響應。
     * 這個時候會導致資料包的不斷重傳,只能依靠重傳超過了允許的最大時間,來判斷連線超時。
     * 為了解決這一問題,引入了TCP_USER_TIMEOUT,允許使用者指定超時時間,可見下文:)
     */
    if (tp->packets_out || tcp_send_head(sk))
        goto resched; /* 保活定時器重新開始計時 */

    /* 連線經歷的空閒時間,即上次收到報文至今的時間 */
    elapsed = keepalive_time_elapsed(tp);

    /* 如果連線空閒的時間超過了設定的時間值 */
    if (elapsed >= keepalive_time_when(tp)) {

        /* 什麼時候關閉連線?
         * 1. 使用了TCP_USER_TIMEOUT選項。當連線空閒時間超過了使用者設定的時間,且有傳送過探測報文。
         * 2. 使用者沒有使用選項。當傳送的保活探測包達到了保活探測的最大次數。
         */
        if (icsk->icsk_user_timeout != 0 && elapsed >= icsk->icsk_user_timeout &&
            icsk->icsk_probes_out > 0) || (icsk->icsk_user_timeout == 0 &&
            icsk->icsk_probes_out >= keepalive_probes(tp))) {
            tcp_send_active_reset(sk, GFP_ATOMIC); /* 構造一個RST包併發送 */
            tcp_write_err(sk); /* 報告錯誤,關閉連線 */
            goto out;
        }

        /* 如果還不到關閉連線的時候,就繼續傳送保活探測包 */
        if (tcp_write_wakeup(sk) <= 0) {
            icsk->icsk_probes_out++; /* 已傳送的保活探測包個數 */
            elapsed = keepalive_intvl_when(tp); /* 下次超時的時間,預設為75s */
        } else {
            /* If keepalive was lost due to local congestion, try harder. */
            elapsd = TCP_RESOURCE_PROBE_INTERVAL; /* 預設為500ms,會使超時更加頻繁 */
        }

    } else {
        /* 如果連線的空閒時間,還沒有超過設定值,則接著等待 */
        elapsed = keepalive_time_when(tp) - elapsed;
    } 

    sk_mem_reclaim(sk);

resched: /* 重設保活定時器 */
    inet_csk_reset_keepalive_timer(sk, elapsed);
    goto out; 

out:
    bh_unlock_sock(sk);
    sock_put(sk);
}


Q:TCP是如何傳送Keepalive探測報文的?

A:分兩種情況。

1. 有新的資料段可供傳送,且對端接收視窗還沒被塞滿。傳送新的資料段,來作為探測包。

2. 沒有新的資料段可供傳送,或者對端的接收視窗滿了。傳送序號為snd_una - 1、長度為0的ACK包作為探測包。

/* Initiate keepalive or window probe from timer. */

int tcp_write_wakeup (struct sock *sk)
{
    struct tcp_sock *tp = tcp_sk(sk);
    struct sk_buff *skb;

    if (sk->sk_state == TCP_CLOSE)
        return -1;

    /* 如果還有未傳送過的資料包,並且對端的接收視窗還沒有滿 */
    if ((skb = tcp_send_head(sk)) != NULL && before(TCP_SKB_CB(skb)->seq, tcp_wnd_end(tp))) {
        int err;
        unsigned int mss = tcp_current_mss(sk); /* 當前的MSS */
        /* 對端接收視窗所允許的最大報文長度 */
        unsigned int seg_size = tcp_wnd_end(tp) - TCP_SKB_CB(skb)->seq;

        /* pushed_seq記錄傳送出去的最後一個位元組的序號 */
        if (before(tp->pushed_seq, TCP_SKB_CB(skb)->end_seq))
            tp->pushed_seq = TCP_SKB_CB(skb)->end_seq;

        /* 如果對端接收視窗小於此資料段的長度,或者此資料段的長度超過了MSS,那麼就要進行分段 */
        if (seg_size < TCP_SKB_CB(skb)->end_seq - TCP_SKB_CB(skb)->seq || skb->len > mss) {
            seg_size = min(seg_size, mss);
            TCP_SKB_CB(skb)->tcp_flags |= TCPHDR_PSH; /* 設定PSH標誌,讓對端馬上把資料提交給程式 */
            if (tcp_fragment(sk, skb, seg_size, mss)) /* 進行分段 */
                return -1;
        } else if (! tcp_skb_pcount(skb)) /* 進行TSO分片 */
            tcp_set_skb_tso_segs(sk, skb, mss); /* 初始化分片相關變數 */

        TCP_SKB_CB(skb)->tcp_flags |= TCPHDR_PSH;
        TCP_SKB_CB(skb)->when = tcp_time_stamp;
        err = tcp_transmit_skb(sk, skb, 1, GFP_ATOMIC); /* 傳送此資料段 */
        if (!err)
            tcp_event_new_data_sent(sk, skb); /* 傳送了新的資料,更新相關引數 */

    } else { /* 如果沒有新的資料段可用作探測報文傳送,或者對端的接收視窗為0 */

       /* 處於緊急模式時,額外發送一個序號為snd_una的ACK包,告訴對端緊急指標 */
       if (between(tp->snd_up, tp->snd_una + 1, tp->snd_una + 0xFFFF))
           tcp_xmit_probe_skb(sk, 1);

        /* 傳送一個序號為snd_una -1的ACK包,長度為0,這是一個序號過時的報文。
         * snd_una: first byte we want an ack for,所以snd_una - 1序號的位元組已經被確認過了。
         * 對端會響應一個ACK。
         */
        return tcp_xmit_probe_skb(sk, 0);
    }
}

Q:當沒有新的資料可以用作探測包、或者對端的接收視窗為0時,怎麼辦呢?

A:傳送一個序號為snd_una - 1、長度為0的ACK包,對端收到此包後會傳送一個ACK響應。

如此一來本端就能夠知道對端是否還活著、接收視窗是否打開了。

/* This routine sends a packet with an out of date sequence number.
 * It assumes the other end will try to ack it.
 * 
 * Question: what should we make while urgent mode?
 * 4.4BSD forces sending single byte of data. We cannot send out of window
 * data, because we have SND.NXT == SND.MAX...
 * 
 * Current solution: to send TWO zero-length segments in urgent mode:
 * one is with SEG.SEG=SND.UNA to deliver urgent pointer, another is out-of-date with
 * SND.UNA - 1 to probe window.
 */

static int tcp_xmit_probe_skb (struct sock *sk, int urgent)
{
    struct tcp_sock *tp = tcp_sk(sk);
    struct sk_buff *skb;

    /* We don't queue it, tcp_transmit_skb() sets ownership. */
    skb = alloc_skb(MAX_TCP_HEADER, sk_gfp_atomic(sk, GFP_ATOMIC));
    if (skb == NULL)
        return -1;

    /* Reserve space for headers and set control bits. */
    skb_reserve(skb, MAX_TCP_HEADER);

    /* Use a previous sequence. This should cause the other end to send an ack.
     * Don't queue or clone SKB, just send it.
     */
    /* 如果沒有設定緊急指標,那麼傳送的序號為snd_una - 1,否則傳送的序號為snd_una */
    tcp_init_nondata_skb(skb, tp->snd_una - !urgent, TCPHDR_ACK);
    TCP_SKB_CB(skb)->when = tcp_time_stamp;
    return tcp_transmit_skb(sk, skb, 0, GFP_ATOMIC); /* 傳送探測包 */
}

傳送RST包。

/* We get here when a process closes a file descriptor (either due to an explicit close()
 * or as a byproduct of exit()'ing) and there was unread data in the receive queue.
 * This behavior is recommended by RFC 2525, section 2.17. -DaveM
 */

void tcp_send_active_reset (struct sock *sk, gfp_t priority)
{
    struct sk_buff *skb;
    /* NOTE: No TCP options attached and we never retransmit this. */
    skb = alloc_skb(MAX_TCP_HEADER, priority);
    if (!skb) {
        NET_INC_STATS(sock_net(sk), LINUX_MIB_TCPABORTFAILED);
        return;
    }

    /* Reserve space for headers and prepare control bits. */
    skb_reserve(skb, MAX_TCP_HEADER); /* 為報文頭部預留空間 */
    /* 初始化不攜帶資料的skb的一些控制欄位 */
    tcp_init_nondata_skb(skb, tcp_acceptable_seq(sk), TCPHDR_ACK | TCPHDR_RST);

    /* Send if off,傳送此RST包*/
    TCP_SKB_CB(skb)->when = tcp_time_stamp;
    if (tcp_transmit_skb(sk, skb, 0, priority))
        NET_INC_STATS(sock_net(sk), LINUX_MIB_TCPABORTFAILED);
    TCP_INC_STATS(sock_net(sk), TCP_MIB_OUTRSTS);
}

static inline __u32 tcp_acceptable_seq (const struct sock *sk)
{
    const struct tcp_sock *tp = tcp_sk(sk);

    /* 如果snd_nxt在對端接收視窗範圍內 */
    if (! before(tcp_wnd_end(tp), tp->snd_nxt))
        return tp->snd_nxt;
    else
        return tcp_wnd_end(tp);
}

TCP_USER_TIMEOUT選項

從上文可知同時符合以下條件時,保活定時器才會傳送探測報文:

1. 網路中沒有傳送且未確認的資料包。

2. 傳送佇列為空。

3. 連線的空閒時間超過了設定的時間。

Q:如果網路中有傳送且未確認的資料包、或者傳送佇列不為空時,保活定時器不起作用了,

豈不是不能夠檢測到對端的異常了?

A:可以使用TCP_USER_TIMEOUT,顯式的指定當傳送資料多久後還沒有得到響應,就判定連線超時,

從而主動關閉連線。

TCP_USER_TIMEOUT選項會影響到超時重傳定時器和保活定時器。

(1) 超時重傳定時器

判斷連線是否超時,分3種情況:

1. SYN包:當SYN包的重傳次數達到上限時,判定連線超時。(預設允許重傳5次,初始超時時間為1s,總共歷時31s)

2. 非SYN包,使用者使用TCP_USER_TIMEOUT:當資料包發出去後的等待時間超過使用者設定的時間時,判定連線超時。

3. 非SYN包,使用者沒有使用TCP_USER_TIMEOUT:當資料包發出去後的等待時間超過以TCP_RTO_MIN為初始超時

時間,重傳boundary次所花費的時間後,判定連線超時。(boundary的最大值為tcp_retries2,預設值為15)

(2) 保活定時器

判斷連線是否異常,分2種情況:

1. 使用者使用了TCP_USER_TIMEOUT選項。當連線的空閒時間超過了使用者設定的時間,且有傳送過探測報文。

2. 使用者沒有使用TCP_USER_TIMEOUT選項。當傳送保活探測包的次數達到了保活探測的最大次數時。