美團二面:TCP 四次揮手,可以變成三次嗎?

2022.08.31
美團二面:TCP 四次揮手,可以變成三次嗎?
當被動關閉方在TCP 揮手過程中,如果「沒有數據要發送」,同時「沒有開啟TCP_QUICKACK(默認情況就是沒有開啟,沒有開啟TCP_QUICKACK,等於就是在使用TCP 延遲確認機制)」,那麼第二和第三次揮手就會合併傳輸,這樣就出現了三次揮手。

大家好,我是小林,又到了愉快的周末,我來水一水。 

上周有位讀者面美團時,被問到:TCP 四次揮手中,能不能把第二次的ACK 報文, 放到第三次FIN 報文一起發送?

圖片

雖然我們在學習TCP 揮手時,學到的是需要四次來完成TCP 揮手,但是在一些情況下, TCP 四次揮手是可以變成TCP 三次揮手的。

圖片

而且在用wireshark 工具抓包的時候,我們也會常看到TCP 揮手過程是三次,而不是四次,如下圖:

圖片

先來回答為什麼RFC 文檔裡定義TCP 揮手過程是要四次?

再來回答什麼情況下,什麼情況會出現三次揮手?

為什麼TCP 揮手需要四次?

TCP 四次揮手的過程如下:

圖片

具體過程:

  • 客戶端主動調用關閉連接的函數,於是就會發送FIN 報文,這個 FIN 報文代表客戶端不會再發送數據了,進入FIN_WAIT_1 狀態;
  • 服務端收到了FIN 報文,然後馬上回復一個ACK 確認報文,此時服務端進入CLOSE_WAIT 狀態。在收到FIN 報文的時候,TCP 協議棧會為FIN 包插入一個文件結束符EOF 到接收緩衝區中,服務端應用程序可以通過read 調用來感知這個FIN 包,這個EOF 會被放在已排隊等候的其他已接收的數據之後,所以必須要得繼續read 接收緩衝區已接收的數據;
  • 接著,當服務端在read 數據的時候,最後自然就會讀到EOF,接著read() 就會返回0,這時服務端應用程序如果有數據要發送的話,就發完數據後才調用關閉連接的函數,如果服務端應用程序沒有數據要發送的話,可以直接調用關閉連接的函數,這時服務端就會發一個FIN 包,這個 FIN 報文代表服務端不會再發送數據了,之後處於LAST_ACK 狀態;
  • 客戶端接收到服務端的FIN 包,並發送ACK 確認包給服務端,此時客戶端將進入TIME_WAIT 狀態;
  • 服務端收到ACK 確認包後,就進入了最後的CLOSE 狀態;
  • 客戶端經過2MSL 時間之後,也進入CLOSE 狀態;

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

為什麼TCP 揮手需要四次呢?

服務器收到客戶端的FIN 報文時,內核會馬上回一個ACK 應答報文,但是服務端應用程序可能還有數據要發送,所以並不能馬上發送FIN 報文,而是將發送FIN 報文的控制權交給服務端應用程序:

如果服務端應用程序有數據要發送的話,就發完數據後,才調用關閉連接的函數;

如果服務端應用程序沒有數據要發送的話,可以直接調用關閉連接的函數,

從上面過程可知,是否要發送第三次揮手的控制權不在內核,而是在被動關閉方(上圖的服務端)的應用程序,因為應用程序可能還有數據要發送,由應用程序決定什麼時候調用關閉連接的函數,當調用了關閉連接的函數,內核就會發送FIN 報文了,所以服務端的ACK 和FIN 一般都會分開發送。

FIN 報文一定得調用關閉連接的函數,才會發送嗎?

不一定。

如果進程退出了,不管是不是正常退出,還是異常退出(如進程崩潰),內核都會發送FIN 報文,與對方完成四次揮手。

粗暴關閉vs 優雅關閉

前面介紹TCP 四次揮手的時候,並沒有詳細介紹關閉連接的函數,其實關閉的連接的函數有兩種函數:

  • close 函數,同時socket 關閉發送方向和讀取方向,也就是socket 不再有發送和接收數據的能力;
  • shutdown 函數,可以指定socket 只關閉發送方向而不關閉讀取方向,也就是socket 不再有發送數據的能力,但是還是具有接收數據的能力;

如果客戶端是用close 函數來關閉連接,那麼在TCP 四次揮手過程中,如果收到了服務端發送的數據,由於客戶端已經不再具有發送和接收數據的能力,所以客戶端的內核會回RST 報文給服務端,然後內核會釋放連接,這時就不會經歷完成的TCP 四次揮手,所以我們常說,調用close 是粗暴的關閉。

圖片

當服務端收到RST 後,內核就會釋放連接,當服務端應用程序再次發起讀操作或者寫操作時,就能感知到連接已經被釋放了:

  • 如果是讀操作,則會返回RST 的報錯,也就是我們常見的Connection reset by peer。
  • 如果是寫操作,那麼程序會產生SIGPIPE 信號,應用層代碼可以捕獲並處理信號,如果不處理,則默認情況下進程會終止,異常退出。

