天天看點

深入剖析阻塞式socket的timeout

前言

        網絡程式設計中逾時時間是一個重要但又容易被忽略的問題,對其的設定需要仔細斟酌。

        本文讨論的是socket設定為阻塞模式,如果socket處于阻塞模式運作時,就需要考慮處理socket操作逾時的問題。

        所謂阻塞模式,是指其完成指定的操作之前阻塞目前的程序或線程,直到操作有結果傳回.

        在我們直接調用socket操作函數時,如果不進行特意聲明的話,它們都是工作在阻塞模式的,

         如 connect, send, recv等.

簡單分類的話,可以将逾時處理分成兩類:

連接配接(connect)逾時;

發送(send), 接收(recv)逾時; 

連接配接逾時

        從字面上看,連接配接逾時就是在一定時間内還是連接配接不上目标主機。你所建立的socket連接配接其實最終都要進行系統調用進入核心态,剩下的就是等待核心通知連接配接建立。是以自行在代碼中設定了逾時時間(一般是叫connectTimeout或者socketTimeout),那麼這個逾時時間一到如果核心還沒成功建立連接配接,那就認為是連接配接逾時了。如果他們沒設定逾時時間,那麼這個connectTimeout就取決于核心什麼時候抛出逾時異常了。

是以,我們需要分析一下核心是怎麼來判斷連接配接逾時的。

核心層的逾時分析

我們都知道一個連接配接的建立需要經過3次握手,是以連接配接逾時簡單的說是是用戶端往服務端發的SYN封包沒有得到響應(服務端沒有傳回ACK封包)。

由于網絡本身是不穩定的,丢包是很常見的事情(或者對方主機因為某些原因丢棄了該包),是以核心在發送SYN封包沒有得到響應後,往往還是進行多次重試。同時,為了避免發送太多的包影響網絡,重試的時間間隔還會不斷增加。

在linux中,重試的時間間隔會呈指數型增長,為2的N次方,即:

第一次發送SYN封包後等待1s(2的0次幂)後再重試

第二次發送SYN封包後等待2s(2的1次幂)後再重試

第三次發送SYN封包後等待4s(2的2次幂)後再重試

第四次發送SYN封包後等待8s(2的3次幂)後再重試

第五次發送SYN封包後等待16s(2的4次幂)後再重試

第六次發送SYN封包後等待32s(2的5次幂)後再重試

第七次發送SYN封包後等待64s(2的6次幂)後再重試

對于重試次數,由linux的net.ipv4.tcp_syn_retries來确定,預設值一般是6(有些linux發行版可能不太一樣),我們可以通過sysctl net.ipv4.tcp_syn_retries檢視。比如重試次數是6次,那麼我們可以得出逾時時間應該是 1+2+4+8+16+32+64=127秒 (上面的第一條是第一次發送SYN封包,不算重試)。

如果我們想修改重試次數,可以輸入指令sysctl -w net.ipv4.tcp_syn_retries=5來修改(需要root權限)。如果希望重新開機後生效,将net.ipv4.tcp_syn_retries = 5放入/etc/sysctl.conf中,之後執行sysctl -p 即可生效。

在一些linux發行版中,重試時間可能會變動。如果想确定作業系統具體的逾時時間,可以通過下面這條指令來判斷:

gaoke@ubuntu:~$ date; telnet 10.16.15.15 5000; date
Sat Apr  2 14:27:33 CST 2022
Trying 10.16.15.15...
telnet: Unable to connect to remote host: Connection timed out
Sat Apr  2 14:29:40 CST 2022      

綜合分析

如果應用層面設定了自己的逾時時間,同時核心也有自己的逾時時間,那麼應該以哪個為準呢?答案是哪個逾時時間小以哪個為準。

個人認為,在我們的實際應用中,這個逾時時間不宜設定的太長,通常建議2-10s。比如在分布式系統中,我們通常會在多台節點中根據一定政策選擇一台進行連接配接。在有機器當機的情況下,如果連接配接逾時時間設定的比較長,而我們用戶端的線程池又比較小,就很可能大多數的線程都在等待建立連接配接,過了較長時間才發現連接配接不上,影響應用的整體吞吐量。

connect系統調用

我們觀察一下此系統調用的kernel源碼,調用棧如下所示:

connect[使用者态]
    |->SYSCALL_DEFINE3(connect)[核心态]
            |->sock->ops->connect      

最終調用的tcp_connect源碼如下: 

