天天看點

TCP 三次握手和四次揮手的面試題(2023最新版)

作者:小小怪下士的架構攻略

-----

任 TCP 虐我千百遍,我仍待 TCP 如初戀。

巨巨巨巨長的提綱,發車!發車!

TCP 三次握手和四次揮手的面試題(2023最新版)

img

TCP 基本認識

TCP 頭格式有哪些?

我們先來看看 TCP 頭的格式,标注顔色的表示與本文關聯比較大的字段,其他字段不做詳細闡述。

TCP 三次握手和四次揮手的面試題(2023最新版)

TCP 頭格式

序列号:在建立連接配接時由計算機生成的随機數作為其初始值,通過 SYN 包傳給接收端主機,每發送一次資料,就「累加」一次該「資料位元組數」的大小。用來解決網絡包亂序問題。

确認應答号:指下一次「期望」收到的資料的序列号,發送端收到這個确認應答以後可以認為在這個序号以前的資料都已經被正常接收。用來解決丢包的問題。

控制位:

  • ACK:該位為 1 時,「确認應答」的字段變為有效,TCP 規定除了最初建立連接配接時的 SYN 包之外該位必須設定為 1 。
  • RST:該位為 1 時,表示 TCP 連接配接中出現異常必須強制斷開連接配接。
  • SYN:該位為 1 時,表示希望建立連接配接,并在其「序列号」的字段進行序列号初始值的設定。
  • FIN:該位為 1 時,表示今後不會再有資料發送,希望斷開連接配接。當通信結束希望斷開連接配接時,通信雙方的主機之間就可以互相交換 FIN 位為 1 的 TCP 段。

#為什麼需要 TCP 協定?TCP 工作在哪一層?

IP 層是「不可靠」的,它不保證網絡包的傳遞、不保證網絡包的按序傳遞、也不保證網絡包中的資料的完整性。

TCP 三次握手和四次揮手的面試題(2023最新版)

OSI 參考模型與 TCP/IP 的關系

如果需要保障網絡資料包的可靠性,那麼就需要由上層(傳輸層)的 TCP 協定來負責。

因為 TCP 是一個工作在傳輸層的可靠資料傳輸的服務,它能確定接收端接收的網絡包是無損壞、無間隔、非備援和按序的。

#什麼是 TCP ?

TCP 是面向連接配接的、可靠的、基于位元組流的傳輸層通信協定。

TCP 三次握手和四次揮手的面試題(2023最新版)

img

  • 面向連接配接:一定是「一對一」才能連接配接,不能像 UDP 協定可以一個主機同時向多個主機發送消息,也就是一對多是無法做到的;
  • 可靠的:無論的網絡鍊路中出現了怎樣的鍊路變化,TCP 都可以保證一個封包一定能夠到達接收端;
  • 位元組流:使用者消息通過 TCP 協定傳輸時,消息可能會被作業系統「分組」成多個的 TCP 封包,如果接收方的程式如果不知道「消息的邊界」,是無法讀出一個有效的使用者消息的。并且 TCP 封包是「有序的」,當「前一個」TCP 封包沒有收到的時候,即使它先收到了後面的 TCP 封包,那麼也不能扔給應用層去處理,同時對「重複」的 TCP 封包會自動丢棄。

#什麼是 TCP 連接配接?

我們來看看 RFC 793 是如何定義「連接配接」的:

Connections: The reliability and flow control mechanisms described above require that TCPs initialize and maintain certain status information for each data stream. The combination of this information, including sockets, sequence numbers, and window sizes, is called a connection.

簡單來說就是,用于保證可靠性和流量控制維護的某些狀态資訊,這些資訊的組合,包括 Socket、序列号和視窗大小稱為連接配接。

TCP 三次握手和四次揮手的面試題(2023最新版)

img

是以我們可以知道,建立一個 TCP 連接配接是需要用戶端與服務端達成上述三個資訊的共識。

  • Socket:由 IP 位址和端口号組成
  • 序列号:用來解決亂序問題等
  • 視窗大小:用來做流量控制

#如何唯一确定一個 TCP 連接配接呢?

TCP 四元組可以唯一的确定一個連接配接,四元組包括如下:

  • 源位址
  • 源端口
  • 目的位址
  • 目的端口
TCP 三次握手和四次揮手的面試題(2023最新版)

TCP 四元組

源位址和目的位址的字段(32 位)是在 IP 頭部中,作用是通過 IP 協定發送封包給對方主機。

源端口和目的端口的字段(16 位)是在 TCP 頭部中,作用是告訴 TCP 協定應該把封包發給哪個程序。

有一個 IP 的服務端監聽了一個端口,它的 TCP 的最大連接配接數是多少?

服務端通常固定在某個本地端口上監聽,等待用戶端的連接配接請求。

是以,用戶端 IP 和端口是可變的,其理論值計算公式如下:

TCP 三次握手和四次揮手的面試題(2023最新版)

img

對 IPv4,用戶端的 IP 數最多為 2 的 32 次方,用戶端的端口數最多為 2 的 16 次方,也就是服務端單機最大 TCP 連接配接數,約為 2 的 48 次方。

當然,服務端最大并發 TCP 連接配接數遠不能達到理論上限,會受以下因素影響:

  • 檔案描述符限制,每個 TCP 連接配接都是一個檔案,如果檔案描述符被占滿了,會發生 Too many open files。Linux 對可打開的檔案描述符的數量分别作了三個方面的限制:
    • 系統級:目前系統可打開的最大數量,通過 cat /proc/sys/fs/file-max 檢視;
    • 使用者級:指定使用者可打開的最大數量,通過 cat /etc/security/limits.conf 檢視;
    • 程序級:單個程序可打開的最大數量,通過 cat /proc/sys/fs/nr_open 檢視;
  • 記憶體限制,每個 TCP 連接配接都要占用一定記憶體,作業系統的記憶體是有限的,如果記憶體資源被占滿後,會發生 OOM。

#UDP 和 TCP 有什麼差別呢?分别的應用場景是?

UDP 不提供複雜的控制機制,利用 IP 提供面向「無連接配接」的通信服務。

UDP 協定真的非常簡,頭部隻有 8 個位元組(64 位),UDP 的頭部格式如下:

TCP 三次握手和四次揮手的面試題(2023最新版)

UDP 頭部格式

  • 目标和源端口:主要是告訴 UDP 協定應該把封包發給哪個程序。
  • 包長度:該字段儲存了 UDP 首部的長度跟資料的長度之和。
  • 校驗和:校驗和是為了提供可靠的 UDP 首部和資料而設計,防止收到在網絡傳輸中受損的 UDP 包。

TCP 和 UDP 差別:

1. 連接配接

  • TCP 是面向連接配接的傳輸層協定,傳輸資料前先要建立連接配接。
  • UDP 是不需要連接配接,即刻傳輸資料。

2. 服務對象

  • TCP 是一對一的兩點服務,即一條連接配接隻有兩個端點。
  • UDP 支援一對一、一對多、多對多的互動通信

3. 可靠性

  • TCP 是可靠傳遞資料的,資料可以無差錯、不丢失、不重複、按序到達。
  • UDP 是盡最大努力傳遞,不保證可靠傳遞資料。但是我們可以基于 UDP 傳輸協定實作一個可靠的傳輸協定,比如 QUIC 協定。

4. 擁塞控制、流量控制

  • TCP 有擁塞控制和流量控制機制,保證資料傳輸的安全性。
  • UDP 則沒有,即使網絡非常擁堵了,也不會影響 UDP 的發送速率。

5. 首部開銷

  • TCP 首部長度較長,會有一定的開銷,首部在沒有使用「選項」字段時是 20 個位元組,如果使用了「選項」字段則會變長的。
  • UDP 首部隻有 8 個位元組,并且是固定不變的,開銷較小。

6. 傳輸方式

  • TCP 是流式傳輸,沒有邊界,但保證順序和可靠。
  • UDP 是一個包一個包的發送,是有邊界的,但可能會丢包和亂序。

7. 分片不同

  • TCP 的資料大小如果大于 MSS 大小,則會在傳輸層進行分片,目标主機收到後,也同樣在傳輸層組裝 TCP 資料包,如果中途丢失了一個分片,隻需要傳輸丢失的這個分片。
  • UDP 的資料大小如果大于 MTU 大小,則會在 IP 層進行分片,目标主機收到後,在 IP 層組裝完資料,接着再傳給傳輸層。

TCP 和 UDP 應用場景:

由于 TCP 是面向連接配接,能保證資料的可靠性傳遞,是以經常用于:

  • FTP 檔案傳輸;
  • HTTP / HTTPS;

