連 TCP 這幾個引數都不懂,回去等通知吧!(二)

CrazyZard發表於2020-06-22

接下來,我們一起看看針對 TCP 四次揮手關不連線時,如何優化效能。

在開始之前,我們得先了解四次揮手狀態變遷的過程。

客戶端和服務端雙方都可以主動斷開連線,通常先關閉連線的一方稱為主動方,後關閉連線的一方稱為被動方。

連 TCP 這幾個引數都不懂,回去等通知吧!(二)

可以看到,四次揮手過程只涉及了兩種報文,分別是 FIN 和 ACK

  • FIN 就是結束連線的意思,誰發出 FIN 報文,就表示它將不會再傳送任何資料,關閉這一方向上的傳輸通道;

  • ACK 就是確認的意思,用來通知對方:你方的傳送通道已經關閉;

四次揮手的過程:

  • 當主動方關閉連線時,會傳送 FIN 報文,此時傳送方的 TCP 連線將從 ESTABLISHED 變成 FIN_WAIT1。

  • 當被動方收到 FIN 報文後,核心會自動回覆 ACK 報文,連線狀態將從 ESTABLISHED 變成 CLOSE_WAIT,表示被動方在等待程式呼叫 close 函式關閉連線。

  • 當主動方收到這個 ACK 後,連線狀態由 FIN_WAIT1 變為 FIN_WAIT2,也就是表示主動方的傳送通道就關閉了

  • 當被動方進入 CLOSE_WAIT 時,被動方還會繼續處理資料,等到程式的 read 函式返回 0 後,應用程式就會呼叫 close 函式,進而觸發核心傳送 FIN 報文,此時被動方的連線狀態變為 LAST_ACK。

  • 當主動方收到這個 FIN 報文後,核心會回覆 ACK 報文給被動方,同時主動方的連線狀態由 FIN_WAIT2 變為 TIME_WAIT,在 Linux 系統下大約等待 1 分鐘後,TIME_WAIT 狀態的連線才會徹底關閉

  • 當被動方收到最後的 ACK 報文後,被動方的連線就會關閉

你可以看到,每個方向都需要一個 FIN 和一個 ACK,因此通常被稱為四次揮手

這裡一點需要注意是:

主動關閉連線的,才有 TIME_WAIT 狀態。

主動關閉方和被動關閉方優化的思路也不同,接下來分別說說如何優化他們。

主動方的優化

關閉的連線的方式通常有兩種,分別是 RST 報文關閉和 FIN 報文關閉。

如果程式異常退出了,核心就會傳送 RST 報文來關閉,它可以不走四次揮手流程,是一個暴力關閉連線的方式。

安全關閉連線的方式必須通過四次揮手,它由程式呼叫 closeshutdown 函式發起 FIN 報文(shutdown 引數須傳入 SHUT_WR 或者 SHUT_RDWR 才會傳送 FIN)。

呼叫 close 函式 和 shutdown 函式有什麼區別?

呼叫了 close 函式意味著完全斷開連線,完全斷開不僅指無法傳輸資料,而且也不能傳送資料。此時,呼叫了 close 函式的一方的連線叫做「孤兒連線」,如果你用 netstat -p 命令,會發現連線對應的程式名為空。

使用 close 函式關閉連線是不優雅的。於是,就出現了一種優雅關閉連線的 shutdown 函式,它可以控制只關閉一個方向的連線

連 TCP 這幾個引數都不懂,回去等通知吧!(二)

第二個引數決定斷開連線的方式,主要有以下三種方式:

  • SHUT_RD(0):關閉連線的「讀」這個方向,如果接收緩衝區有已接收的資料,則將會被丟棄,並且後續再收到新的資料,會對資料進行 ACK,然後悄悄地丟棄。也就是說,對端還是會接收到 ACK,在這種情況下根本不知道資料已經被丟棄了。

  • SHUT_WR(1):關閉連線的「寫」這個方向,這就是常被稱為「半關閉」的連線。如果傳送緩衝區還有未傳送的資料,將被立即傳送出去,併傳送一個 FIN 報文給對端。

  • SHUT_RDWR(2):相當於 SHUT_RD 和 SHUT_WR 操作各一次,關閉套接字的讀和寫兩個方向

