美團二面:TCP 四次揮手,可以變成三次嗎?
美團二面: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 延遲確認機制)」,那麼第二和第三次揮手就會合併傳輸,這樣就出現了三次揮手。