由于 UDP 面向無連接配接,它可以随時發送資料,再加上 UDP 本身的處理既簡單又高效,是以經常用于:

  • 包總量較少的通信,如 DNS 、SNMP 等;
  • 視訊、音頻等多媒體通信;
  • 廣播通信;
為什麼 UDP 頭部沒有「首部長度」字段,而 TCP 頭部有「首部長度」字段呢?

原因是 TCP 有可變長的「選項」字段,而 UDP 頭部長度則是不會變化的,無需多一個字段去記錄 UDP 的首部長度。

為什麼 UDP 頭部有「包長度」字段,而 TCP 頭部則沒有「包長度」字段呢?

先說說 TCP 是如何計算負載資料長度:

TCP 三次握手和四次揮手的面試題(2023最新版)

img

其中 IP 總長度 和 IP 首部長度,在 IP 首部格式是已知的。TCP 首部長度,則是在 TCP 首部格式已知的,是以就可以求得 TCP 資料的長度。

大家這時就奇怪了問:“UDP 也是基于 IP 層的呀,那 UDP 的資料長度也可以通過這個公式計算呀?為何還要有「包長度」呢?”

這麼一問,确實感覺 UDP 的「包長度」是備援的。

我查閱了很多資料,我覺得有兩個比較靠譜的說法:

  • 第一種說法:因為為了網絡裝置硬體設計和處理友善,首部長度需要是 4 位元組的整數倍。如果去掉 UDP 的「包長度」字段,那 UDP 首部長度就不是 4 位元組的整數倍了,是以我覺得這可能是為了補全 UDP 首部長度是 4 位元組的整數倍,才補充了「包長度」字段。
  • 第二種說法:如今的 UDP 協定是基于 IP 協定發展的,而當年可能并非如此,依賴的可能是别的不提供自身封包長度或首部長度的網絡層協定,是以 UDP 封包首部需要有長度字段以供計算。

#TCP 和 UDP 可以使用同一個端口嗎?

答案:可以的。

在資料鍊路層中,通過 MAC 位址來尋找區域網路中的主機。在網際層中,通過 IP 位址來尋找網絡中互連的主機或路由器。在傳輸層中,需要通過端口進行尋址,來識别同一計算機中同時通信的不同應用程式。

是以,傳輸層的「端口号」的作用,是為了區分同一個主機上不同應用程式的資料包。

傳輸層有兩個傳輸協定分别是 TCP 和 UDP,在核心中是兩個完全獨立的軟體子產品。

當主機收到資料包後,可以在 IP 標頭的「協定号」字段知道該資料包是 TCP/UDP,是以可以根據這個資訊确定送給哪個子產品(TCP/UDP)處理,送給 TCP/UDP 子產品的封包根據「端口号」确定送給哪個應用程式處理。

TCP 三次握手和四次揮手的面試題(2023最新版)

img

是以,TCP/UDP 各自的端口号也互相獨立,如 TCP 有一個 80 号端口,UDP 也可以有一個 80 号端口,二者并不沖突。

關于端口的知識點,還是挺多可以講的,比如還可以牽扯到這幾個問題:

  • 多個 TCP 服務程序可以同時綁定同一個端口嗎?
  • 重新開機 TCP 服務程序時,為什麼會出現“Address in use”的報錯資訊?又該怎麼避免?
  • 用戶端的端口可以重複使用嗎?
  • 用戶端 TCP 連接配接 TIME_WAIT 狀态過多,會導緻端口資源耗盡而無法建立新的連接配接嗎?

#TCP 連接配接建立

#TCP 三次握手過程是怎樣的?

TCP 是面向連接配接的協定,是以使用 TCP 前必須先建立連接配接,而建立連接配接是通過三次握手來進行的。三次握手的過程如下圖:

TCP 三次握手和四次揮手的面試題(2023最新版)

TCP 三次握手

  • 一開始,用戶端和服務端都處于 CLOSE 狀态。先是服務端主動監聽某個端口,處于 LISTEN 狀态
TCP 三次握手和四次揮手的面試題(2023最新版)

第一個封包 —— SYN 封包

  • 用戶端會随機初始化序号(client_isn),将此序号置于 TCP 首部的「序号」字段中,同時把 SYN 标志位置為 1,表示 SYN 封包。接着把第一個 SYN 封包發送給服務端,表示向服務端發起連接配接,該封包不包含應用層資料,之後用戶端處于 SYN-SENT 狀态。
TCP 三次握手和四次揮手的面試題(2023最新版)

第二個封包 —— SYN + ACK 封包

  • 服務端收到用戶端的 SYN 封包後,首先服務端也随機初始化自己的序号(server_isn),将此序号填入 TCP 首部的「序号」字段中,其次把 TCP 首部的「确認應答号」字段填入 client_isn + 1, 接着把 SYN 和 ACK 标志位置為 1。最後把該封包發給用戶端,該封包也不包含應用層資料,之後服務端處于 SYN-RCVD 狀态。
TCP 三次握手和四次揮手的面試題(2023最新版)

第三個封包 —— ACK 封包

  • 用戶端收到服務端封包後,還要向服務端回應最後一個應答封包,首先該應答封包 TCP 首部 ACK 标志位置為 1 ,其次「确認應答号」字段填入 server_isn + 1 ,最後把封包發送給服務端,這次封包可以攜帶客戶到服務端的資料,之後用戶端處于 ESTABLISHED 狀态。
  • 服務端收到用戶端的應答封包後,也進入 ESTABLISHED 狀态。

從上面的過程可以發現第三次握手是可以攜帶資料的,前兩次握手是不可以攜帶資料的,這也是面試常問的題。

一旦完成三次握手,雙方都處于 ESTABLISHED 狀态,此時連接配接就已建立完成,用戶端和服務端就可以互相發送資料了。

#如何在 Linux 系統中檢視 TCP 狀态?

TCP 的連接配接狀态檢視,在 Linux 可以通過 netstat -napt 指令檢視。

TCP 三次握手和四次揮手的面試題(2023最新版)

TCP 連接配接狀态檢視

#為什麼是三次握手?不是兩次、四次?

相信大家比較常回答的是:“因為三次握手才能保證雙方具有接收和發送的能力。”

這回答是沒問題,但這回答是片面的,并沒有說出主要的原因。

在前面我們知道了什麼是 TCP 連接配接:

  • 用于保證可靠性和流量控制維護的某些狀态資訊,這些資訊的組合,包括 Socket、序列号和視窗大小稱為連接配接。

是以,重要的是為什麼三次握手才可以初始化 Socket、序列号和視窗大小并建立 TCP 連接配接。

接下來,以三個方面分析三次握手的原因:

  • 三次握手才可以阻止重複曆史連接配接的初始化(主要原因)
  • 三次握手才可以同步雙方的初始序列号
  • 三次握手才可以避免資源浪費

原因一:避免曆史連接配接

我們來看看 RFC 793 指出的 TCP 連接配接使用三次握手的首要原因:

The principle reason for the three-way handshake is to prevent old duplicate connection initiations from causing confusion.

簡單來說,三次握手的首要原因是為了防止舊的重複連接配接初始化造成混亂。

我們考慮一個場景,用戶端先發送了 SYN(seq = 90)封包,然後用戶端當機了,而且這個 SYN 封包還被網絡阻塞了,服務端并沒有收到,接着用戶端重新開機後,又重新向服務端建立連接配接,發送了 SYN(seq = 100)封包(注意!不是重傳 SYN,重傳的 SYN 的序列号是一樣的)。

看看三次握手是如何阻止曆史連接配接的:

TCP 三次握手和四次揮手的面試題(2023最新版)

三次握手避免曆史連接配接

用戶端連續發送多次 SYN(都是同一個四元組)建立連接配接的封包,在網絡擁堵情況下:

  • 一個「舊 SYN 封包」比「最新的 SYN」 封包早到達了服務端,那麼此時服務端就會回一個 SYN + ACK 封包給用戶端,此封包中的确認号是 91(90+1)。
  • 用戶端收到後,發現自己期望收到的确認号應該是 100 + 1,而不是 90 + 1,于是就會回 RST 封包。
  • 服務端收到 RST 封包後,就會釋放連接配接。
  • 後續最新的 SYN 抵達了服務端後,用戶端與服務端就可以正常的完成三次握手了。

上述中的「舊 SYN 封包」稱為曆史連接配接,TCP 使用三次握手建立連接配接的最主要原因就是防止「曆史連接配接」初始化了連接配接。

TIP

有很多人問,如果服務端在收到 RST 封包之前,先收到了「新 SYN 封包」,也就是服務端收到用戶端封包的順序是:「舊 SYN 封包」->「新 SYN 封包」,此時會發生什麼?