int tcp_connect(struct sock *sk) {
    ......
    // 發送SYN
    err = tcp_transmit_skb(sk, buff, 1, sk->sk_allocation);
    ...
    /* Timer for repeating the SYN until an answer. */
    // 由于是剛建立連接配接,是以其rto是TCP_TIMEOUT_INIT
    inet_csk_reset_xmit_timer(sk, ICSK_TIME_RETRANS,
                inet_csk(sk)->icsk_rto, TCP_RTO_MAX);
    return 0;    
}      

又上面代碼可知,在tcp_connect設定了重傳定時器之後return回了tcp_v4_connect再return到inet_stream_connect。

我們可以采用設定SO_SNDTIMEO來控制connect系統調用的逾時,如下所示:

setsockopt(sockfd, SOL_SOCKET, SO_SNDTIMEO, &timeout, len);      

不設定SO_SNDTIMEO

如果不設定SO_SNDTIMEO,那麼會由tcp重傳定時器在重傳超過設定的時候後逾時,如下圖所示:

我們如何檢視syn重傳次數?:

cat /proc/sys/net/ipv4/tcp_syn_retries      

對于系統調用,connect的逾時時間為:

tcp_syn_retries timeout
1 min(so_sndtimeo,3s)
2 min(so_sndtimeo,7s)
3 min(so_sndtimeo,15s)
4 min(so_sndtimeo,31s)
5 min(so_sndtimeo,63s)

kernel代碼版本細微變化

值得注意的是,linux本身官方釋出的2.6.32源碼對于tcp_syn_retries2的解釋和RFC并不一緻,不同核心小版本上的實驗會有不同的connect timeout表現的原因(有的抓包到的重傳SYN時間間隔為3,6,12......)。

以下為代碼對比:

========================>linux 核心版本2.6.32-431<========================
#define TCP_TIMEOUT_INIT ((unsigned)(1*HZ))    /* RFC2988bis initial RTO value    */

static inline bool retransmits_timed_out(struct sock *sk,
unsigned int boundary,
unsigned int timeout,
bool syn_set)
{
    ......
unsigned int rto_base = syn_set ? TCP_TIMEOUT_INIT : TCP_RTO_MIN;
    ......
    timeout = ((2 << boundary) - 1) * rto_base;
    ......

}
========================>linux 核心版本2.6.32.63<========================
#define TCP_TIMEOUT_INIT ((unsigned)(3*HZ))    /* RFC 1122 initial RTO value    */