close 和 shutdown 函式都可以關閉連線,但這兩種方式關閉的連線,不只功能上有差異,控制它們的 Linux 引數也不相同。

FIN_WAIT1 狀態的優化

主動方傳送 FIN 報文後,連線就處於 FIN_WAIT1 狀態,正常情況下,如果能及時收到被動方的 ACK,則會很快變為 FIN_WAIT2 狀態。

但是當遲遲收不到對方返回的 ACK 時,連線就會一直處於 FIN_WAIT1 狀態。此時,核心會定時重發 FIN 報文,其中重發次數由 tcp_orphan_retries 引數控制(注意,orphan 雖然是孤兒的意思,該引數卻不只對孤兒連線有效,事實上,它對所有 FIN_WAIT1 狀態下的連線都有效),預設值是 0。

連 TCP 這幾個引數都不懂,回去等通知吧!(二)

你可能會好奇,這 0 表示幾次?實際上當為 0 時,特指 8 次,從下面的核心原始碼可知

連 TCP 這幾個引數都不懂,回去等通知吧!(二)

如果 FIN_WAIT1 狀態連線很多,我們就需要考慮降低 tcp_orphan_retries 的值,當重傳次數超過 tcp_orphan_retries 時,連線就會直接關閉掉。

對於普遍正常情況時,調低 tcp_orphan_retries 就已經可以了。如果遇到惡意攻擊,FIN 報文根本無法傳送出去,這由 TCP 兩個特性導致的:

  • 首先,TCP 必須保證報文是有序傳送的,FIN 報文也不例外,當傳送緩衝區還有資料沒有傳送時,FIN 報文也不能提前傳送。

  • 其次,TCP 有流量控制功能,當接收方接收視窗為 0 時,傳送方就不能再傳送資料。所以,當攻擊者下載大檔案時,就可以通過接收視窗設為 0 ,這就會使得 FIN 報文都無法傳送出去,那麼連線會一直處於 FIN_WAIT1 狀態。

解決這種問題的方法,是調整 tcp_max_orphans 引數,它定義了「孤兒連線」的最大數量

連 TCP 這幾個引數都不懂,回去等通知吧!(二)

當程式呼叫了 close 函式關閉連線,此時連線就會是「孤兒連線」,因為它無法在傳送和接收資料。Linux 系統為了防止孤兒連線過多,導致系統資源長時間被佔用,就提供了 tcp_max_orphans 引數。如果孤兒連線數量大於它,新增的孤兒連線將不再走四次揮手,而是直接傳送 RST 復位報文強制關閉。

FIN_WAIT2 狀態的優化
當主動方收到 ACK 報文後,會處於 FIN_WAIT2 狀態,就表示主動方的傳送通道已經關閉,接下來將等待對方傳送 FIN 報文,關閉對方的傳送通道。

這時,如果連線是用 shutdown 函式關閉的,連線可以一直處於 FIN_WAIT2 狀態,因為它可能還可以傳送或接收資料。但對於 close 函式關閉的孤兒連線,由於無法在傳送和接收資料,所以這個狀態不可以持續太久,而 tcp_fin_timeout 控制了這個狀態下連線的持續時長,預設值是 60 秒:

連 TCP 這幾個引數都不懂,回去等通知吧!(二)

它意味著對於孤兒連線(呼叫 close 關閉的連線),如果在 60 秒後還沒有收到 FIN 報文,連線就會直接關閉。

這個 60 秒不是隨便決定的,它與 TIME_WAIT 狀態持續的時間是相同的,後面我們在來說說為什麼是 60 秒。

TIME_WAIT 狀態的優化

TIME_WAIT 是主動方四次揮手的最後一個狀態,也是最常遇見的狀態。

當收到被動方發來的 FIN 報文後,主動方會立刻回覆 ACK,表示確認對方的傳送通道已經關閉,接著就處於 TIME_WAIT 狀態。在 Linux 系統,TIME_WAIT 狀態會持續 60 秒後才會進入關閉狀態。