當服務端第一次收到 SYN 封包,也就是收到 「舊 SYN 封包」時,就會回複 SYN + ACK 封包給用戶端,此封包中的确認号是 91(90+1)。

然後這時再收到「新 SYN 封包」時,就會回 Challenge Ack封包給用戶端,這個 ack 封包并不是确認收到「新 SYN 封包」的,而是上一次的 ack 确認号,也就是91(90+1)。是以用戶端收到此 ACK 封包時,發現自己期望收到的确認号應該是 101,而不是 91,于是就會回 RST 封包。

如果是兩次握手連接配接,就無法阻止曆史連接配接,那為什麼 TCP 兩次握手為什麼無法阻止曆史連接配接呢?

我先直接說結論,主要是因為在兩次握手的情況下,服務端沒有中間狀态給用戶端來阻止曆史連接配接,導緻服務端可能建立一個曆史連接配接,造成資源浪費。

你想想,在兩次握手的情況下,服務端在收到 SYN 封包後,就進入 ESTABLISHED 狀态,意味着這時可以給對方發送資料,但是用戶端此時還沒有進入 ESTABLISHED 狀态,假設這次是曆史連接配接,用戶端判斷到此次連接配接為曆史連接配接,那麼就會回 RST 封包來斷開連接配接,而服務端在第一次握手的時候就進入 ESTABLISHED 狀态,是以它可以發送資料的,但是它并不知道這個是曆史連接配接,它隻有在收到 RST 封包後,才會斷開連接配接。

TCP 三次握手和四次揮手的面試題(2023最新版)

兩次握手無法阻止曆史連接配接

可以看到,如果采用兩次握手建立 TCP 連接配接的場景下,服務端在向用戶端發送資料前,并沒有阻止掉曆史連接配接,導緻服務端建立了一個曆史連接配接,又白白發送了資料,妥妥地浪費了服務端的資源。

是以,要解決這種現象,最好就是在服務端發送資料前,也就是建立連接配接之前,要阻止掉曆史連接配接,這樣就不會造成資源浪費,而要實作這個功能,就需要三次握手。

是以,TCP 使用三次握手建立連接配接的最主要原因是防止「曆史連接配接」初始化了連接配接。

TIP

有人問:用戶端發送三次握手(ack 封包)後就可以發送資料了,而被動方此時還是 syn_received 狀态,如果 ack 丢了,那用戶端發的資料是不是也白白浪費了?

不是的,即使服務端還是在 syn_received 狀态,收到了用戶端發送的資料,還是可以建立連接配接的,并且還可以正常收到這個資料包。這是因為資料封包中是有 ack 辨別位,也有确認号,這個确認号就是确認收到了第二次握手。如下圖:

TCP 三次握手和四次揮手的面試題(2023最新版)

img

是以,服務端收到這個資料封包,是可以正常建立連接配接的,然後就可以正常接收這個資料包了。

原因二:同步雙方初始序列号

TCP 協定的通信雙方, 都必須維護一個「序列号」, 序列号是可靠傳輸的一個關鍵因素,它的作用:

  • 接收方可以去除重複的資料;
  • 接收方可以根據資料包的序列号按序接收;
  • 可以辨別發送出去的資料包中, 哪些是已經被對方收到的(通過 ACK 封包中的序列号知道);

可見,序列号在 TCP 連接配接中占據着非常重要的作用,是以當用戶端發送攜帶「初始序列号」的 SYN 封包的時候,需要服務端回一個 ACK 應答封包,表示用戶端的 SYN 封包已被服務端成功接收,那當服務端發送「初始序列号」給用戶端的時候,依然也要得到用戶端的應答回應,這樣一來一回,才能確定雙方的初始序列号能被可靠的同步。

TCP 三次握手和四次揮手的面試題(2023最新版)

四次握手與三次握手

四次握手其實也能夠可靠的同步雙方的初始化序号,但由于第二步和第三步可以優化成一步,是以就成了「三次握手」。

而兩次握手隻保證了一方的初始序列号能被對方成功接收,沒辦法保證雙方的初始序列号都能被确認接收。

原因三:避免資源浪費

如果隻有「兩次握手」,當用戶端發生的 SYN 封包在網絡中阻塞,用戶端沒有接收到 ACK 封包,就會重新發送 SYN ,由于沒有第三次握手,服務端不清楚用戶端是否收到了自己回複的 ACK 封包,是以服務端每收到一個 SYN 就隻能先主動建立一個連接配接,這會造成什麼情況呢?

如果用戶端發送的 SYN 封包在網絡中阻塞了,重複發送多次 SYN 封包,那麼服務端在收到請求後就會建立多個備援的無效連結,造成不必要的資源浪費。

TCP 三次握手和四次揮手的面試題(2023最新版)

兩次握手會造成資源浪費

即兩次握手會造成消息滞留情況下,服務端重複接受無用的連接配接請求 SYN 封包,而造成重複配置設定資源。

TIP

很多人問,兩次握手不是也可以根據上下文資訊丢棄 syn 曆史封包嗎?

我這裡兩次握手是假設「由于沒有第三次握手,服務端不清楚用戶端是否收到了自己發送的建立連接配接的 ACK 确認封包,是以每收到一個 SYN 就隻能先主動建立一個連接配接」這個場景。

當然你要實作成類似三次握手那樣,根據上下文丢棄 syn 曆史封包也是可以的,兩次握手沒有具體的實作,怎麼假設都行。

小結

TCP 建立連接配接時,通過三次握手能防止曆史連接配接的建立,能減少雙方不必要的資源開銷,能幫助雙方同步初始化序列号。序列号能夠保證資料包不重複、不丢棄和按序傳輸。

不使用「兩次握手」和「四次握手」的原因:

  • 「兩次握手」:無法防止曆史連接配接的建立,會造成雙方資源的浪費,也無法可靠的同步雙方序列号;
  • 「四次握手」:三次握手就已經理論上最少可靠連接配接建立,是以不需要使用更多的通信次數。

#為什麼每次建立 TCP 連接配接時,初始化的序列号都要求不一樣呢?

主要原因有兩個方面:

  • 為了防止曆史封包被下一個相同四元組的連接配接接收(主要方面);
  • 為了安全性,防止黑客僞造的相同序列号的 TCP 封包被對方接收;

接下來,詳細說說第一點。

假設每次建立連接配接,用戶端和服務端的初始化序列号都是從 0 開始:

TCP 三次握手和四次揮手的面試題(2023最新版)

img

過程如下:

  • 用戶端和服務端建立一個 TCP 連接配接,在用戶端發送資料包被網絡阻塞了,然後逾時重傳了這個資料包,而此時服務端裝置斷電重新開機了,之前與用戶端建立的連接配接就消失了,于是在收到用戶端的資料包的時候就會發送 RST 封包。
  • 緊接着,用戶端又與服務端建立了與上一個連接配接相同四元組的連接配接;
  • 在新連接配接建立完成後,上一個連接配接中被網絡阻塞的資料包正好抵達了服務端,剛好該資料包的序列号正好是在服務端的接收視窗内,是以該資料包會被服務端正常接收,就會造成資料錯亂。

可以看到,如果每次建立連接配接,用戶端和服務端的初始化序列号都是一樣的話,很容易出現曆史封包被下一個相同四元組的連接配接接收的問題。

如果每次建立連接配接用戶端和服務端的初始化序列号都「不一樣」,就有大機率因為曆史封包的序列号「不在」對方接收視窗,進而很大程度上避免了曆史封包,比如下圖:

TCP 三次握手和四次揮手的面試題(2023最新版)

img

相反,如果每次建立連接配接用戶端和服務端的初始化序列号都「一樣」,就有大機率遇到曆史封包的序列号剛「好在」對方的接收視窗内,進而導緻曆史封包被新連接配接成功接收。

是以,每次初始化序列号不一樣很大程度上能夠避免曆史封包被下一個相同四元組的連接配接接收,注意是很大程度上,并不是完全避免了(因為序列号會有回繞的問題,是以需要用時間戳的機制來判斷曆史封包)

#初始序列号 ISN 是如何随機産生的?

起始 ISN 是基于時鐘的,每 4 微秒 + 1,轉一圈要 4.55 個小時。

RFC793 提到初始化序列号 ISN 随機生成算法:ISN = M + F(localhost, localport, remotehost, remoteport)。

  • M 是一個計時器,這個計時器每隔 4 微秒加 1。
  • F 是一個 Hash 算法,根據源 IP、目的 IP、源端口、目的端口生成一個随機數值。要保證 Hash 算法不能被外部輕易推算得出,用 MD5 算法是一個比較好的選擇。

