TCP漫談之keepalive和time_wait

宜信技術學院發表於2020-04-07

TCP是一個有狀態通訊協議,所謂的有狀態是指通訊過程中通訊的雙方各自維護連線的狀態。

一、TCP keepalive

先簡單回顧一下TCP連線建立和斷開的整個過程。(這裡主要考慮主流程,關於丟包、擁塞、視窗、失敗重試等情況後面詳細討論。)

首先是客戶端傳送syn(Synchronize Sequence Numbers:同步序列編號)包給服務端,告訴服務端我要連線你,syn包裡面主要攜帶了客戶端的seq序列號;服務端回發一個syn+ack,其中syn包和客戶端原理類似,只不過攜帶的是服務端的seq序列號,ack包則是確認客戶端允許連線;最後客戶端再次傳送一個ack確認接收到服務端的syn包。這樣客戶端和服務端就可以建立連線了。整個流程稱為三次握手。

建立連線後,客戶端或者服務端便可以透過已建立的socket連線傳送資料,對端接收資料後,便可以透過ack確認已經收到資料。

資料交換完畢後,通常是客戶端便可以傳送FIN包,告訴另一端我要斷開了;另一端先透過ack確認收到FIN包,然後傳送FIN包告訴客戶端我也關閉了;最後客戶端回應ack確認連線終止。整個流程成為四次揮手。

TCP的效能經常為大家所詬病,除了TCP+IP額外的header以外,它建立連線需要三次握手,關閉連線需要四次揮手。如果只是傳送很少的資料,那麼傳輸的有效資料是非常少的。

是不是建立一次連線後續可以繼續複用呢?的確可以這樣做,但這又帶來另一個問題,如果連線一直不釋放,埠被佔滿了咋辦。為此引入了今天討論的第一個話題TCP keepalive。所謂的TCP keepalive是指TCP連線建立後會透過keepalive的方式一直保持,不會在資料傳輸完成後立刻中斷,而是透過keepalive機制檢測連線狀態。

Linux控制keepalive有三個引數:保活時間net.ipv4.tcp_keepalive_time、保活時間間隔net.ipv4.tcp_keepalive_intvl、保活探測次數net.ipv4.tcp_keepalive_probes,預設值分別是 7200 秒(2 小時)、75 秒和 9 次探測。如果使用 TCP 自身的 keep-Alive 機制,在 Linux 系統中,最少需要經過 2 小時 + 9*75 秒後斷開。譬如我們SSH登入一臺伺服器後可以看到這個TCP的keepalive時間是2個小時,並且會在2個小時後傳送探測包,確認對端是否處於連線狀態。

之所以會討論TCP的keepalive,是因為發現服器上有洩露的TCP連線:

# ll /proc/11516/fd/10
lrwx------ 1 root root 64 Jan  3 19:04 /proc/11516/fd/10 -> socket:[1241854730]
# date
Sun Jan  5 17:39:51 CST 2020

已經建立連線兩天,但是對方已經斷開了(非正常斷開)。由於使用了比較老的go(1.9之前版本有問題)導致連線沒有釋放。

解決這類問題,可以藉助TCP的keepalive機制。新版go語言支援在建立連線的時候設定keepalive時間。首先檢視網路包中建立TCP連線的DialContext方法中

if tc, ok := c.(*TCPConn); ok && d.KeepAlive >= 0 {
   setKeepAlive(tc.fd, true)
   ka := d.KeepAlive
   if d.KeepAlive == 0 {
      ka = defaultTCPKeepAlive
   }
   setKeepAlivePeriod(tc.fd, ka)
   testHookSetKeepAlive(ka)
}

其中defaultTCPKeepAlive是15s。如果是HTTP連線,使用預設client,那麼它會將keepalive時間設定成30s。

var DefaultTransport RoundTripper = &Transport{
   Proxy: ProxyFromEnvironment,
   DialContext: (&net.Dialer{
      Timeout:   30 * time.Second,
      KeepAlive: 30 * time.Second,
      DualStack: true,
   }).DialContext,
   ForceAttemptHTTP2:     true,
   MaxIdleConns:          100,
   IdleConnTimeout:       90 * time.Second,
   TLSHandshakeTimeout:   10 * time.Second,
   ExpectContinueTimeout: 1 * time.Second,
}

下面透過一個簡單的demo測試一下,程式碼如下:

func main() {
   wg := &sync.WaitGroup{}
   c := http.DefaultClient
   for i := 0; i < 2; i++ {
      wg.Add(1)
      go func() {
         defer wg.Done()
         for {
            r, err := c.Get(")
            if err != nil {
               fmt.Println(err)
               return
            }
            _, err = ioutil.ReadAll(r.Body)
            r.Body.Close()
            if err != nil {
               fmt.Println(err)
               return
            }
            time.Sleep(30 * time.Millisecond)
         }
      }()
   }
   wg.Wait()
}

執行程式後,可以檢視連線。初始設定keepalive為30s。

然後不斷遞減,至0後,又會重新獲取30s。

整個過程可以透過tcpdump抓包獲取。

# tcpdump -i bond0 port 35832 -nvv -A

其實很多應用並非是透過TCP的keepalive機制探活的,因為預設的兩個多小時檢查時間對於很多實時系統是完全沒法滿足的,通常的做法是透過應用層的定時監測,如PING-PONG機制(就像打乒乓球,一來一回),應用層每隔一段時間傳送心跳包,如websocket的ping-pong。

二、TCP Time_wait

第二個希望和大家分享的話題是TCP的Time_wait狀態。、

為啥需要time_wait狀態呢?為啥不直接進入closed狀態呢?直接進入closed狀態能更快地釋放資源給新的連線使用了,而不是還需要等待2MSL(Linux預設)時間。

有兩個原因:

一是為了防止“迷路的資料包”,如下圖所示,如果在第一個連線裡第三個資料包由於底層網路故障延遲送達。等待新的連線建立後,這個遲到的資料包才到達,那麼將會導致接收資料紊亂。

第二個原因則更加簡單,如果因為最後一個ack丟失,那麼對方將一直處於last ack狀態,如果此時重新發起新的連線,對方將返回RST包拒絕請求,將會導致無法建立新連線。

為此設計了time_wait狀態。在高併發情況下,如果能將time_wait的TCP複用, time_wait複用是指可以將處於time_wait狀態的連線重複利用起來。從time_wait轉化為established,繼續複用。Linux核心透過net.ipv4.tcp_tw_reuse引數控制是否開啟time_wait狀態複用。

讀者可能很好奇,之前不是說time_wait設計之初是為了解決上面兩個問題的嗎?如果直接複用不是反而會導致上面兩個問題出現嗎?這裡先介紹Linux預設開啟的一個TCP時間戳策略net.ipv4.tcp_timestamps = 1。

時間戳開啟後,針對第一個迷路資料包的問題,由於晚到資料包的時間戳過早會被直接丟棄,不會導致新連線資料包紊亂;針對第二個問題,開啟reuse後,當對方處於last-ack狀態時,傳送syn包會返回FIN,ACK包,然後客戶端傳送RST讓服務端關閉請求,從而客戶端可以再次傳送syn建立新的連線。

最後還需要提醒讀者的是,Linux 4.1核心版本之前除了tcp_tw_reuse以外,還有一個引數tcp_tw_recycle,這個引數就是強制回收time_wait狀態的連線,它會導致NAT環境丟包,所以不建議開啟。

作者:陳曉宇

作者著作《雲端計算那些事兒:從IaaS到PaaS進階》


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69918724/viewspace-2684864/,如需轉載,請註明出處,否則將追究法律責任。

相關文章