static inline bool retransmits_timed_out(struct sock *sk,
unsigned int boundary
{
    ......
    timeout = ((2 << boundary) - 1) * TCP_RTO_MIN;
    ......
}      

另外,tcp_syn_retries重傳次數可以在單個socket中通過setsockopt設定。

發送逾時

        在tcp連接配接建立之後,寫操作可以了解為向對端發送tcp封包的過程。在tcp的實作中,每一段封包都需要有對端的回應,即ACK封包。和連接配接時發送SYN封包一樣,如果超過一定時間沒有收到響應,核心會再次重發該封包。和SYN封包的重試不同的是,linux有另外的參數來控制這個重試次數,即net.ipv4.tcp_retries2,可以通過sysctl net.ipv4.tcp_retries2檢視其值。

另外,這個資料封包重試時間間隔的計算方式也和SYN封包不一樣,由于計算方式比較複雜,這裡就不詳細介紹。

一般linux發行版的net.ipv4.tcp_retries2的預設值為5或者15,對應的逾時時間如下表:

tcp_retries2對端無響應

525.6s-51.2s,根據動态rto定

15924.6s-1044.6s,根據動态rto定

和SYN封包的逾時時間一樣,如果應用層設定了逾時時間,哪麼具體的逾時時間以核心和應用層的逾時時間的最小值為準。

socket的write系統調用最後調用的是tcp_sendmsg,源碼如下所示:

int tcp_sendmsg(struct kiocb *iocb, struct socket *sock, struct msghdr *msg,
        size_t size){
......
    timeo = sock_sndtimeo(sk, flags & MSG_DONTWAIT);
......
while (--iovlen >= 0) {
......
// 此種情況是buffer不夠了
if (copy <= 0) {
    new_segment:
......
if (!sk_stream_memory_free(sk))
              goto wait_for_sndbuf;

          skb = sk_stream_alloc_skb(sk, select_size(sk),sk->sk_allocation);
if (!skb)
              goto wait_for_memory;
        }
......
    }
......
// 這邊等待write buffer有空間
wait_for_sndbuf:
        set_bit(SOCK_NOSPACE, &sk->sk_socket->flags);
wait_for_memory:
if (copied)
            tcp_push(sk, flags & ~MSG_MORE, mss_now, TCP_NAGLE_PUSH);
// 這邊等待timeo長的時間
if ((err = sk_stream_wait_memory(sk, &timeo)) != 0)
            goto do_error;
......
out:
// 如果拷貝了資料,則傳回
if (copied)
        tcp_push(sk, flags, mss_now, tp->nonagle);
    TCP_CHECK_TIMER(sk);
    release_sock(sk);
return copied;        
out_err:
// error的處理
    err = sk_stream_error(sk, flags, err);
    TCP_CHECK_TIMER(sk);
    release_sock(sk);
return err;        
}      

從上面的核心代碼看出,如果socket的write buffer依舊有空間的時候,會立馬傳回,并不會有timeout。但是write buffer不夠的時候,會等待SO_SNDTIMEO的時間(nonblock時候為0)。但是如果SO_SNDTIMEO沒有設定的時候,預設初始化為MAX_SCHEDULE_TIMEOUT,可以認為其逾時時間為無限。那麼其逾時時間會有另一個條件來決定,我們看下sk_stream_wait_memory的源碼:

int sk_stream_wait_memory(struct sock *sk, long *timeo_p){
// 等待socket shutdown或者socket出現err
        sk_wait_event(sk, ¤t_timeo, sk->sk_err ||
                          (sk->sk_shutdown & SEND_SHUTDOWN) ||
                          (sk_stream_memory_free(sk) &&
                          !vm_wait));
}      

在write等待的時候,如果出現socket被shutdown或者socket出現錯誤的時候,則會跳出wait進而傳回錯誤。在不考慮對端shutdown的情況下,出現sk_err的時間其實就是其write的timeout時間,那麼我們看下什麼時候出現sk->sk_err。

SO_SNDTIMEO不設定,write buffer滿之後ack一直不傳回的情況(例如,實體機當機)

實體機當機後,tcp發送msg的時候,ack不會傳回,則會在重傳定時器tcp_retransmit_timer到期後timeout,其重傳到期時間通過tcp_retries2以及TCP_RTO_MIN計算出來。

tcp_retries2的設定位置為:

cat /proc/sys/net/ipv4/tcp_retries2      

SO_SNDTIMEO不設定,write buffer滿之後對端不消費,導緻buffer一直滿的情況

和上面ack逾時有些許不一樣的是,一個邏輯是用TCP_RTO_MIN通過tcp_retries2計算出來的時間。另一個是真的通過重傳超過tcp_retries2次數來time_out,兩者的差別和rto的動态計算有關。但是可以大緻認為是一緻的。

上述邏輯如下圖所示:

write_timeout表格

tcp_retries2 buffer未滿 buffer滿
5 立即傳回 min(SO_SNDTIMEO,(25.6s-51.2s)根據動态rto定
15 立即傳回 min(SO_SNDTIMEO,(924.6s-1044.6s)根據動态rto定

接收逾時

在tcp協定中,讀的操作和寫操作的邏輯是相通的。

tcp連接配接建立後,兩邊的通信無非就是封包的互傳。寫操作是将資料放到tcp封包中發送給對端,然後等待對端響應,一定時間沒有得到響應就是逾時。而讀操作其實就是發送一個讀取資料的封包給對端,然後對端傳回帶有資料的封包,一定時間沒有收到對端的封包則認為逾時。對于tcp協定而言,其實不會分辨他們發送的封包具體是要幹嘛,是以readTimeout的判斷邏輯和writeTimeout基本一樣。它的重傳次數也是由參數net.ipv4.tcp_retries2控制。在應用層面也一般是統一叫socketTimeout。 

read系統調用

socket的read系統調用最終調用的是tcp_recvmsg, 其源碼如下:

int tcp_recvmsg(struct kiocb *iocb, struct sock *sk, struct msghdr *msg,
        size_t len, int nonblock, int flags, int *addr_len)
{
    ......
// 這邊timeo=SO_RCVTIMEO
    timeo = sock_rcvtimeo(sk, nonblock);
    ......
do{
        ......
// 下面這一堆判斷表明,如果出現錯誤,或者已經被CLOSE/SHUTDOWN則跳出循環
if(copied) {
if (sk->sk_err ||
                sk->sk_state == TCP_CLOSE ||
                (sk->sk_shutdown & RCV_SHUTDOWN) ||
                !timeo ||
                signal_pending(current))
break;
        } else {
if (sock_flag(sk, SOCK_DONE))
break;

if (sk->sk_err) {
                copied = sock_error(sk);
break;
            }
// 如果socket shudown跳出
if (sk->sk_shutdown & RCV_SHUTDOWN)
break;
// 如果socket close跳出
if (sk->sk_state == TCP_CLOSE) {
if (!sock_flag(sk, SOCK_DONE)) {
/* This occurs when user tries to read
                     * from never connected socket.
                     */
                    copied = -ENOTCONN;
break;
                }
break;
            }
            .......
        }
        .......

if (copied >= target) {
/* Do not sleep, just process backlog. */
            release_sock(sk);
            lock_sock(sk);
        } else /* 如果沒有讀到target自己數(和水位有關,可以暫認為是1),則等待SO_RCVTIMEO的時間 */
            sk_wait_data(sk, &timeo);    
    } while (len > 0);
    ......
}      

上面的邏輯如下圖所示:

重傳以及探測定時器timeout事件的觸發時機如下圖所示:

如果核心層面ack正常傳回而且對端視窗不為0,僅僅應用層不傳回任何資料,那麼就會無限等待,直到對端有資料或者socket close/shutdown為止,如下圖所示:

很多應用就是基于這個無限逾時來設計的,例如activemq的消費者邏輯。

ReadTimeout逾時表格

C系統調用:

tcp_retries2 對端無響應 對端核心響應正常
5 min(SO_RCVTIMEO,(25.6s-51.2s)根據動态rto定 SO_RCVTIMEO==0?無限,SO_RCVTIMEO)
15 min(SO_RCVTIMEO,(924.6s-1044.6s)根據動态rto定 SO_RCVTIMEO==0?無限,SO_RCVTIMEO)

Java系統調用

tcp_retries2 對端無響應 對端核心響應正常
5 min(SO_TIMEOUT,(25.6s-51.2s)根據動态rto定 SO_TIMEOUT==0?無限,SO_RCVTIMEO
15 min(SO_TIMEOUT,(924.6s-1044.6s)根據動态rto定 SO_TIMEOUT==0?無限,SO_RCVTIMEO

對端實體機當機之後的逾時

對端實體機當機後還依舊有資料發送

對端實體機當機時對端核心也gg了(不會發出任何包通知當機),那麼本端發送任何資料給對端都不會有響應。其逾時時間就由上面讨論的

min(設定的socket逾時[例如SO_TIMEOUT],核心内部的定時器逾時來決定)。

對端實體機當機後沒有資料發送,但在read等待

這時候如果設定了逾時時間timeout,則在timeout後傳回。但是,如果僅僅是在read等待,由于底層沒有資料互動,那麼其無法知道對端是否當機,是以會一直等待。但是,核心會在一個socket兩個小時都沒有資料互動情況下(可設定)啟動keepalive定時器來探測對端的socket。如下圖所示:

大概是2小時11分鐘之後會逾時傳回。keepalive的設定由核心參數指定:

cat /proc/sys/net/ipv4/tcp_keepalive_time 7200 即兩個小時後開始探測
cat /proc/sys/net/ipv4/tcp_keepalive_intvl 75 即每次探測間隔為75s
cat /proc/sys/net/ipv4/tcp_keepalve_probes 9 即一共探測9次      

可以在setsockops中對單獨的socket指定是否啟用keepalive定時器。

對端實體機當機後沒有資料發送,也沒有read等待

和上面同理,也是在keepalive定時器逾時之後,将連接配接close。是以我們可以看到一個不活躍的socket在對端實體機突然當機之後,依舊是ESTABLISHED狀态,過很長一段時間之後才會關閉。

程序當機後的逾時

實體機突然當機和程序宕掉的表現不一樣。一個tcp連接配接建立後,如果一端的實體機突然當機,另外一端是完全不知情的,它會像往常一樣繼續發送相關封包,直到逾時時間到了才傳回。另外,一般作業系統會有機制檢測來釋放該tcp連接配接。而如果隻是程序宕掉,在程序退出的時候,操作會負責回收這個程序所屬的所有tcp連接配接,在這時會向這些tcp連接配接的對端發送FIN封包,表示要關閉連接配接了,這時候對端是可以知道連接配接已經關閉的。(如果程序退出後還收到來自對端的封包,那麼核心會立馬發送reset給對端,進而不會卡住對端的線程資源) 

         是以如果僅僅是對端程序當機的話(程序所在核心會close其所擁有的所有socket),由于fin包的發送,本端核心可以立刻知道目前socket的狀态。如果socket是阻塞的,那麼将會在目前或者下一次write/read系統調用的時候傳回給應用層相應的錯誤。如果是nonblock,那麼會在select/epoll中觸發出對應的事件通知應用層去處理。

如果fin包沒發送到對端,那麼在下一次write/read的時候核心會發送reset包作為回應。

nonblock

總結