可以看到,随機數是會基于時鐘計時器遞增的,基本不可能會随機成一樣的初始化序列号。

#既然 IP 層會分片,為什麼 TCP 層還需要 MSS 呢?

我們先來認識下 MTU 和 MSS

TCP 三次握手和四次揮手的面試題(2023最新版)

MTU 與 MSS

  • MTU:一個網絡包的最大長度,以太網中一般為 1500 位元組;
  • MSS:除去 IP 和 TCP 頭部之後,一個網絡包所能容納的 TCP 資料的最大長度;

如果在 TCP 的整個封包(頭部 + 資料)交給 IP 層進行分片,會有什麼異常呢?

當 IP 層有一個超過 MTU 大小的資料(TCP 頭部 + TCP 資料)要發送,那麼 IP 層就要進行分片,把資料分片成若幹片,保證每一個分片都小于 MTU。把一份 IP 資料報進行分片以後,由目标主機的 IP 層來進行重新組裝後,再交給上一層 TCP 傳輸層。

這看起來井然有序,但這存在隐患的,那麼當如果一個 IP 分片丢失,整個 IP 封包的所有分片都得重傳。

因為 IP 層本身沒有逾時重傳機制,它由傳輸層的 TCP 來負責逾時和重傳。

當某一個 IP 分片丢失後,接收方的 IP 層就無法組裝成一個完整的 TCP 封包(頭部 + 資料),也就無法将資料封包送到 TCP 層,是以接收方不會響應 ACK 給發送方,因為發送方遲遲收不到 ACK 确認封包,是以會觸發逾時重傳,就會重發「整個 TCP 封包(頭部 + 資料)」。

是以,可以得知由 IP 層進行分片傳輸,是非常沒有效率的。

是以,為了達到最佳的傳輸效能 TCP 協定在建立連接配接的時候通常要協商雙方的 MSS 值,當 TCP 層發現資料超過 MSS 時,則就先會進行分片,當然由它形成的 IP 包的長度也就不會大于 MTU ,自然也就不用 IP 分片了。

TCP 三次握手和四次揮手的面試題(2023最新版)

握手階段協商 MSS

經過 TCP 層分片後,如果一個 TCP 分片丢失後,進行重發時也是以 MSS 為機關,而不用重傳所有的分片,大大增加了重傳的效率。

#第一次握手丢失了,會發生什麼?

當用戶端想和服務端建立 TCP 連接配接的時候,首先第一個發的就是 SYN 封包,然後進入到 SYN_SENT 狀态。

在這之後,如果用戶端遲遲收不到服務端的 SYN-ACK 封包(第二次握手),就會觸發「逾時重傳」機制,重傳 SYN 封包,而且重傳的 SYN 封包的序列号都是一樣的。

不同版本的作業系統可能逾時時間不同,有的 1 秒的,也有 3 秒的,這個逾時時間是寫死在核心裡的,如果想要更改則需要重新編譯核心,比較麻煩。

當用戶端在 1 秒後沒收到服務端的 SYN-ACK 封包後,用戶端就會重發 SYN 封包,那到底重發幾次呢?

在 Linux 裡,用戶端的 SYN 封包最大重傳次數由 tcp_syn_retries核心參數控制,這個參數是可以自定義的,預設值一般是 5。

# cat /proc/sys/net/ipv4/tcp_syn_retries
5
           

通常,第一次逾時重傳是在 1 秒後,第二次逾時重傳是在 2 秒,第三次逾時重傳是在 4 秒後,第四次逾時重傳是在 8 秒後,第五次是在逾時重傳 16 秒後。沒錯,每次逾時的時間是上一次的 2 倍。

當第五次逾時重傳後,會繼續等待 32 秒,如果服務端仍然沒有回應 ACK,用戶端就不再發送 SYN 包,然後斷開 TCP 連接配接。

是以,總耗時是 1+2+4+8+16+32=63 秒,大約 1 分鐘左右。

舉個例子,假設 tcp_syn_retries 參數值為 3,那麼當用戶端的 SYN 封包一直在網絡中丢失時,會發生下圖的過程:

TCP 三次握手和四次揮手的面試題(2023最新版)

img

具體過程:

  • 當用戶端逾時重傳 3 次 SYN 封包後,由于 tcp_syn_retries 為 3,已達到最大重傳次數,于是再等待一段時間(時間為上一次逾時時間的 2 倍),如果還是沒能收到服務端的第二次握手(SYN-ACK 封包),那麼用戶端就會斷開連接配接。

第二次揮手丢失了,會發生什麼?

當服務端收到用戶端的第一次揮手後,就會先回一個 ACK 确認封包,此時服務端的連接配接進入到 CLOSE_WAIT 狀态。

在前面我們也提了,ACK 封包是不會重傳的,是以如果服務端的第二次揮手丢失了,用戶端就會觸發逾時重傳機制,重傳 FIN 封包,直到收到服務端的第二次揮手,或者達到最大的重傳次數。

舉個例子,假設 tcp_orphan_retries 參數值為 2,當第二次揮手一直丢失時,發生的過程如下圖:

TCP 三次握手和四次揮手的面試題(2023最新版)

img

具體過程:

  • 當用戶端逾時重傳 2 次 FIN 封包後,由于 tcp_orphan_retries 為 2,已達到最大重傳次數,于是再等待一段時間(時間為上一次逾時時間的 2 倍),如果還是沒能收到服務端的第二次揮手(ACK 封包),那麼用戶端就會斷開連接配接。

這裡提一下,當用戶端收到第二次揮手,也就是收到服務端發送的 ACK 封包後,用戶端就會處于 FIN_WAIT2 狀态,在這個狀态需要等服務端發送第三次揮手,也就是服務端的 FIN 封包。

對于 close 函數關閉的連接配接,由于無法再發送和接收資料,是以FIN_WAIT2 狀态不可以持續太久,而 tcp_fin_timeout 控制了這個狀态下連接配接的持續時長,預設值是 60 秒。

這意味着對于調用 close 關閉的連接配接,如果在 60 秒後還沒有收到 FIN 封包,用戶端(主動關閉方)的連接配接就會直接關閉,如下圖:

TCP 三次握手和四次揮手的面試題(2023最新版)

img

但是注意,如果主動關閉方使用 shutdown 函數關閉連接配接,指定了隻關閉發送方向,而接收方向并沒有關閉,那麼意味着主動關閉方還是可以接收資料的。

此時,如果主動關閉方一直沒收到第三次揮手,那麼主動關閉方的連接配接将會一直處于 FIN_WAIT2 狀态(tcp_fin_timeout 無法控制 shutdown 關閉的連接配接)。如下圖:

TCP 三次握手和四次揮手的面試題(2023最新版)

img

第三次揮手丢失了,會發生什麼?

當服務端(被動關閉方)收到用戶端(主動關閉方)的 FIN 封包後,核心會自動回複 ACK,同時連接配接處于 CLOSE_WAIT 狀态,顧名思義,它表示等待應用程序調用 close 函數關閉連接配接。

此時,核心是沒有權利替代程序關閉連接配接,必須由程序主動調用 close 函數來觸發服務端發送 FIN 封包。

服務端處于 CLOSE_WAIT 狀态時,調用了 close 函數,核心就會發出 FIN 封包,同時連接配接進入 LAST_ACK 狀态,等待用戶端傳回 ACK 來确認連接配接關閉。

如果遲遲收不到這個 ACK,服務端就會重發 FIN 封包,重發次數仍然由 tcp_orphan_retries 參數控制,這與用戶端重發 FIN 封包的重傳次數控制方式是一樣的。

舉個例子,假設 tcp_orphan_retries = 3,當第三次揮手一直丢失時,發生的過程如下圖:

TCP 三次握手和四次揮手的面試題(2023最新版)

img

具體過程:

  • 當服務端重傳第三次揮手封包的次數達到了 3 次後,由于 tcp_orphan_retries 為 3,達到了重傳最大次數,于是再等待一段時間(時間為上一次逾時時間的 2 倍),如果還是沒能收到用戶端的第四次揮手(ACK封包),那麼服務端就會斷開連接配接。
  • 用戶端因為是通過 close 函數關閉連接配接的,處于 FIN_WAIT_2 狀态是有時長限制的,如果 tcp_fin_timeout 時間内還是沒能收到服務端的第三次揮手(FIN 封包),那麼用戶端就會斷開連接配接。

#第四次揮手丢失了,會發生什麼?

當用戶端收到服務端的第三次揮手的 FIN 封包後,就會回 ACK 封包,也就是第四次揮手,此時用戶端連接配接進入 TIME_WAIT 狀态。

在 Linux 系統,TIME_WAIT 狀态會持續 2MSL 後才會進入關閉狀态。