相對的,shutdown 函數因為可以指定只關閉發送方向而不關閉讀取方向,所以即使在TCP 四次揮手過程中,如果收到了服務端發送的數據,客戶端也是可以正常讀取到該數據的,然後就會經歷完整的TCP 四次揮手,所以我們常說,調用shutdown 是優雅的關閉。

圖片

但是注意,shutdown 函數也可以指定「只關閉讀取方向,而不關閉發送方向」,但是這時候內核是不會發送FIN 報文的,因為發送FIN 報文是意味著我方將不再發送任何數據,而 shutdown 如果指定「不關閉發送方向」,就意味著socket 還有發送數據的能力,所以內核就不會發送FIN。

什麼情況會出現三次揮手?

當被動關閉方(上圖的服務端)在TCP 揮手過程中,「沒有數據要發送」並且「開啟了TCP 延遲確認機制」,那麼第二和第三次揮手就會合併傳輸,這樣就出現了三次揮手。

圖片

然後因為TCP 延遲確認機制是默認開啟的,所以導致我們抓包時,看見三次揮手的次數比四次揮手還多。

什麼是 TCP 延遲確認機制?

當發送沒有攜帶數據的ACK,它的網絡效率也是很低的,因為它也有40 個字節的IP 頭和TCP 頭,但卻沒有攜帶數據報文。

為了解決ACK 傳輸效率低問題,所以就衍生出了 TCP 延遲確認。

TCP 延遲確認的策略:

  • 當有響應數據要發送時,ACK 會隨著響應數據一起立刻發送給對方
  • 當沒有響應數據要發送時,ACK 將會延遲一段時間,以等待是否有響應數據可以一起發送
  • 如果在延遲等待發送ACK 期間,對方的第二個數據報文又到達了,這時就會立刻發送ACK

圖片

延遲等待的時間是在Linux 內核中定義的,如下圖:

圖片

關鍵就需要HZ 這個數值大小,HZ 是跟系統的時鐘頻率有關,每個操作系統都不一樣,在我的Linux 系統中HZ 大小是1000,如下圖:

圖片

知道了HZ 的大小,那麼就可以算出:

  • 最大延遲確認時間是200 ms (1000/5)
  • 最短延遲確認時間是40 ms (1000/25)

怎麼關閉TCP 延遲確認機制?

如果要關閉TCP 延遲確認機制,可以在Socket 設置裡啟用TCP_QUICKACK,啟用 TCP_QUICKACK,就相當於關閉TCP 延遲確認機制。

// 1 表示开启 TCP_QUICKACK,即关闭 TCP 延迟确认机制
int value = 1;
setsockopt(socketfd, IPPROTO_TCP, TCP_QUICKACK, (char*)& value, sizeof(int));
  • 1.
  • 2.
  • 3.

實驗驗證

實驗一

接下來,來給大家做個實驗,驗證這個結論:

當被動關閉方(上圖的服務端)在TCP 揮手過程中,「沒有數據要發送」並且「開啟了TCP 延遲確認機制」,那麼第二和第三次揮手就會合併傳輸,這樣就出現了三次揮手。

服務端的代碼如下,做的事情很簡單,就讀取數據,然後當read 返回0 的時候,就馬上調用close 關閉連接。因為TCP 延遲確認機制是默認開啟的,所以不需要特殊設置。

#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <netdb.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <netinet/tcp.h>

#define MAXLINE 1024

