從linux原始碼看socket(tcp)的timeout
阿新 • • 發佈:2020-06-10
# 從linux原始碼看socket(tcp)的timeout
## 前言
網路程式設計中超時時間是一個重要但又容易被忽略的問題,對其的設定需要仔細斟酌。在經歷了數次物理機宕機之後,筆者詳細的考察了在網路程式設計(tcp)中的各種超時設定,於是就有了本篇博文。本文大部分討論的是socket設定為block的情況,即setNonblock(false),僅在最後提及了nonblock socket(本文基於linux 2.6.32-431核心)。
## connectTimeout
在討論connectTimeout之前,讓我們先看下java和C語言對於socket connect呼叫的函式簽名:
```
java:
// 函式呼叫中攜帶有超時時間
public void connect(SocketAddress endpoint, int timeout) ;
C語言:
// 函式呼叫中並不攜帶超時時間
int connect(int sockfd, const struct sockaddr * sockaddr, socklen_t socklent)
```
作業系統提供的connect系統呼叫並沒有提供timeout的引數設定而java卻有,我們先考察一下原生系統呼叫的超時策略。
### connect系統呼叫
我們觀察一下此係統呼叫的kernel原始碼,呼叫棧如下所示:
```
connect[使用者態]
|->SYSCALL_DEFINE3(connect)[核心態]
|->sock->ops->connect
```
由於我們考察的是tcp的connect,其socket的內部結構如下圖所示:
![](https://oscimg.oschina.net/oscnet/up-59900e9bb1796ee054b0a28e52e6684e9fe.png)
最終呼叫的是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。我們繼續考察:
```
int inet_stream_connect(struct socket *sock, struct sockaddr *uaddr,
int addr_len, int flags)
{
......
// tcp_v4_connect=>tcp_connect
err = sk->sk_prot->connect(sk, uaddr, addr_len);
// 這邊用的是sk->sk_sndtimeo
timeo = sock_sndtimeo(sk, flags & O_NONBLOCK);
......
inet_wait_for_connect(sk, timeo));
......
out:
release_sock(sk);
return err;
sock_error:
err = sock_error(sk) ? : -ECONNABORTED;
sock->state = SS_UNCONNECTED;
if (sk->sk_prot->disconnect(sk, flags))
sock->state = SS_DISCONNECTING;
goto out
}
```
由上面程式碼可見,可以採用設定SO\_SNDTIMEO來控制connect系統呼叫的超時,如下所示:
```
setsockopt(sockfd, SOL_SOCKET, SO_SNDTIMEO, &timeout, len);
```
#### 不設定SO\_SNDTIMEO
如果不設定SO\_SNDTIMEO,那麼會由tcp重傳定時器在重傳超過設定的時候後超時,如下圖所示:
![](https://oscimg.oschina.net/oscnet/up-76e0d5c60919bde35ee06e38f8adb45e7f9.png)
這個syn重傳的次數由:
```
cat /proc/sys/net/ipv4/tcp_syn_retries 筆者機器上是5
```
來決定。那麼我們就來看一下這個重傳到底是多長時間:
```
tcp_connect中:
// 設定的初始超時時間為icsk_rto=TCP_TIMEOUT_INIT為1s
inet_csk_reset_xmit_timer(sk, ICSK_TIME_RETRANS,
inet_csk(sk)->icsk_rto, TCP_RTO_MAX);
```
其重傳定時器的回掉函式為tcp\_retransmit\_timer:
```
void tcp_retransmit_timer(struct sock *sk)
{
......
// 檢測是否超時
if (tcp_write_timeout(sk))
goto out;
......
// icsk_rto = icsk_rto * 2,由於syn階段,所以isck_rto不會由於網路傳輸而改變
// 重傳的時候會以1,2,4,8指數遞增
icsk->icsk_rto = min(icsk->icsk_rto << 1, TCP_RTO_MAX);
// 重設timer
inet_csk_reset_xmit_timer(sk, ICSK_TIME_RETRANS, icsk->icsk_rto, TCP_RTO_MAX);
out:;
}
```
而計算tcp\_write\_timeout的邏輯則是在這篇blog中已經詳細描述過,
```
https://my.oschina.net/alchemystar/blog/1936433
```
只不過在connect時刻,重傳的計算以TCP\_TIMEOUT\_INIT為單位進行計算。而ESTABLISHED(read/write)時刻,重傳以TCP\_RTO\_MIN進行計算。那麼根據這段重傳邏輯,我們就可以計算出不同tcp\_syn\_retries最終表現的超時時間。如下圖所示:
![](https://oscimg.oschina.net/oscnet/up-22bc1461d14944bb9cc5fe5bef605fedac7.png)
那麼整理下表格,對於系統呼叫,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並不一致(至少筆者閱讀的程式碼如此,這個細微的變化困擾了筆者好久,筆者下載了和機器對應的核心版本後才發現程式碼改了)。而redhat釋出的2.6.32-431已經修復了這個問題(不清楚具體哪個小版本修改的),並將初始RTO設定為1s(官方2.6.32為3s)。這也是,不同核心小版本上的實驗會有不同的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設定。
### JAVA connect API
現在我們考察下java的connect api,其connect最終呼叫下面的程式碼:
```
Java_java_net_PlainSocketImpl_socketConnect(...){
if (timeout <= 0) {
......
connect_rv = NET_Connect(fd, (struct sockaddr *)&him, len);
.....
}else{
// 如果timeout > 0 ,則設定為nonblock模式
SET_NONBLOCKING(fd);
/* no need to use NET_Connect as non-blocking */
connect_rv = connect(fd, (struct sockaddr *)&him, len);
/*
* 這邊用系統呼叫select來模擬阻塞呼叫超時
*/
while (1) {
......
struct timeval t;
t.tv_sec = timeout / 1000;
t.tv_usec = (timeout % 1000) * 1000;
connect_rv = NET_Select(fd+1, 0, &wr, &ex, &t);
......
}
......
// 重新設定為阻塞模式
SET_BLOCKING(fd);
......
}
}
```
其和connect系統呼叫的不同點是,在timeout為0的時候,走預設的系統呼叫不設定超時時間的邏輯。在timeout>0時,將socket設定為非阻塞,然後用select系統呼叫去模擬超時,而沒有走linux本身的超時邏輯,如下圖所示:
![](https://oscimg.oschina.net/oscnet/up-3cc271ca5a7e95b18e48b6b60e79a911ea1.png)
由於沒有java並沒有設定so\_sndtimeo的選項,所以在timeout為0的時候,直接就通過重傳次數來控制超時時間。而在呼叫connect時設定了timeout(不為0)的時候,超時時間如下表格所示:
|tcp\_syn\_retries|timeout|
| --- | :--- |
|1|min(timeout,3s)|
|2|min(timeout,7s)|
|3|min(timeout,15s)|
|4|min(timeout,31s)|
|5|min(timeout,63s)|
## socketTimeout
### write系統呼叫的超時時間
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計算出來。其原始碼可見筆者的blog:
```
https://my.oschina.net/alchemystar/blog/1936433
```
tcp\_retries2的設定位置為:
```
cat /proc/sys/net/ipv4/tcp_retries2 筆者機器上是5,預設是15
```
#### SO_SNDTIMEO不設定,write buffer滿之後對端不消費,導致buffer一直滿的情況
和上面ack超時有些許不一樣的是,一個邏輯是用TCP\_RTO\_MIN通過tcp\_retries2計算出來的時間。另一個是真的通過重傳超過tcp\_retries2次數來time\_out,兩者的區別和rto的動態計算有關。但是可以大致認為是一致的。
## 上述邏輯如下圖所示:
![](https://oscimg.oschina.net/oscnet/up-f02f4126c3b549ff4075392bee425e72d23.png)
### write_timeout表格
|tcp\_retries2|buffer未滿|buffer滿|
| --- | :--- |:---|
|5|立即返回|min(SO\_SNDTIMEO,(25.6s-51.2s)根據動態rto定|
|15|立即返回|min(SO\_SNDTIMEO,(924.6s-1044.6s)根據動態rto定|
### java的SocketOutputStream的sockWrite0超時時間
java的sockWrite0沒有設定超時時間的地方,同時也沒有設定過SO\_SNDTIMEOUT,其直接呼叫了系統呼叫,所以其超時時間和write系統呼叫保持一致。
## readTimeout
ReadTimeout可能是最容易導致問題的地方。我們先看下系統呼叫的原始碼:
### 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);
......
}
```
上面的邏輯如下圖所示:
![](https://oscimg.oschina.net/oscnet/up-b431d593ea5bb7b7f473a84ed775a2e9e85.png)
重傳以及探測定時器timeout事件的觸發時機如下圖所示:
![](https://oscimg.oschina.net/oscnet/up-c6832599cec6d81b23385925379b64500cf.png)
如果核心層面ack正常返回而且對端視窗不為0,僅僅應用層不返回任何資料,那麼就會無限等待,直到對端有資料或者socket close/shutdown為止,如下圖所示:
![](https://oscimg.oschina.net/oscnet/up-3fde656a742968de3f2e228cbac6eeb90bc.png)
很多應用就是基於這個無限超時來設計的,例如activemq的消費者邏輯。
### java的SocketInputStream的sockRead0超時時間
java的超時時間由SO_TIMOUT決定,而linux的socket並沒有這個選項。其sockRead0和上面的java connect一樣,在SO\_TIMEOUT>0的時候依舊是由nonblock socket模擬,在此就不再贅述了。
### 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||
## 對端物理機宕機之後的timeout
### 對端物理機宕機後還依舊有資料傳送
對端物理機宕機時對端核心也gg了(不會發出任何包通知宕機),那麼本端傳送任何資料給對端都不會有響應。其超時時間就由上面討論的
min(設定的socket超時[例如SO_TIMEOUT],核心內部的定時器超時來決定)。
### 對端物理機宕機後沒有資料傳送,但在read等待
這時候如果設定了超時時間timeout,則在timeout後返回。但是,如果僅僅是在read等待,由於底層沒有資料互動,那麼其無法知道對端是否宕機,所以會一直等待。但是,核心會在一個socket兩個小時都沒有資料互動情況下(可設定)啟動keepalive定時器來探測對端的socket。如下圖所示:
![](https://oscimg.oschina.net/oscnet/up-442257698dc7b0243f1a356a55ea96e6aa2.JPEG)
大概是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定時器(java也可以)。
### 對端物理機宕機後沒有資料傳送,也沒有read等待
和上面同理,也是在keepalive定時器超時之後,將連線close。所以我們可以看到一個不活躍的socket在對端物理機突然宕機之後,依舊是ESTABLISHED狀態,過很長一段時間之後才會關閉。
## 程序宕後的超時
如果僅僅是對端程序宕機的話(程序所在核心會close其所擁有的所有socket),由於fin包的傳送,本端核心可以立刻知道當前socket的狀態。如果socket是阻塞的,那麼將會在當前或者下一次write/read系統呼叫的時候返回給應用層相應的錯誤。如果是nonblock,那麼會在select/epoll中觸發出對應的事件通知應用層去處理。
如果fin包沒傳送到對端,那麼在下一次write/read的時候核心會發送reset包作為迴應。
## nonblock
設定為nonblock=true後,由於read/write都是立刻返回,且通過select/epoll等處理重傳超時/probe超時/keep alive超時/socket close等事件,所以根據應用層程式碼決定其超時特性。定時器超時事件發生的時間如上面幾小節所述,和是否nonblock無關。nonblock的程式設計模式可以讓應用層對這些事件做出響應。
# 總結
網路程式設計中超時時間是個重要但又容易被忽略的問題,這個問題只有在遇到物理機宕機等平時遇不到的現象時候才會凸顯。筆者在經歷數次物理機宕機之後才好好的研究了一番,希望本篇文章可以對讀者在以後遇到類似超時問題時有所幫助。
## 公眾號
關注筆者公眾號,獲取更多幹貨文章:
![](https://oscimg.oschina.net/oscnet/up-03e8bdd592b3eb9dec0a50fa5ff56192df0.JPEG)