然後,服務端(被動關閉方)沒有收到 ACK 封包前,還是處于 LAST_ACK 狀态。

如果第四次揮手的 ACK 封包沒有到達服務端,服務端就會重發 FIN 封包,重發次數仍然由前面介紹過的 tcp_orphan_retries 參數控制。

舉個例子,假設 tcp_orphan_retries 為 2,當第四次揮手一直丢失時,發生的過程如下:

TCP 三次握手和四次揮手的面試題(2023最新版)

img

具體過程:

  • 當服務端重傳第三次揮手封包達到 2 時,由于 tcp_orphan_retries 為 2, 達到了最大重傳次數,于是再等待一段時間(時間為上一次逾時時間的 2 倍),如果還是沒能收到用戶端的第四次揮手(ACK 封包),那麼服務端就會斷開連接配接。
  • 用戶端在收到第三次揮手後,就會進入 TIME_WAIT 狀态,開啟時長為 2MSL 的定時器,如果途中再次收到第三次揮手(FIN 封包)後,就會重置定時器,當等待 2MSL 時長後,用戶端就會斷開連接配接。

#為什麼 TIME_WAIT 等待的時間是 2MSL?

MSL 是 Maximum Segment Lifetime,封包最大生存時間,它是任何封包在網絡上存在的最長時間,超過這個時間封包将被丢棄。因為 TCP 封包基于是 IP 協定的,而 IP 頭中有一個 TTL 字段,是 IP 資料報可以經過的最大路由數,每經過一個處理他的路由器此值就減 1,當此值為 0 則資料報将被丢棄,同時發送 ICMP 封包通知源主機。

MSL 與 TTL 的差別:MSL 的機關是時間,而 TTL 是經過路由跳數。是以 MSL 應該要大于等于 TTL 消耗為 0 的時間,以確定封包已被自然消亡。

TTL 的值一般是 64,Linux 将 MSL 設定為 30 秒,意味着 Linux 認為資料封包經過 64 個路由器的時間不會超過 30 秒,如果超過了,就認為封包已經消失在網絡中了。

TIME_WAIT 等待 2 倍的 MSL,比較合理的解釋是:網絡中可能存在來自發送方的資料包,當這些發送方的資料包被接收方處理後又會向對方發送響應,是以一來一回需要等待 2 倍的時間。

比如,如果被動關閉方沒有收到斷開連接配接的最後的 ACK 封包,就會觸發逾時重發 FIN 封包,另一方接收到 FIN 後,會重發 ACK 給被動關閉方, 一來一去正好 2 個 MSL。

可以看到 2MSL時長 這其實是相當于至少允許封包丢失一次。比如,若 ACK 在一個 MSL 内丢失,這樣被動方重發的 FIN 會在第 2 個 MSL 内到達,TIME_WAIT 狀态的連接配接可以應對。

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

2MSL 的時間是從用戶端接收到 FIN 後發送 ACK 開始計時的。如果在 TIME-WAIT 時間内,因為用戶端的 ACK 沒有傳輸到服務端,用戶端又接收到了服務端重發的 FIN 封包,那麼 2MSL 時間将重新計時。

在 Linux 系統裡 2MSL 預設是 60 秒,那麼一個 MSL 也就是 30 秒。Linux 系統停留在 TIME_WAIT 的時間為固定的 60 秒。

其定義在 Linux 核心代碼裡的名稱為 TCP_TIMEWAIT_LEN:

#define TCP_TIMEWAIT_LEN (60*HZ) /* how long to wait to destroy TIME-WAIT 
                                    state, about 60 seconds  */
           

如果要修改 TIME_WAIT 的時間長度,隻能修改 Linux 核心代碼裡 TCP_TIMEWAIT_LEN 的值,并重新編譯 Linux 核心。

為什麼需要 TIME_WAIT 狀态?

主動發起關閉連接配接的一方,才會有 TIME-WAIT 狀态。

需要 TIME-WAIT 狀态,主要是兩個原因:

  • 防止曆史連接配接中的資料,被後面相同四元組的連接配接錯誤的接收;
  • 保證「被動關閉連接配接」的一方,能被正确的關閉;

原因一:防止曆史連接配接中的資料,被後面相同四元組的連接配接錯誤的接收

為了能更好的了解這個原因,我們先來了解序列号(SEQ)和初始序列号(ISN)。

  • 序列号,是 TCP 一個頭部字段,辨別了 TCP 發送端到 TCP 接收端的資料流的一個位元組,因為 TCP 是面向位元組流的可靠協定,為了保證消息的順序性和可靠性,TCP 為每個傳輸方向上的每個位元組都賦予了一個編号,以便于傳輸成功後确認、丢失後重傳以及在接收端保證不會亂序。序列号是一個 32 位的無符号數,是以在到達 4G 之後再循環回到 0。
  • 初始序列号,在 TCP 建立連接配接的時候,用戶端和服務端都會各自生成一個初始序列号,它是基于時鐘生成的一個随機數,來保證每個連接配接都擁有不同的初始序列号。初始化序列号可被視為一個 32 位的計數器,該計數器的數值每 4 微秒加 1,循環一次需要 4.55 小時。

給大家抓了一個包,下圖中的 Seq 就是序列号,其中紅色框住的分别是用戶端和服務端各自生成的初始序列号。

TCP 三次握手和四次揮手的面試題(2023最新版)

TCP 抓包圖

通過前面我們知道,序列号和初始化序列号并不是無限遞增的,會發生回繞為初始值的情況,這意味着無法根據序列号來判斷新老資料。

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

TCP 三次握手和四次揮手的面試題(2023最新版)

TIME-WAIT 時間過短,收到舊連接配接的資料封包

如上圖:

  • 服務端在關閉連接配接之前發送的 SEQ = 301 封包,被網絡延遲了。
  • 接着,服務端以相同的四元組重新打開了新連接配接,前面被延遲的 SEQ = 301 這時抵達了用戶端,而且該資料封包的序列号剛好在用戶端接收視窗内,是以用戶端會正常接收這個資料封包,但是這個資料封包是上一個連接配接殘留下來的,這樣就産生資料錯亂等嚴重的問題。

為了防止曆史連接配接中的資料,被後面相同四元組的連接配接錯誤的接收,是以 TCP 設計了 TIME_WAIT 狀态,狀态會持續 2MSL 時長,這個時間足以讓兩個方向上的資料包都被丢棄,使得原來連接配接的資料包在網絡中都自然消失,再出現的資料包一定都是建立立連接配接所産生的。

原因二:保證「被動關閉連接配接」的一方,能被正确的關閉

在 RFC 793 指出 TIME-WAIT 另一個重要的作用是:

TIME-WAIT - represents waiting for enough time to pass to be sure the remote TCP received the acknowledgment of its connection termination request.

也就是說,TIME-WAIT 作用是等待足夠的時間以確定最後的 ACK 能讓被動關閉方接收,進而幫助其正常關閉。

如果用戶端(主動關閉方)最後一次 ACK 封包(第四次揮手)在網絡中丢失了,那麼按照 TCP 可靠性原則,服務端(被動關閉方)會重發 FIN 封包。

假設用戶端沒有 TIME_WAIT 狀态,而是在發完最後一次回 ACK 封包就直接進入 CLOSE 狀态,如果該 ACK 封包丢失了,服務端則重傳的 FIN 封包,而這時用戶端已經進入到關閉狀态了,在收到服務端重傳的 FIN 封包後,就會回 RST 封包。

TCP 三次握手和四次揮手的面試題(2023最新版)

TIME-WAIT 時間過短,沒有確定連接配接正常關閉

服務端收到這個 RST 并将其解釋為一個錯誤(Connection reset by peer),這對于一個可靠的協定來說不是一個優雅的終止方式。

為了防止這種情況出現,用戶端必須等待足夠長的時間,確定服務端能夠收到 ACK,如果服務端沒有收到 ACK,那麼就會觸發 TCP 重傳機制,服務端會重新發送一個 FIN,這樣一去一來剛好兩個 MSL 的時間。

TCP 三次握手和四次揮手的面試題(2023最新版)

TIME-WAIT 時間正常,確定了連接配接正常關閉

用戶端在收到服務端重傳的 FIN 封包時,TIME_WAIT 狀态的等待時間,會重置回 2MSL。

#TIME_WAIT 過多有什麼危害?

過多的 TIME-WAIT 狀态主要的危害有兩種:

  • 第一是占用系統資源,比如檔案描述符、記憶體資源、CPU 資源、線程資源等;
  • 第二是占用端口資源,端口資源也是有限的,一般可以開啟的端口為 32768~61000,也可以通過 net.ipv4.ip_local_port_range參數指定範圍。