TIME_WAIT 狀態的連線,在主動方看來確實快已經關閉了。然後,被動方沒有收到 ACK 報文前,還是處於 LAST_ACK 狀態。如果這個 ACK 報文沒有到達被動方,被動方就會重發 FIN 報文。重發次數仍然由前面介紹過的 tcp_orphan_retries 引數控制。

TIME-WAIT 的狀態尤其重要,主要是兩個原因:

  • 防止具有相同「四元組」的「舊」資料包被收到;

  • 保證「被動關閉連線」的一方能被正確的關閉,即保證最後的 ACK 能讓被動關閉方接收,從而幫助其正常關閉;

原因一:防止舊連線的資料包

TIME-WAIT 的一個作用是防止收到歷史資料,從而導致資料錯亂的問題。

假設 TIME-WAIT 沒有等待時間或時間過短,被延遲的資料包抵達後會發生什麼呢?

連 TCP 這幾個引數都不懂,回去等通知吧!(二)

  • 如上圖黃色框框服務端在關閉連線之前傳送的 SEQ = 301 報文,被網路延遲了。

  • 這時有相同埠的 TCP 連線被複用後,被延遲的 SEQ = 301 抵達了客戶端,那麼客戶端是有可能正常接收這個過期的報文,這就會產生資料錯亂等嚴重的問題。

所以,TCP 就設計出了這麼一個機制,經過 2MSL 這個時間,足以讓兩個方向上的資料包都被丟棄,使得原來連線的資料包在網路中都自然消失,再出現的資料包一定都是新建立連線所產生的。

原因二:保證連線正確關閉

TIME-WAIT 的另外一個作用是等待足夠的時間以確保最後的 ACK 能讓被動關閉方接收,從而幫助其正常關閉。

假設 TIME-WAIT 沒有等待時間或時間過短,斷開連線會造成什麼問題呢?

連 TCP 這幾個引數都不懂,回去等通知吧!(二)

  • 如上圖紅色框框客戶端四次揮手的最後一個 ACK 報文如果在網路中被丟失了,此時如果客戶端 TIME-WAIT 過短或沒有,則就直接進入了 CLOSE 狀態了,那麼服務端則會一直處在 LASE-ACK 狀態。

  • 當客戶端發起建立連線的 SYN 請求報文後,服務端會傳送 RST 報文給客戶端,連線建立的過程就會被終止。

我們再回過頭來看看,為什麼 TIME_WAIT 狀態要保持 60 秒呢?這與孤兒連線 FIN_WAIT2 狀態預設保留 60 秒的原理是一樣的,因為這兩個狀態都需要保持 2MSL 時長。MSL 全稱是 Maximum Segment Lifetime,它定義了一個報文在網路中的最長生存時間(報文每經過一次路由器的轉發,IP 頭部的 TTL 欄位就會減 1,減到 0 時報文就被丟棄,這就限制了報文的最長存活時間)。

為什麼是 2 MSL 的時長呢?這其實是相當於至少允許報文丟失一次。比如,若 ACK 在一個 MSL 內丟失,這樣被動方重發的 FIN 會在第 2 個 MSL 內到達,TIME_WAIT 狀態的連線可以應對。

為什麼不是 4 或者 8 MSL 的時長呢?你可以想象一個丟包率達到百分之一的糟糕網路,連續兩次丟包的概率只有萬分之一,這個概率實在是太小了,忽略它比解決它更具價效比。

因此,TIME_WAIT 和 FIN_WAIT2 狀態的最大時長都是 2 MSL,由於在 Linux 系統中,MSL 的值固定為 30 秒,所以它們都是 60 秒。

雖然 TIME_WAIT 狀態有存在的必要,但它畢竟會消耗系統資源。如果發起連線一方的 TIME_WAIT 狀態過多,佔滿了所有埠資源,則會導致無法建立新連線。

  • 客戶端受埠資源限制:如果客戶端 TIME_WAIT 過多,就會導致埠資源被佔用,因為埠就65536個,被佔滿就會導致無法建立新的連線;

  • 服務端受系統資源限制:由於一個四元組表示TCP連線,理論上服務端可以建立很多連線,服務端確實只監聽一個埠 但是會把連線扔給處理執行緒,所以理論上監聽的埠可以繼續監聽。但是執行緒池處理不了那麼多一直不斷的連線了。所以當服務端出現大量 TIME_WAIT 時,系統資源被佔滿時,會導致處理不過來新的連線;