int main(int argc, char *argv[])
{

    // 1. 创建一个监听 socket
    int listenfd = socket(AF_INET, SOCK_STREAM, 0);
    if(listenfd < 0)
    {
        fprintf(stderr, "socket error : %s\n", strerror(errno));
        return -1;
    }

    // 2. 初始化服务器地址和端口
    struct sockaddr_in server_addr;
    bzero(&server_addr, sizeof(struct sockaddr_in));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    server_addr.sin_port = htons(8888);

    // 3. 绑定地址+端口
    if(bind(listenfd, (struct sockaddr *)(&server_addr), sizeof(struct sockaddr)) < 0)
    {
        fprintf(stderr,"bind error:%s\n", strerror(errno));
        return -1;
    }

    printf("begin listen....\n");

    // 4. 开始监听
    if(listen(listenfd, 128))
    {
        fprintf(stderr, "listen error:%s\n\a", strerror(errno));
        exit(1);
    }


    // 5. 获取已连接的socket
    struct sockaddr_in client_addr;
    socklen_t client_addrlen = sizeof(client_addr);
    int clientfd = accept(listenfd, (struct sockaddr *)&client_addr, &client_addrlen);
    if(clientfd < 0) {
        fprintf(stderr, "accept error:%s\n\a", strerror(errno));
        exit(1);
    }

    printf("accept success\n");

    char message[MAXLINE] = {0};
    
    while(1) {
        //6. 读取客户端发送的数据
        int n = read(clientfd, message, MAXLINE);
        if(n < 0) { // 读取错误
            fprintf(stderr, "read error:%s\n\a", strerror(errno));
            break;
        } else if(n == 0) {  // 返回 0 ,代表读到 FIN 报文
            fprintf(stderr, "client closed \n");
            close(clientfd); // 没有数据要发送,立马关闭连接
            break;
        }

        message[n] = 0; 
        printf("received %d bytes: %s\n", n, message);
    }
 
    close(listenfd);
    return 0;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
  • 53.
  • 54.
  • 55.
  • 56.
  • 57.
  • 58.
  • 59.
  • 60.
  • 61.
  • 62.
  • 63.
  • 64.
  • 65.
  • 66.
  • 67.
  • 68.
  • 69.
  • 70.
  • 71.
  • 72.
  • 73.
  • 74.
  • 75.
  • 76.
  • 77.
  • 78.
  • 79.

客戶端代碼如下,做的事情也很簡單,與服務端連接成功後,就發送數據給服務端,然後睡眠一秒後,就調用close 關閉連接,所以客戶端是主動關閉方:

#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <netdb.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <sys/socket.h>

int main(int argc, char *argv[])
{

    // 1. 创建一个监听 socket
    int connectfd = socket(AF_INET, SOCK_STREAM, 0);
    if(connectfd < 0)
    {
        fprintf(stderr, "socket error : %s\n", strerror(errno));
        return -1;
    }

    // 2. 初始化服务器地址和端口
    struct sockaddr_in server_addr;
    bzero(&server_addr, sizeof(struct sockaddr_in));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
    server_addr.sin_port = htons(8888);
    
    // 3. 连接服务器
    if(connect(connectfd, (struct sockaddr *)(&server_addr), sizeof(server_addr)) < 0)
    {
        fprintf(stderr,"connect error:%s\n", strerror(errno));
        return -1;
    }

    printf("connect success\n");


    char sendline[64] = "hello, i am xiaolin";

    //4. 发送数据
    int ret = send(connectfd, sendline, strlen(sendline), 0);
    if(ret != strlen(sendline)) {
        fprintf(stderr,"send data error:%s\n", strerror(errno));
        return -1;
    }

    printf("already send %d bytes\n", ret);

    sleep(1);

    //5. 关闭连接
    close(connectfd);
    return 0;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
  • 53.
  • 54.

編譯服務端和客戶端的代碼:

圖片

先啟用服務端:

圖片

然後用tcpdump 工具開始抓包,命令如下:

tcpdump -i lo tcp and port 8888 -s0 -w /home/tcp_close.pcap
  • 1.

然後啟用客戶端,可以看到,與服務端連接成功後,發完數據就退出了。

圖片

此時,服務端的輸出:

圖片

接下來,我們來看看抓包的結果。

圖片

可以看到,TCP 揮手次數是3 次。

所以,下面這個結論是沒問題的。

結論:當被動關閉方(上圖的服務端)在TCP 揮手過程中,「沒有數據要發送」並且「開啟了TCP 延遲確認機制(默認會開啟)」,那麼第二和第三次揮手就會合併傳輸,這樣就出現了三次揮手。

實驗二

我們再做一次實驗,來看看關閉TCP 延遲確認機制,會出現四次揮手嗎?

客戶端代碼保持不變,服務端代碼需要增加一點東西。

在上面服務端代碼中,增加了打開了TCP_QUICKACK (快速應答)機制的代碼,如下:

圖片

編譯好服務端代碼後,就開始運行服務端和客戶端的代碼,同時用tcpdump 進行抓包。

抓包的結果如下,可以看到是四次揮手。

圖片

所以,當被動關閉方(上圖的服務端)在TCP 揮手過程中,「沒有數據要發送」,同時「關閉了TCP 延遲確認機制」,那麼就會是四次揮手。

設置TCP_QUICKACK 的代碼,為什麼要放在read 返回0 之後?

我也是多次實驗才發現,在bind 之前設置TCP_QUICKACK 是不生效的,只有在read 返回0 的時候,設置TCP_QUICKACK 才會出現四次揮手。

網上查了下資料說,設置TCP_QUICKACK 並不是永久的,所以每次讀取數據的時候,如果想要立刻回ACK,那就得在每次讀取數據之後,重新設置TCP_QUICKACK。

而我這裡的實驗,目的是為了當收到客戶端的FIN 報文(第一次揮手)後,立馬回ACK 報文,所以就在read 返回0 的時候,設置TCP_QUICKACK。

當然,實際應用中,沒人會在我這個位置設置TCP_QUICKACK,因為操作系統都通過TCP 延遲確認機制幫我們把四次揮手優化成了三次揮手了,這本來就是一件好事呀。

總結

當被動關閉方在TCP 揮手過程中,如果「沒有數據要發送」,同時「沒有開啟TCP_QUICKACK(默認情況就是沒有開啟,沒有開啟TCP_QUICKACK,等於就是在使用TCP 延遲確認機制)」,那麼第二和第三次揮手就會合併傳輸,這樣就出現了三次揮手。