用戶端和服務端 TIME_WAIT 過多,造成的影響是不同的。

如果用戶端(主動發起關閉連接配接方)的 TIME_WAIT 狀态過多,占滿了所有端口資源,那麼就無法對「目的 IP+ 目的 PORT」都一樣的服務端發起連接配接了,但是被使用的端口,還是可以繼續對另外一個服務端發起連接配接的。具體可以看我這篇文章:用戶端的端口可以重複使用嗎?(opens new window)

是以,用戶端(發起連接配接方)都是和「目的 IP+ 目的 PORT 」都一樣的服務端建立連接配接的話,當用戶端的 TIME_WAIT 狀态連接配接過多的話,就會受端口資源限制,如果占滿了所有端口資源,那麼就無法再跟「目的 IP+ 目的 PORT」都一樣的服務端建立連接配接了。

不過,即使是在這種場景下,隻要連接配接的是不同的服務端,端口是可以重複使用的,是以用戶端還是可以向其他服務端發起連接配接的,這是因為核心在定位一個連接配接的時候,是通過四元組(源IP、源端口、目的IP、目的端口)資訊來定位的,并不會因為用戶端的端口一樣,而導緻連接配接沖突。

如果服務端(主動發起關閉連接配接方)的 TIME_WAIT 狀态過多,并不會導緻端口資源受限,因為服務端隻監聽一個端口,而且由于一個四元組唯一确定一個 TCP 連接配接,是以理論上服務端可以建立很多連接配接,但是 TCP 連接配接過多,會占用系統資源,比如檔案描述符、記憶體資源、CPU 資源、線程資源等。

#如何優化 TIME_WAIT?

這裡給出優化 TIME-WAIT 的幾個方式,都是有利有弊:

  • 打開 net.ipv4.tcp_tw_reuse 和 net.ipv4.tcp_timestamps 選項;
  • net.ipv4.tcp_max_tw_buckets
  • 程式中使用 SO_LINGER ,應用強制使用 RST 關閉。

方式一:net.ipv4.tcp_tw_reuse 和 tcp_timestamps

如下的 Linux 核心參數開啟後,則可以複用處于 TIME_WAIT 的 socket 為新的連接配接所用。

有一點需要注意的是,tcp_tw_reuse 功能隻能用用戶端(連接配接發起方),因為開啟了該功能,在調用 connect() 函數時,核心會随機找一個 time_wait 狀态超過 1 秒的連接配接給新的連接配接複用。

net.ipv4.tcp_tw_reuse = 1
           

使用這個選項,還有一個前提,需要打開對 TCP 時間戳的支援,即

net.ipv4.tcp_timestamps=1(預設即為 1)
           

這個時間戳的字段是在 TCP 頭部的「選項」裡,它由一共 8 個位元組表示時間戳,其中第一個 4 位元組字段用來儲存發送該資料包的時間,第二個 4 位元組字段用來儲存最近一次接收對方發送到達資料的時間。

由于引入了時間戳,我們在前面提到的 2MSL 問題就不複存在了,因為重複的資料包會因為時間戳過期被自然丢棄。

方式二:net.ipv4.tcp_max_tw_buckets

這個值預設為 18000,當系統中處于 TIME_WAIT 的連接配接一旦超過這個值時,系統就會将後面的 TIME_WAIT 連接配接狀态重置,這個方法比較暴力。

方式三:程式中使用 SO_LINGER

我們可以通過設定 socket 選項,來設定調用 close 關閉連接配接行為。

struct linger so_linger;
so_linger.l_onoff = 1;
so_linger.l_linger = 0;
setsockopt(s, SOL_SOCKET, SO_LINGER, &so_linger,sizeof(so_linger));
           

如果l_onoff為非 0, 且l_linger值為 0,那麼調用close後,會立該發送一個RST标志給對端,該 TCP 連接配接将跳過四次揮手,也就跳過了TIME_WAIT狀态,直接關閉。

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

前面介紹的方法都是試圖越過 TIME_WAIT狀态的,這樣其實不太好。雖然 TIME_WAIT 狀态持續的時間是有一點長,顯得很不友好,但是它被設計來就是用來避免發生亂七八糟的事情。

《UNIX網絡程式設計》一書中卻說道:TIME_WAIT 是我們的朋友,它是有助于我們的,不要試圖避免這個狀态,而是應該弄清楚它。

如果服務端要避免過多的 TIME_WAIT 狀态的連接配接,就永遠不要主動斷開連接配接,讓用戶端去斷開,由分布在各處的用戶端去承受 TIME_WAIT。

#伺服器出現大量 TIME_WAIT 狀态的原因有哪些?

首先要知道 TIME_WAIT 狀态是主動關閉連接配接方才會出現的狀态,是以如果伺服器出現大量的 TIME_WAIT 狀态的 TCP 連接配接,就是說明伺服器主動斷開了很多 TCP 連接配接。

問題來了,什麼場景下服務端會主動斷開連接配接呢?

  • 第一個場景:HTTP 沒有使用長連接配接
  • 第二個場景:HTTP 長連接配接逾時
  • 第三個場景:HTTP 長連接配接的請求數量達到上限

接下來,分别介紹下。

第一個場景:HTTP 沒有使用長連接配接

我們先來看看 HTTP 長連接配接(Keep-Alive)機制是怎麼開啟的。

在 HTTP/1.0 中預設是關閉的,如果浏覽器要開啟 Keep-Alive,它必須在請求的 header 中添加:

Connection: Keep-Alive
           

然後當伺服器收到請求,作出回應的時候,它也被添加到響應中 header 裡:

Connection: Keep-Alive
           

這樣做,TCP 連接配接就不會中斷,而是保持連接配接。當用戶端發送另一個請求時,它會使用同一個 TCP 連接配接。這一直繼續到用戶端或伺服器端提出斷開連接配接。

從 HTTP/1.1 開始, 就預設是開啟了 Keep-Alive,現在大多數浏覽器都預設是使用 HTTP/1.1,是以 Keep-Alive 都是預設打開的。一旦用戶端和服務端達成協定,那麼長連接配接就建立好了。

如果要關閉 HTTP Keep-Alive,需要在 HTTP 請求或者響應的 header 裡添加 Connection:close 資訊,也就是說,隻要用戶端和服務端任意一方的 HTTP header 中有 Connection:close 資訊,那麼就無法使用 HTTP 長連接配接的機制。

關閉 HTTP 長連接配接機制後,每次請求都要經曆這樣的過程:建立 TCP -> 請求資源 -> 響應資源 -> 釋放連接配接,那麼此方式就是 HTTP 短連接配接,如下圖:

TCP 三次握手和四次揮手的面試題(2023最新版)

HTTP 短連接配接

在前面我們知道,隻要任意一方的 HTTP header 中有 Connection:close 資訊,就無法使用 HTTP 長連接配接機制,這樣在完成一次 HTTP 請求/處理後,就會關閉連接配接。

問題來了,這時候是用戶端還是服務端主動關閉連接配接呢?

在 RFC 文檔中,并沒有明确由誰來關閉連接配接,請求和響應的雙方都可以主動關閉 TCP 連接配接。

不過,根據大多數 Web 服務的實作,不管哪一方禁用了 HTTP Keep-Alive,都是由服務端主動關閉連接配接,那麼此時服務端上就會出現 TIME_WAIT 狀态的連接配接。

用戶端禁用了 HTTP Keep-Alive,服務端開啟 HTTP Keep-Alive,誰是主動關閉方?

當用戶端禁用了 HTTP Keep-Alive,這時候 HTTP 請求的 header 就會有 Connection:close 資訊,這時服務端在發完 HTTP 響應後,就會主動關閉連接配接。

為什麼要這麼設計呢?HTTP 是請求-響應模型,發起方一直是用戶端,HTTP Keep-Alive 的初衷是為用戶端後續的請求重用連接配接,如果我們在某次 HTTP 請求-響應模型中,請求的 header 定義了 connection:close 資訊,那不再重用這個連接配接的時機就隻有在服務端了,是以我們在 HTTP 請求-響應這個周期的「末端」關閉連接配接是合理的。

用戶端開啟了 HTTP Keep-Alive,服務端禁用了 HTTP Keep-Alive,誰是主動關閉方?

當用戶端開啟了 HTTP Keep-Alive,而服務端禁用了 HTTP Keep-Alive,這時服務端在發完 HTTP 響應後,服務端也會主動關閉連接配接。