另外,Linux 提供了 tcp_max_tw_buckets 引數,當 TIME_WAIT 的連線數量超過該引數時,新關閉的連線就不再經歷 TIME_WAIT 而直接關閉:

連 TCP 這幾個引數都不懂,回去等通知吧!(二)

當伺服器的併發連線增多時,相應地,同時處於 TIME_WAIT 狀態的連線數量也會變多,此時就應當調大 tcp_max_tw_buckets 引數,減少不同連線間資料錯亂的概率。

tcp_max_tw_buckets 也不是越大越好,畢竟記憶體和埠都是有限的。

有一種方式可以在建立新連線時,複用處於 TIME_WAIT 狀態的連線,那就是開啟 tcp_tw_reuse 引數。但是需要注意,該引數是隻用於客戶端(建立連線的發起方),因為是在呼叫 connect() 時起作用的,而對於服務端(被動連線方)是沒有用的。

連 TCP 這幾個引數都不懂,回去等通知吧!(二)
cp_tw_reuse 從協議角度理解是安全可控的,可以複用處於 TIME_WAIT 的埠為新的連線所用。

什麼是協議角度理解的安全可控呢?主要有兩點:

  • 只適用於連線發起方,也就是 C/S 模型中的客戶端;

  • 對應的 TIME_WAIT 狀態的連線建立時間超過 1 秒才可以被複用。

使用這個選項,還有一個前提,需要開啟對 TCP 時間戳的支援(對方也要開啟 ):

連 TCP 這幾個引數都不懂,回去等通知吧!(二)

由於引入了時間戳,它能帶來了些好處:

  • 我們在前面提到的 2MSL 問題就不復存在了,因為重複的資料包會因為時間戳過期被自然丟棄;

  • 同時,它還可以防止序列號繞回,也是因為重複的資料包會由於時間戳過期被自然丟棄;

老版本的 Linux 還提供了 tcp_tw_recycle 引數,但是當開啟了它,就有兩個坑:

  • Linux 會加快客戶端和服務端 TIME_WAIT 狀態的時間,也就是它會使得 TIME_WAIT 狀態會小於 60 秒,很容易導致資料錯亂;

  • 另外,Linux 會丟棄所有來自遠端時間戳小於上次記錄的時間戳(由同一個遠端傳送的)的任何資料包。就是說要使用該選項,則必須保證資料包的時間戳是單調遞增的。那麼,問題在於,此處的時間戳並不是我們通常意義上面的絕對時間,而是一個相對時間。很多情況下,我們是沒法保證時間戳單調遞增的,比如使用了 NAT,LVS 等情況;

所以,不建議設定為 1 ,建議關閉它:

連 TCP 這幾個引數都不懂,回去等通知吧!(二)

在 Linux 4.12 版本後,Linux 核心直接取消了這一引數。

另外,我們可以在程式中設定 socket 選項,來設定呼叫 close 關閉連線行為。

連 TCP 這幾個引數都不懂,回去等通知吧!(二)

如果l_onoff為非 0, 且l_linger值為 0,那麼呼叫close後,會立該傳送一個 RST 標誌給對端,該 TCP 連線將跳過四次揮手,也就跳過了 TIME_WAIT 狀態,直接關閉。

但這為跨越 TIME_WAIT 狀態提供了一個可能,不過是一個非常危險的行為,不值得提倡。

被動方的優化

當被動方收到 FIN 報文時,核心會自動回覆 ACK,同時連線處於 CLOSE_WAIT 狀態,顧名思義,它表示等待應用程式呼叫 close 函式關閉連線。

