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

作者:小林 coding

八股文網站:xiaolincoding.com

大家好,我是小林。

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

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

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

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

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

爲什麼 TCP 揮手需要四次?

TCP 四次揮手的過程如下:

具體過程:

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

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

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

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

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

不一定。

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

粗暴關閉 vs 優雅關閉

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

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

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

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

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

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

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

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

什麼是  TCP 延遲確認機制?

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

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

TCP 延遲確認的策略:

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

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

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

怎麼關閉 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));

實驗驗證

實驗一

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

當被動關閉方(上圖的服務端)在 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;
}

客戶端代碼如下,做的事情也很簡單,與服務端連接成功後,就發送數據給服務端,然後睡眠一秒後,就調用 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;
}

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

先啓用服務端:

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

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

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

此時,服務端的輸出:

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

可以看到,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 延遲確認機制)」,那麼第二和第三次揮手就會合並傳輸,這樣就出現了三次揮手。

所以,出現三次揮手現象,是因爲 TCP 延遲確認機制導致的。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/JUt-N0VxF_lXACFr6WJN8w