為什麼要這麼設計呢?在服務端主動關閉連接配接的情況下,隻要調用一次 close() 就可以釋放連接配接,剩下的工作由核心 TCP 棧直接進行了處理,整個過程隻有一次 syscall;如果是要求 用戶端關閉,則服務端在寫完最後一個 response 之後需要把這個 socket 放入 readable 隊列,調用 select / epoll 去等待事件;然後調用一次 read() 才能知道連接配接已經被關閉,這其中是兩次 syscall,多一次使用者态程式被激活執行,而且 socket 保持時間也會更長。

是以,當服務端出現大量的 TIME_WAIT 狀态連接配接的時候,可以排查下是否用戶端和服務端都開啟了 HTTP Keep-Alive,因為任意一方沒有開啟 HTTP Keep-Alive,都會導緻服務端在處理完一個 HTTP 請求後,就主動關閉連接配接,此時服務端上就會出現大量的 TIME_WAIT 狀态的連接配接。

針對這個場景下,解決的方式也很簡單,讓用戶端和服務端都開啟 HTTP Keep-Alive 機制。

第二個場景:HTTP 長連接配接逾時

HTTP 長連接配接的特點是,隻要任意一端沒有明确提出斷開連接配接,則保持 TCP 連接配接狀态。

HTTP 長連接配接可以在同一個 TCP 連接配接上接收和發送多個 HTTP 請求/應答,避免了連接配接建立和釋放的開銷。

TCP 三次握手和四次揮手的面試題(2023最新版)

img

可能有的同學會問,如果使用了 HTTP 長連接配接,如果用戶端完成一個 HTTP 請求後,就不再發起新的請求,此時這個 TCP 連接配接一直占用着不是挺浪費資源的嗎?

對沒錯,是以為了避免資源浪費的情況,web 服務軟體一般都會提供一個參數,用來指定 HTTP 長連接配接的逾時時間,比如 nginx 提供的 keepalive_timeout 參數。

假設設定了 HTTP 長連接配接的逾時時間是 60 秒,nginx 就會啟動一個「定時器」,如果用戶端在完後一個 HTTP 請求後,在 60 秒内都沒有再發起新的請求,定時器的時間一到,nginx 就會觸發回調函數來關閉該連接配接,那麼此時服務端上就會出現 TIME_WAIT 狀态的連接配接。

TCP 三次握手和四次揮手的面試題(2023最新版)

HTTP 長連接配接逾時

當服務端出現大量 TIME_WAIT 狀态的連接配接時,如果現象是有大量的用戶端建立完 TCP 連接配接後,很長一段時間沒有發送資料,那麼大機率就是因為 HTTP 長連接配接逾時,導緻服務端主動關閉連接配接,産生大量處于 TIME_WAIT 狀态的連接配接。

可以往網絡問題的方向排查,比如是否是因為網絡問題,導緻用戶端發送的資料一直沒有被服務端接收到,以至于 HTTP 長連接配接逾時。

第三個場景:HTTP 長連接配接的請求數量達到上限

Web 服務端通常會有個參數,來定義一條 HTTP 長連接配接上最大能處理的請求數量,當超過最大限制時,就會主動關閉連接配接。

比如 nginx 的 keepalive_requests 這個參數,這個參數是指一個 HTTP 長連接配接建立之後,nginx 就會為這個連接配接設定一個計數器,記錄這個 HTTP 長連接配接上已經接收并處理的用戶端請求的數量。如果達到這個參數設定的最大值時,則 nginx 會主動關閉這個長連接配接,那麼此時服務端上就會出現 TIME_WAIT 狀态的連接配接。

keepalive_requests 參數的預設值是 100 ,意味着每個 HTTP 長連接配接最多隻能跑 100 次請求,這個參數往往被大多數人忽略,因為當 QPS (每秒請求數) 不是很高時,預設值 100 湊合夠用。

但是,對于一些 QPS 比較高的場景,比如超過 10000 QPS,甚至達到 30000 , 50000 甚至更高,如果 keepalive_requests 參數值是 100,這時候就 nginx 就會很頻繁地關閉連接配接,那麼此時服務端上就會出大量的 TIME_WAIT 狀态。

針對這個場景下,解決的方式也很簡單,調大 nginx 的 keepalive_requests 參數就行。

伺服器出現大量 CLOSE_WAIT 狀态的原因有哪些?

CLOSE_WAIT 狀态是「被動關閉方」才會有的狀态,而且如果「被動關閉方」沒有調用 close 函數關閉連接配接,那麼就無法發出 FIN 封包,進而無法使得 CLOSE_WAIT 狀态的連接配接轉變為 LAST_ACK 狀态。

是以,當服務端出現大量 CLOSE_WAIT 狀态的連接配接的時候,說明服務端的程式沒有調用 close 函數關閉連接配接。

那什麼情況會導緻服務端的程式沒有調用 close 函數關閉連接配接?這時候通常需要排查代碼。

我們先來分析一個普通的 TCP 服務端的流程:

  1. 建立服務端 socket,bind 綁定端口、listen 監聽端口
  2. 将服務端 socket 注冊到 epoll
  3. epoll_wait 等待連接配接到來,連接配接到來時,調用 accpet 擷取已連接配接的 socket
  4. 将已連接配接的 socket 注冊到 epoll
  5. epoll_wait 等待事件發生
  6. 對方連接配接關閉時,我方調用 close

可能導緻服務端沒有調用 close 函數的原因,如下。

第一個原因:第 2 步沒有做,沒有将服務端 socket 注冊到 epoll,這樣有新連接配接到來時,服務端沒辦法感覺這個事件,也就無法擷取到已連接配接的 socket,那服務端自然就沒機會對 socket 調用 close 函數了。

不過這種原因發生的機率比較小,這種屬于明顯的代碼邏輯 bug,在前期 read view 階段就能發現的了。

第二個原因:第 3 步沒有做,有新連接配接到來時沒有調用 accpet 擷取該連接配接的 socket,導緻當有大量的用戶端主動斷開了連接配接,而服務端沒機會對這些 socket 調用 close 函數,進而導緻服務端出現大量 CLOSE_WAIT 狀态的連接配接。

發生這種情況可能是因為服務端在執行 accpet 函數之前,代碼卡在某一個邏輯或者提前抛出了異常。

第三個原因:第 4 步沒有做,通過 accpet 擷取已連接配接的 socket 後,沒有将其注冊到 epoll,導緻後續收到 FIN 封包的時候,服務端沒辦法感覺這個事件,那服務端就沒機會調用 close 函數了。

發生這種情況可能是因為服務端在将已連接配接的 socket 注冊到 epoll 之前,代碼卡在某一個邏輯或者提前抛出了異常。之前看到過别人解決 close_wait 問題的實踐文章,感興趣的可以看看:一次 Netty 代碼不健壯導緻的大量 CLOSE_WAIT 連接配接原因分析

第四個原因:第 6 步沒有做,當發現用戶端關閉連接配接後,服務端沒有執行 close 函數,可能是因為代碼漏處理,或者是在執行 close 函數之前,代碼卡在某一個邏輯,比如發生死鎖等等。

可以發現,當服務端出現大量 CLOSE_WAIT 狀态的連接配接的時候,通常都是代碼的問題,這時候我們需要針對具體的代碼一步一步的進行排查和定位,主要分析的方向就是服務端為什麼沒有調用 close。

#如果已經建立了連接配接,但是用戶端突然出現故障了怎麼辦?

用戶端出現故障指的是用戶端的主機發生了當機,或者斷電的場景。發生這種情況的時候,如果服務端一直不會發送資料給用戶端,那麼服務端是永遠無法感覺到用戶端當機這個事件的,也就是服務端的 TCP 連接配接将一直處于 ESTABLISH 狀态,占用着系統資源。

為了避免這種情況,TCP 搞了個保活機制。這個機制的原理是這樣的:

定義一個時間段,在這個時間段内,如果沒有任何連接配接相關的活動,TCP 保活機制會開始作用,每隔一個時間間隔,發送一個探測封包,該探測封包包含的資料非常少,如果連續幾個探測封包都沒有得到響應,則認為目前的 TCP 連接配接已經死亡,系統核心将錯誤資訊通知給上層應用程式。

在 Linux 核心可以有對應的參數可以設定保活時間、保活探測的次數、保活探測的時間間隔,以下都為預設值:

net.ipv4.tcp_keepalive_time=7200
net.ipv4.tcp_keepalive_intvl=75  
net.ipv4.tcp_keepalive_probes=9
           
  • tcp_keepalive_time=7200:表示保活時間是 7200 秒(2小時),也就 2 小時内如果沒有任何連接配接相關的活動,則會啟動保活機制
  • tcp_keepalive_intvl=75:表示每次檢測間隔 75 秒;
  • tcp_keepalive_probes=9:表示檢測 9 次無響應,認為對方是不可達的,進而中斷本次的連接配接。