核心沒有權利替代程式去關閉連線,因為如果主動方是通過 shutdown 關閉連線,那麼它就是想在半關閉連線上接收資料或傳送資料。因此,Linux 並沒有限制 CLOSE_WAIT 狀態的持續時間。

當然,大多數應用程式並不使用 shutdown 函式關閉連線。所以,當你用 netstat 命令發現大量 CLOSE_WAIT 狀態。就需要排查你的應用程式,因為可能因為應用程式出現了 Bug,read 函式返回 0 時,沒有呼叫 close 函式。

處於 CLOSE_WAIT 狀態時,呼叫了 close 函式,核心就會發出 FIN 報文關閉傳送通道,同時連線進入 LAST_ACK 狀態,等待主動方返回 ACK 來確認連線關閉。

如果遲遲收不到這個 ACK,核心就會重發 FIN 報文,重發次數仍然由 tcp_orphan_retries 引數控制,這與主動方重發 FIN 報文的優化策略一致。

還有一點我們需要注意的,如果被動方迅速呼叫 close 函式,那麼被動方的 ACK 和 FIN 有可能在一個報文中傳送,這樣看起來,四次揮手會變成三次揮手,這只是一種特殊情況,不用在意。

如果連線雙方同時關閉連線,會怎麼樣?

由於 TCP 是雙全工的協議,所以是會出現兩方同時關閉連線的現象,也就是同時傳送了 FIN 報文。

此時,上面介紹的優化策略仍然適用。兩方傳送 FIN 報文時,都認為自己是主動方,所以都進入了 FIN_WAIT1 狀態,FIN 報文的重發次數仍由 tcp_orphan_retries 引數控制。

連 TCP 這幾個引數都不懂,回去等通知吧!(二)

接下來,雙方在等待 ACK 報文的過程中,都等來了 FIN 報文。這是一種新情況,所以連線會進入一種叫做 CLOSING 的新狀態,它替代了 FIN_WAIT2 狀態。接著,雙方核心回覆 ACK 確認對方傳送通道的關閉後,進入 TIME_WAIT 狀態,等待 2MSL 的時間後,連線自動關閉。

針對 TCP 四次揮手的優化,我們需要根據主動方和被動方四次揮手狀態變化來調整系統 TCP 核心引數。

連 TCP 這幾個引數都不懂,回去等通知吧!(二)

主動方的優化
主動發起 FIN 報文斷開連線的一方,如果遲遲沒收到對方的 ACK 回覆,則會重傳 FIN 報文,重傳的次數由 tcp_orphan_retries 引數決定。

當主動方收到 ACK 報文後,連線就進入 FIN_WAIT2 狀態,根據關閉的方式不同,優化的方式也不同:

  • 如果這是 close 函式關閉的連線,那麼它就是孤兒連線。如果 tcp_fin_timeout 秒內沒有收到對方的 FIN 報文,連線就直接關閉。同時,為了應對孤兒連線佔用太多的資源,tcp_max_orphans 定義了最大孤兒連線的數量,超過時連線就會直接釋放。

  • 反之是 shutdown 函式關閉的連線,則不受此引數限制;

當主動方接收到 FIN 報文,並返回 ACK 後,主動方的連線進入 TIME_WAIT 狀態。這一狀態會持續 1 分鐘,為了防止 TIME_WAIT 狀態佔用太多的資源,tcp_max_tw_buckets 定義了最大數量,超過時連線也會直接釋放。

當 TIME_WAIT 狀態過多時,還可以通過設定 tcp_tw_reusetcp_timestamps 為 1 ,將 TIME_WAIT 狀態的埠複用於作為客戶端的新連線,注意該引數只適用於客戶端。

被動方的優化
被動關閉的連線方應對非常簡單,它在回覆 ACK 後就進入了 CLOSE_WAIT 狀態,等待程式呼叫 close 函式關閉連線。因此,出現大量 CLOSE_WAIT 狀態的連線時,應當從應用程式中找問題。

當被動方傳送 FIN 報文後,連線就進入 LAST_ACK 狀態,在未等到 ACK 時,會在tcp_orphan_retries 引數的控制下重發 FIN 報文。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

快樂就是解決一個又一個的問題!

相關文章