一、前言
上篇文章講到了TCP通信建立連接的“三次握手”的一些細節,本文再對TCP通信斷開連接的“四次揮手”的過程做一些分析了解。
二、TCP斷開連接的“四次揮手”
我們知道TCP在建立連接的時需要“三次握手”,三次握手完后就可以進行通信了。而在通信結束的時候,TCP協議還需要進行“四次揮手”才能斷開連接。如圖
?從圖中看,?TCP的"四次揮手", 是由?兩端分別發送FIN報文, 對端再應答ACK報文?形成的
"四次揮手"的主動發起者, 可以是客戶端, 也可以是服務端,所以, "四次揮手"用主動端和被動端來區分狀態
整個過程是這樣的:
- 先發送FIN的一端(主動端A) 在接收到對端(被動端B)的ACK之后, 會進入FIN_WAIT_2狀態, 而不是直接CLOSED
- 并且, B端接收到FIN之后, 也沒有直接應答FIN關閉連接. 而是, 進入了CLOSE_WAIT狀態
- 然后, B端才發送了FIN報文, 并進入LAST_ACK狀態, 直到收到A端的ACK應答報文
- A端收到B端發送的FIN報文, 并發送ACK應答報文之后, 并沒有直接進入CLOSED狀態, 而是先進入了TIME_WAIT狀態
哪一方發送了FIN報文, 就表示這一方想要斷開連接了, 此端應用層不會再向對端發送數據了,這里所謂的不發數據指的是不發用戶數據了,并不代表底層沒有管理報文的交互。
問題1、?A端發送FIN報文之后, 為什么B端沒有直接應答FIN, 而是進入了CLOSE_WAIT狀態?
答案是, 為了維護數據傳輸的可靠性
A端向B端發送FIN報文, 表示A端不準備通信了, 實際也就表示A端應用層不會再向B端發送數據了
但是, A端沒有數據發送了, B端卻不一定. 畢竟, TCP協議是存在發送緩沖區的, 也就是說 B端可能還有數據沒有向A端發送呢, 如果直接和A端一起斷開連接, 那么還沒有發送的數據怎么辦?
所以, 雖然A端發送了FIN報文, 想要斷開連接, 但是B端可能并不想現在就斷開連接, 所以就可能不會直接應答FIN
即, 當被動端不想直接斷開連接時, 就只應答ACK報文, 并進入CLOSE_WAIT狀態
直到, 被動端也沒有要發送的數據了, 被動端才會發送FIN報文, 并進入LAST_ACK狀態
如果, 主動端發送FIN報文時, 被動端也想要斷開連接了
那么, B端就可能會應答ACK+FIN的報文
不過, 這并不是一般情況, 我們還是要討論一般情況滴
主動端接收到被動端的FIN報文之后, 向被動端應答最后一個ACK報文, 并進入TIME_WAIT狀態 持續一段時間后, 正式關閉連接
被動端在收到主動端的ACK應答報文之后, 正式關閉連接
“四次揮手”狀態分析
下面就來分析一下這四次揮手時候,每一次的揮手雙方都進入哪一種狀態,這些狀態的意義是什么?
主動端分析
主動段發送FIN報文之后,會進入FIN_WAIT_1狀態,該狀態的作用是
- 表示我已經主動發送FIN報文, 告訴對端 自己想要斷開連接
- 防止因網絡延遲或其他原因, 我沒有收到對端的ACK應答報文. 出現此情況, 還需要超時重傳FIN報文
- 進入FIN_WAIT_1狀態開始, 我就關閉了TCP發送緩沖區, 應用層不會再向對端發送數據, 同時讓對端也了解到這一點
- 此狀態持續時間, 取決于什么時候收到對端的ACK應答報文
主動端收到ACK應答報文之后,會進入?FIN_WAIT_2狀態,該狀態的作用是
- 表示我已經收到了對端的ACK應答報文
- 并了解到 對端還不想關閉連接(因為對端還沒有發送FIN), 所以 我需要保持TCP接收緩沖區不關閉, 保持此端接收數據的功能
- 此狀態持續時間, 取決于對端什么時候想要關閉連接, 即 什么時候收到對端發送的FIN報文
主動端接收到對端的FIN報文, 并作出應答之后, 會進入 TIME_WAIT 狀態?,該狀態的作用是
- 表示我已經收到了對端發送的FIN報文, 了解到對端數據也發完了, 對端也想要關閉連接了
- 此端也已經發送了ACK應答報文, 告訴對端收到了FIN報文
- 但是, 此狀態并不會直接結束, 而是會持續一段時間,原因一: 對端發送的數據可能還在傳輸中, 所以并不能馬上關閉連接,還存在著滯留的報文,必須等它們消散。原因二: 對端可能沒有收到我發送的ACK應答, 對端可能還會發送FIN報文, 我還得再次ACK應答, 保證對端收到了ACK之后 正確關閉連接
- 此狀態持續時間, 不能過長 不能過短, TCP協議推薦值為2*MSL (后面分析解釋),MSL:一個消息從C到S或者從S到C的最大傳輸單元稱為MSL。
被動端分析?
?被動端收到對端的FIN報文, 并作出應答之后, 會進入?CLOSE_WAIT?狀態,該狀態的作用是
- 表示被動端已經收到了對端的FIN報文, 知道了對端要關閉連接 并且已經關閉了發送緩沖區 不再給被動端發送數據了
- 不過, 被動端暫時還不想關閉連接, TCP發送緩沖區內還有數據沒有發送完, 所以需要維持CLOSE_WAIT?狀態
- 并且, 需要在 對端沒有收到ACK應答, 再次發送FIN報文時, 重新應答ACK報文
- 還有, 被動端收到了FIN報文, 也會向應用層關閉發送緩沖區,提醒應用層, 將write()或send()緩沖區里已經存在的數據發走之后, 就不要在發送數據了, 發送緩沖區要關閉了,不然, 還一直有數據要發送, 還要一直消耗雙方資源
此狀態持續時間, 取決于什么時候TCP發送緩沖區的數據發完, 并且與close()調用有關 (后面分析解釋)
被動端數據發送完, 調用close()發送FIN報文之后, 會進入LAST_ACK狀態,該狀態的作用是
-
表示被動端已經發送了FIN報文, 也要關閉連接了
-
等待對端的ACK應答報文, 即 需要知道 對端已經收到了被動端的FIN報文
如果長時間沒有收到對端的ACK應答報文, 被動端需要重新發送FIN報文
所以, 需要維護LAST_ACK狀態
直到收到對端的ACK應答, 才會關閉連接
在上面這5個狀態中, 有2個狀態很重要:?被動端的CLOSE_WAIT?狀態?和?主動端的TIME_WAIT 狀態?
并且, 這兩種狀態也是"四次揮手"過程中, 最容易觀察到的
三、通過實驗觀察CLOSE_WAIT?狀態和TIME_WAIT 狀態?
下面我們使用一個最簡單的TCP服務器,并通過連接它來觀察現象
#include <iostream>
#include <string>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>#define SOCKET_ERR 1
#define BIND_ERR 2
#define LISTEN_ERR 3
#define USE_ERR 4
#define CONNECT_ERR 5
#define FORK_ERR 6
#define WAIT_ERR 7#define BUFFER_SIZE 1024class tcpServer {
public:tcpServer(uint16_t port, const std::string& ip = ""): _port(port), _ip(ip), _listenSock(-1) {}void init() {_listenSock = socket(AF_INET, SOCK_STREAM, 0);if (_listenSock < 0) {// 套接字文件描述符創建失敗printf("socket() faild:: %s : %d\n", strerror(errno), _listenSock);exit(SOCKET_ERR); // 創建套接字失敗 以 SOCKET_ERR 退出}printf("socket create success: %d\n", _listenSock);// 套接字創建成功, 就需要將向 sockaddr_in 里填充網絡信息struct sockaddr_in local;std::memset(&local, 0, sizeof(local));// 填充網絡信息local.sin_family = AF_INET;local.sin_port = htons(_port);_ip.empty() ? (local.sin_addr.s_addr = htonl(INADDR_ANY)) : (inet_aton(_ip.c_str(), &local.sin_addr));// 綁定網絡信息到主機if (bind(_listenSock, (const struct sockaddr*)&local, sizeof(local)) == -1) {printf("bind() faild:: %s : %d\n", strerror(errno), _listenSock);exit(BIND_ERR);}printf("socket bind success : %d\n", _listenSock);if (listen(_listenSock, 2) == -1) {printf("listen() faild:: %s : %d\n", strerror(errno), _listenSock);exit(LISTEN_ERR);}printf("listen success : %d\n", _listenSock);// 開始監聽之后, 別的主機就可以發送連接請求了.}// 服務器初始化完成之后, 就可以啟動了void loop() {while (true) {/* sleep(1);struct sockaddr_in peer; // 輸出型參數 接受所連接主機客戶端網絡信息socklen_t peerLen = sizeof(peer); // 輸入輸出型參數int serviceSock = accept(_listenSock, (struct sockaddr*)&peer, &peerLen);if (serviceSock == -1) {printf("accept() faild:: %s : %d\n", strerror(errno), serviceSock);continue;}sleep(120);close(serviceSock); */sleep(1);}}private:uint16_t _port; // 端口號std::string _ip;int _listenSock; // 服務器套接字文件描述符
};void Usage(std::string proc) {std::cerr << "Usage:: \n\t" << proc << " port ip" << std::endl;std::cerr << "example:: \n\t" << proc << " 8080 127.0.0.1" << std::endl;
}int main(int argc, char* argv[]) {if (argc != 3 && argc != 2) {Usage(argv[0]);exit(USE_ERR);}uint16_t port = atoi(argv[1]);std::string ip;if (argc == 3) {ip = argv[2];}tcpServer svr(port, ip);svr.init();svr.loop();return 0;
}
- 需要注意的是上面的代碼所實現的服務器中, accept() 接口并沒有被調用, close()接口也沒有被調用。?
- 且listen()接口的第二個參數設置為 2
1、CLOSE_WAIT?狀態
Ⅰ、先將服務器運行起來,在沒有建立連接的前提下使用netstat命令查看系統中處于監聽(LISTEN)狀態的TCP服務,如下
?Ⅱ 、接著使用telnet命令連接服務器,如下
?可以看到無論是客戶端到服務端還是服務端到客戶端,系統都有維護連接,狀態都處于ESTABLISHED狀態。可是我們在代碼中并沒有調用accept()接口,這是為什么呢?在我們潛意識中我們都認為accept()是來同意連接請求的,但是并不是這樣的,因為即使沒有調用accept(),"三次握手"依然正常完成,雙方都進入了ESTABLISHED狀態。
這就說明“三次握手”的完成與否,實際上是和accept()無關的,accept()接口的作用只是將內核中已經與客戶端建立好連接的TCP連接數據信息,“拿”到進程中去執行。
可能很多人認為accept()接口是用來同意連接請求的, 但實際并不是的, 因為即使沒有調用accept(), "三次握手"依然是正常完成了, 雙端正常進入了ESTABLISHED狀態
所以, 這個現象說明 "三次握手"完成與否, 是與應用層是否調用accept()無關的
并且說明了, accept()接口的功能 只是將內核中已經與客戶端建立好的TCP連接數據、信息, "拿"到進程中使用
在TCP協議中,三次握手的過程是由操作系統內核層面的TCP/IP協議棧處理的,而不是由應用程序直接控制的。這意味著即使服務器端的應用程序沒有調用 accept(),客戶端與服務器之間的三次握手仍然可以順利完成,并且連接狀態可以進入ESTABLISHED。
accept() 函數的作用主要是用于從處于監聽狀態(LISTEN)的套接字上接收到來的連接請求。當有新的連接建立時(即三次握手中最后一個ACK已經收到并且連接已經建立),accept()會從已完成連接的隊列中取出一個連接,并為這個連接創建一個新的文件描述符,使得服務器可以通過這個新的文件描述符與客戶端進行數據交互。這樣做的好處之一是可以讓服務器管理多個并發的客戶端連接。
?Ⅲ 、接下來嘗試使用多個客戶端來連接此服務器
?可以看到建立的四條連接有三條都是建立成功的,只有一條的服務端的狀態為 SYN_RECY,SYN_RECY 并不是標準的TCP狀態之一,而是一個特定于某些操作系統(尤其是Linux)中的狀態標識,用來描述類似的場景。在Linux內核中,當一個半開(half-open)連接存在時,即服務器已收到SYN包并發送了SYN-ACK但尚未收到最終的ACK確認,該連接可能會被標記為SYN_RECY。實際上,它指的是處于三次握手第二步和第三步之間的一個狀態,類似于標準TCP狀態轉換圖中的SYN_RCVD。
這表示該條連接沒有建立成功,這是因為listen()接口的第二個參數設置為2,并且沒有調用accept()接口將建立好的連接拿走。
函數的第二個參數指定了處于待處理狀態的最大連接數,即那些已經完成了三次握手但還沒有被應用程序通過調用accept處理的連接數量。換句話說,它定義了等待隊列的最大長度,該隊列保存了那些等待被服務器接受(通過 accept方法)的客戶端連接。
- 當一個客戶端嘗試連接到服務器時,如果此時服務器的待處理連接數小于設置的值,則該連接會被添加到等待隊列中。
- 如果當連接請求到來時,等待隊列已滿(即當前未處理的連接數已經達到或超過了設置的數量),新的連接請求可能會被拒絕或者暫時懸置,具體行為取決于操作系統的實現。
IV、接下來我們使用客戶端對服務器進行連接,然后觀察客戶端斷開連接前后,服務端和客戶端所處的狀態。?
可以看到
- 客戶端發起連接之后,連接成功,服務端和客戶端相互維護連接,都是ESTABLISHED狀態。
- 客戶端主動關閉連接之后,服務端會進入COLSE_WAIT狀態。
- ?之后若服務端一直不停止運行,其會一直處于COLSE_WAIT狀態
- 只有服務端停止運行了,對應的連接狀態才會關閉
- 客戶端主動關閉連接之后,可以看到先是進入了FIN_WAIT2狀態,在之后,客戶端就主動關閉了連接,這個狀態也就不見了,但是我們在上面的“四次揮手”中看到,如果客戶端沒有收到服務端的FIN報文的話,不是會一直處于FIN_WAIT2狀態嗎?
? ? ? ? ? ? ? ? 理論上是這樣的,但是Linux卻在實際中并沒有這么實現,Linux中 tcp_fin_timeout 指定了在強制關閉套接字前,等待最終FIN數據包的秒數。雖然它違反了TCP的規范,但是卻是防止服務器被攻擊所必需的,在Linux2.2中,該值默認為180
? ? ? ? ? ? ??也就是, 說Linux針對 主動端 等待 被動端的FIN報文 設定了一個時間限制. 只要 主動端等待的時間 超出了這個時間限制, 主動端就會強制關閉連接
這也就是為什么, 上面 客戶端進入FIN_WAIT2一段時間之后, 突然不見了
我們也可以發現,客戶端主動關閉連接之后,服務端會一直處于COLSE_WAIT狀態,盡管客戶端已經關閉了連接,所以這就會導致服務器一直無效地占用系統的資源。這是因為服務端沒有調用close()接口來關閉socket。?
下面我們將注釋的部分打開,然后做同樣的測試,如下
從觀察、分析的結果來看, 如果被動端 不調用close() 關閉此次連接創建的socket, 那么被動端就會一直處于CLOSE_WAIT狀態, 即使 已經不會再有任何通信
這就會導致, 被動端的系統資源得不到釋放, 一直被無效占用, 所以, 無論是客戶端還是服務端, 雙端在通信完成之后, 一定要調用close()關閉socket,釋放資源
2、TIME_WAIT 狀態
可以看到在 客戶端收到服務端的FIN 報文后依然處于TIME_WAIT 狀態一段時間后才關閉連接。
為什么需要維護一個TIME_WAIT狀態?
- TIME_WAIT是"四次揮手"的主動發起方需要維持的一個狀態,我們知道, 主動端想要關閉連接, 被動端可能還存在數據未發送完畢 并不想要關閉連接,主動端是如何進入TIME_WAIT狀態的呢? 是被動端將數據發送完畢之后, 向主動端發送FIN報文, 主動端收到此報文并應答之后, 進入TIME_WAIT狀態,也就是說, 主動端向被動端 發送ACK應答報文之后, 才進入了TIME_WAIT,被動端是需要收到主動端的ACK應答報文才能正常關閉連接的, 所以主動端是需要保證 被動端確實收到了ACK應答報文的,如果, 被動端沒有收到主動端的ACK報文, 那么被動端是會重傳FIN報文的,因為, 被動端可能重傳FIN報文, 所以 主動端需要維持一段時間的TIME_WAIT狀態, 保證可能重傳的FIN報文不被漏掉
- 其次, 如果在主動端應答了ACK報文之后, 不維護TIME_WAIT狀態 直接關閉連接, 被動端也收到了ACK報文正常的關閉了連接. 但是, 實際上網絡中還有報文在有效傳輸,如果, 此時 恰好 雙端使用了相同的四元組(源IP/目的IP:源Port/目的Port)建立了新的連接,那么 新的連接有沒有可能會收到 舊的有效報文呢? 舊報文會不會影響此次的連接呢?,答案當然是有可能的. 雖然因為序號和確認序號等標識 被影響的概率很低,所以, 需要維護一段時間的TIME_WAIT狀態, 保證舊報文傳輸完畢或失效。
TIME_WAIT狀態維持的時間是多少? 為什么?
????????
在簡單分析"四次揮手"雙端狀態時, 提到過 TIME_WAIT的持續時間不能太長, 不能太短, TCP協議標準 推薦值為2*MSL
MSL(Maximum Segment Lifetime), 表示 TCP報文在網絡中的最大生存時間. 不同系統 可能設置不同的MSL, 如果一個TCP報文在網絡中傳輸的時間超過了系統的MSL, 那么此報文到達目的地時會被丟棄
TCP協議標準 推薦TIME_WAIT的持續時長為2倍MSL, 為什么呢?
因為 如果TIME_WAIT持續2*MSL的時長, 那么基本可以保證此次連接 雙方發送的報文 都已經傳輸完畢或已經失效,如果, 主動端在TIME_WAIT期間 再次收到了被動端的FIN報文, 主動端會重新發送ACK報文并進入新的TIME_WAIT
那么, 主動端發送ACK應答報文之后, 此報文會有兩種結果:
- 丟失, 即被動端一直沒收到ACK報文,此時, 被動端會一直重傳FIN, 直到達到重傳的上限,否則, 雙端的狀態一般是不會變化的,你可能會想, 如果被動端一直在重傳FIN, 但是每一個都沒有被主動端收到,然后重傳時間超出了2*MSL, 主動端都關閉連接了, 被動端還在重傳FIN,如果出現了這種情況, 那么在被動端達到重傳上限之前, 網絡中會一直存在有效的FIN報文,這怎么解決?只能說出現這種情況的概率, 非常非常低. 不過 在此種情況中, 由于被動端在達到重傳上線之前一直沒有關閉連接, 也就沒有釋放資源, 系統一直占用著四元組資源所以, 也不會出現 雙端使用相同四元組建立新連接的情況,當然, 還有最極限的一種情況: 被動端剛好達到重傳上限, 重傳了最后一個FIN報文, 剛好關閉連接釋放資源, 雙端就使用了相同的四元組建立了新的連接,此時, 網絡中還傳輸有有效的FIN報文, 新連接就又可能被影響,但是, 好像還是無法解決, 只能說出現這種情況的概率 更更更更低了,這都是非常非常非常極端的情況, 基本不需要考慮
- 被動端收到了ACK報文,那么, 以此報文可以被接收為前提, 最長的傳輸時長就是不超過MSL,并且, 被動端有可能在收到ACK的前一瞬 剛好重傳了一份FIN報文, 那么 這一份FIN在網絡中傳輸失效需要的時間就是MSL,ACK最長的傳輸時間+FIN傳輸失效需要的時間<=2*MSL,所以, TCP協議標準 推薦TIME_WAIT持續時長為2*MSL
3、setsockopt()?
TIME_WAIT持續時間太久也會無效占用系統資源,除了占用系統資源還會造成一些其他的影響。比如我們平時斷開服務器連接時,如果想要接著短時間內再次開啟服務器,就會出現連接不成功的問題,如下
這就是為什么每次斷開服務器之后短時間連接不成功的問題。
由于服務端主動關閉連接, 維持在TIME_WAIT狀態, 導致端口資源無法釋放, 耽誤服務重啟
Linux系統TIME_WAIT維持時間在60s左右
服務器在實際運行中, 如果整個服務掛掉了, 服務器建立的每一個連接都會進入TIME_WAIT, 只要有一個連接沒有正式關閉, 服務就無法使用相同的端口重啟, 難道服務要等待60s再重啟嗎?
要解決這個問題, 除了直接從系統層面做優化之外. Linux還提供了一個系統調用setsockopt()?
#include <sys/socket.h>int setsockopt(int sockfd, int level, int optname,const void *optval, socklen_t optlen);
/** sockfd, 創建的套接字* level, 協議層 可以指定協議, 這里使用 SOL_SOCKET* optname, 選項名* optval, 要設置的值/內容, 需要根據選項的實際類型進行定義和填充, 是一個輸入性參數* optlen, optval的長度/大小*/
?這個系統調用可以 對進程中的指定 socket 的行為 做出一些調整, 即 設置套接字的一些選項, 需要調用在創建套接字之后
有兩個選項, 可以使相同的服務直接使用相同的端口/IP創建套接字, 即使之前的連接還未正式關閉
這兩個選項看作布爾值, 可以直接以0/1
設置
1、SO_REUSEADDR
可以在服務進入TIME_WAIT之后, 即使 沒有正式關閉連接, 讓服務使用相同的port和IP創建socket并bind. 不過前提是, 需要是同一個服務
// 創建套接字之后
int opt = 1;
setsockopt(_listenSock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
2、SO_REUSEPORT?
允許不同服務在任何狀態下, 使用相同的port和IP創建socket并bind, 之前的服務的連接不用在TIME_WAIT狀態
// 創建套接字之后
int opt = 1;
setsockopt(_listenSock, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));
感謝閱讀!?