也就是說在 Linux 系統中,最少需要經過 2 小時 11 分 15 秒才可以發現一個「死亡」連接配接。

TCP 三次握手和四次揮手的面試題(2023最新版)

img

注意,應用程式若想使用 TCP 保活機制需要通過 socket 接口設定 SO_KEEPALIVE 選項才能夠生效,如果沒有設定,那麼就無法使用 TCP 保活機制。

如果開啟了 TCP 保活,需要考慮以下幾種情況:

  • 第一種,對端程式是正常工作的。當 TCP 保活的探測封包發送給對端, 對端會正常響應,這樣 TCP 保活時間會被重置,等待下一個 TCP 保活時間的到來。
  • 第二種,對端主機當機并重新開機。當 TCP 保活的探測封包發送給對端後,對端是可以響應的,但由于沒有該連接配接的有效資訊,會産生一個 RST 封包,這樣很快就會發現 TCP 連接配接已經被重置。
  • 第三種,是對端主機當機(注意不是程序崩潰,程序崩潰後作業系統在回收程序資源的時候,會發送 FIN 封包,而主機當機則是無法感覺的,是以需要 TCP 保活機制來探測對方是不是發生了主機當機),或對端由于其他原因導緻封包不可達。當 TCP 保活的探測封包發送給對端後,石沉大海,沒有響應,連續幾次,達到保活探測次數後,TCP 會報告該 TCP 連接配接已經死亡。

TCP 保活的這個機制檢測的時間是有點長,我們可以自己在應用層實作一個心跳機制。

比如,web 服務軟體一般都會提供 keepalive_timeout 參數,用來指定 HTTP 長連接配接的逾時時間。如果設定了 HTTP 長連接配接的逾時時間是 60 秒,web 服務軟體就會啟動一個定時器,如果用戶端在完成一個 HTTP 請求後,在 60 秒内都沒有再發起新的請求,定時器的時間一到,就會觸發回調函數來釋放該連接配接。

TCP 三次握手和四次揮手的面試題(2023最新版)

web 服務的 心跳機制

#如果已經建立了連接配接,但是服務端的程序崩潰會發生什麼?

TCP 的連接配接資訊是由核心維護的,是以當服務端的程序崩潰後,核心需要回收該程序的所有 TCP 連接配接資源,于是核心會發送第一次揮手 FIN 封包,後續的揮手過程也都是在核心完成,并不需要程序的參與,是以即使服務端的程序退出了,還是能與用戶端完成 TCP 四次揮手的過程。

我自己做了個實驗,使用 kill -9 來模拟程序崩潰的情況,發現在 kill 掉程序後,服務端會發送 FIN 封包,與用戶端進行四次揮手。

Socket 程式設計

針對 TCP 應該如何 Socket 程式設計?

TCP 三次握手和四次揮手的面試題(2023最新版)

基于 TCP 協定的用戶端和服務端工作

  • 服務端和用戶端初始化 socket,得到檔案描述符;
  • 服務端調用 bind,将 socket 綁定在指定的 IP 位址和端口;
  • 服務端調用 listen,進行監聽;
  • 服務端調用 accept,等待用戶端連接配接;
  • 用戶端調用 connect,向服務端的位址和端口發起連接配接請求;
  • 服務端 accept 傳回用于傳輸的 socket 的檔案描述符;
  • 用戶端調用 write 寫入資料;服務端調用 read 讀取資料;
  • 用戶端斷開連接配接時,會調用 close,那麼服務端 read 讀取資料的時候,就會讀取到了 EOF,待處理完資料後,服務端調用 close,表示連接配接關閉。

這裡需要注意的是,服務端調用 accept 時,連接配接成功了會傳回一個已完成連接配接的 socket,後續用來傳輸資料。

是以,監聽的 socket 和真正用來傳送資料的 socket,是「兩個」 socket,一個叫作監聽 socket,一個叫作已完成連接配接 socket。

成功連接配接建立之後,雙方開始通過 read 和 write 函數來讀寫資料,就像往一個檔案流裡面寫東西一樣。

listen 時候參數 backlog 的意義?

Linux核心中會維護兩個隊列:

  • 半連接配接隊列(SYN 隊列):接收到一個 SYN 建立連接配接請求,處于 SYN_RCVD 狀态;
  • 全連接配接隊列(Accpet 隊列):已完成 TCP 三次握手過程,處于 ESTABLISHED 狀态;
TCP 三次握手和四次揮手的面試題(2023最新版)

SYN 隊列 與 Accpet 隊列

int listen (int socketfd, int backlog)
           
  • 參數一 socketfd 為 socketfd 檔案描述符
  • 參數二 backlog,這參數在曆史版本有一定的變化

在早期 Linux 核心 backlog 是 SYN 隊列大小,也就是未完成的隊列大小。

在 Linux 核心 2.2 之後,backlog 變成 accept 隊列,也就是已完成連接配接建立的隊列長度,是以現在通常認為 backlog 是 accept 隊列。

但是上限值是核心參數 somaxconn 的大小,也就說 accpet 隊列長度 = min(backlog, somaxconn)。

accept 發生在三次握手的哪一步?

我們先看看用戶端連接配接服務端時,發送了什麼?

TCP 三次握手和四次揮手的面試題(2023最新版)

socket 三次握手

  • 用戶端的協定棧向服務端發送了 SYN 包,并告訴服務端目前發送序列号 client_isn,用戶端進入 SYN_SENT 狀态;
  • 服務端的協定棧收到這個包之後,和用戶端進行 ACK 應答,應答的值為 client_isn+1,表示對 SYN 包 client_isn 的确認,同時服務端也發送一個 SYN 包,告訴用戶端目前我的發送序列号為 server_isn,服務端進入 SYN_RCVD 狀态;
  • 用戶端協定棧收到 ACK 之後,使得應用程式從 connect 調用傳回,表示用戶端到服務端的單向連接配接建立成功,用戶端的狀态為 ESTABLISHED,同時用戶端協定棧也會對服務端的 SYN 包進行應答,應答資料為 server_isn+1;
  • ACK 應答包到達服務端後,服務端的 TCP 連接配接進入 ESTABLISHED 狀态,同時服務端協定棧使得 accept 阻塞調用傳回,這個時候服務端到用戶端的單向連接配接也建立成功。至此,用戶端與服務端兩個方向的連接配接都建立成功。

從上面的描述過程,我們可以得知用戶端 connect 成功傳回是在第二次握手,服務端 accept 成功傳回是在三次握手成功之後。

用戶端調用 close 了,連接配接是斷開的流程是什麼?

我們看看用戶端主動調用了 close,會發生什麼?

TCP 三次握手和四次揮手的面試題(2023最新版)

用戶端調用 close 過程

  • 用戶端調用 close,表明用戶端沒有資料需要發送了,則此時會向服務端發送 FIN 封包,進入 FIN_WAIT_1 狀态;
  • 服務端接收到了 FIN 封包,TCP 協定棧會為 FIN 包插入一個檔案結束符 EOF 到接收緩沖區中,應用程式可以通過 read 調用來感覺這個 FIN 包。這個 EOF 會被放在已排隊等候的其他已接收的資料之後,這就意味着服務端需要處理這種異常情況,因為 EOF 表示在該連接配接上再無額外資料到達。此時,服務端進入 CLOSE_WAIT 狀态;
  • 接着,當處理完資料後,自然就會讀到 EOF,于是也調用 close 關閉它的套接字,這會使得服務端發出一個 FIN 包,之後處于 LAST_ACK 狀态;
  • 用戶端接收到服務端的 FIN 包,并發送 ACK 确認包給服務端,此時用戶端将進入 TIME_WAIT 狀态;
  • 服務端收到 ACK 确認包後,就進入了最後的 CLOSE 狀态;
  • 用戶端經過 2MSL 時間之後,也進入 CLOSE 狀态;

沒有 accept,能建立 TCP 連接配接嗎?

答案:可以的。

accpet 系統調用并不參與 TCP 三次握手過程,它隻是負責從 TCP 全連接配接隊列取出一個已經建立連接配接的 socket,使用者層通過 accpet 系統調用拿到了已經建立連接配接的 socket,就可以對該 socket 進行讀寫操作了。

TCP 三次握手和四次揮手的面試題(2023最新版)

半連接配接隊列與全連接配接隊列

沒有 listen,能建立 TCP 連接配接嗎?

答案:可以的。

用戶端是可以自己連自己的形成連接配接(TCP自連接配接),也可以兩個用戶端同時向對方送出請求建立連接配接(TCP同時打開),這兩個情況都有個共同點,就是沒有服務端參與,也就是沒有 listen,就能 TCP 建立連接配接。