網絡編程-再看TCP的四次揮手
前言
四次揮手
四次揮手的流程在很多地方都可以看到,這里簡略介紹一下,其常見流程如下圖所示:
其大體流程如下:
- 客戶端發其結束請求,發送seq=X,處于FIN_WAIT_1狀態
- 服務端收到結束請求,發送應答ACK=X+1,處于CLOSE_WAIT狀態
- 客戶端收到X的應答后,處于FIN_WAIT_2狀態,此時還可以接收來自服務端的數據
- 服務端沒有數據要發送,也發送結束請求,seq=Y,處于LAST_ACK狀態
- 客戶端又收到服務端的結束請求,客戶端回應ACK,此時處于TIME_WAIT狀態,確保ACK能夠到達服務端;服務端收到客戶端最終ACK,關閉連接。
- 2MSL時間結束后,無論服務端是否收到最終ACK,客戶端完全結束連接
作為一種常見的四次揮手場景,我們可能習以為常了,但需要注意的是,連接的斷開并不只有這種情況,還可以是服務端發起主動關閉,或者雙方同時發起,但這不是本文關注的重點。我們直接看看四次揮手有哪些需要注意的。
什么是TCP的半關閉
TCP半關閉指的是一端結束發送后還能夠接受來自另一端的數據。也就是說,雖然客戶端準備斷開連接并且發送了FIN報文,客戶端還是可以接收來自服務端的數據。不過這種關閉方式不能使用close接口,而需要使用shutdown:
- #include <sys/socket.h>
- int shutdown(int sockfd, int how);
并且how參數值為SHUT_WR,即1,表明shutdown for writing ,僅關閉本端的發送。
為什么要四次揮手
為什么建立一個TCP連接需要三次握手,而終止一個連接需要四次揮手呢?這是因為TCP半關閉造成的。由于一個TCP連接是全雙工的,在兩個方向上都能傳輸數據,因此兩個方向就需要單獨關閉。所以這個流程是這樣的:
- 客戶端執行主動關閉,發送FIN報文,告訴服務端,我沒有數據要發送了,我要關閉連接,當然了,你有啥數據要給我,我隨時候著
- 服務端收到后,必須及時告訴客戶端我收到了,因此先回復客戶端一個ACK。但是服務端可能還有未發送完的數據,因此它可以將自己未完成的數據進行發送,發送完成之后,再發送給客戶端FIN報文,表明我也沒啥要發送的了,關閉吧
- 客戶端收到后,也回復ACK響應,最終關閉連接
因而整個過程需要四次揮手。
為什么要TIME_WAIT狀態
TIME_WAIT也稱為2MSL等待時間。MSL為報文最大生存時間,它是任何報文在被丟棄前存在于網絡內的最長時間。這個時間在不同類型的系統中可能有所不同,但這不是關鍵。在我個人的機器上,可以借助netstat命令和nc命令通過下面的方式觀察到。在終端1監聽1234端口:
- $ nc -l 1234
在終端2連接到1234端口:
- $ nc 127.0.0.1 1234
在終端3通過netstat命令觀察:
- $ netstat -anpoc|grep :1234
然后在終端1按ctrl+c,終止連接,立刻觀察終端3的結果,我們發現:
- tcp 0 0 127.0.0.1:1234 127.0.0.1:33524 TIME_WAIT - timewait (59.76/0/0)
- tcp 0 0 127.0.0.1:1234 127.0.0.1:33524 TIME_WAIT - timewait (58.74/0/0)
- tcp 0 0 127.0.0.1:1234 127.0.0.1:33524 TIME_WAIT - timewait (57.71/0/0)
- tcp 0 0 127.0.0.1:1234 127.0.0.1:33524 TIME_WAIT - timewait (56.69/0/0)
我們可以觀察到,服務端當前處于TIME_WAIT,且有一個timewait的定時器,為1分鐘。
netstat命令和nc命令的使用可以分別參考《不可不知的網絡命令-netstat》和《網絡工具中的”瑞士軍刀“-nc》。
TIME_WAIT狀態的存在主要考慮以下兩個方面:
- 實現可靠的四次揮手
- 避免收到老的報文
為什么說TIME_WAIT是為了實現可靠的四次揮手呢?試想一下,如果客戶端最后回應的ACK丟了,那么服務端會再次發送FIN報文,此時,客戶端必須處于一個等待狀態,否則服務端永遠無法收到這個ACK,而會收到一個RST,以為出錯。而如果客戶端此時處于TIME_WAIT狀態,即等待2MSL時間,它還可以再次回應服務端ACK。這也就保證了可靠的四次揮手。
當然了,如果在2MSL時間內,服務端還沒有收到,那么對不起,客戶端已經仁至義盡了,不會再等待了。
這里需要注意,最終執行主動關閉的那一端會處于TIME_WAIT狀態。
那么為什么又說是為了避免收到老的重復報文呢?
試想這樣的場景:
假設一開始已經有一個連接在1234端口建立,我們關閉這個連接;過一會我們在同樣的ip和端口建立連接,但是TCP必須防止在前一次連接中的老的報文在它原先的連接已終止后,還出現在這個新的連接中,因此,TCP將不允許在處于TIME_WAIT狀態的ip和端口處建立新的連接。而2MSL時間過后,老的報文早已在網絡中消失了,也就避免了這種情況的發生。
這種情況可以很容易通過《網絡編程-一個簡單的echo程序》的server程序來觀察:
- $ ./server #在一個終端啟動server,
- $ ./client 127.0.0.1 1234 #在另一個終端啟動client
在服務端終端ctrl+c終止服務端,然后再次啟動server:
- $ ./server
- bind error: Address already in use
- $ netstat -anop|grep :1234
- tcp 1 0 127.0.0.1:33722 127.0.0.1:1234 CLOSE_WAIT 11691/client off (0.00/0/0)
- tcp 0 0 127.0.0.1:1234 127.0.0.1:33722 FIN_WAIT2 - timewait (57.92/0/0)
終止服務端后,服務端處于TIME_WAIT狀態,此時再次啟動server,將不能使用原來的ip和端口建立連接,因此出現Address already in use的報錯。
但是需要注意:
- 由于客戶端通常使用的是臨時端口(仔細觀察會發現,客戶端每次啟動使用的端口基本都不一樣),因此客戶端即便處于TIME_WAIT狀態,也不影響它馬上再次啟動
- 一些實現允許一個新的連接請求仍然處于TIME_WAIT狀態的連接,只要新的seq大于該連接的前一個連接的最后序號
- 通過設置選項SO_REUSEADDR,可以讓一個進程重新使用仍處于TIME_WAIT狀態的socket
半打開的TCP連接
假設一個連接建立之后,突然有一方異常終止連接了,但是另一個不知道,這個時候TCP的連接就是半打開的。如果服務端不加處理,那么最終就會導致服務端有大量的半打開連接。那么服務端如何知道客戶端的連接已經異常終止了呢?如果等待服務端發送數據出錯時發現,那么這個時候可能已經太晚了。
幸運的是,TCP有保活定時器。即服務端可以通過設置保活選項來了解客戶端是否已經終止連接。
通過下面的方式可以看到很多連接有這樣的定時器:
- $ netstat -npo|grep keepalive
- tcp 0 0 192.168.0.103:50832 59.111.179.136:443 ESTABLISHED 5882/chrome keepalive (37.33/0/0)
- tcp 0 0 192.168.0.103:50638 154.8.131.191:443 ESTABLISHED 5882/chrome keepalive (0.00/0/0)
- tcp 0 0 192.168.0.103:59330 203.107.41.32:9026 ESTABLISHED 5882/chrome keepalive (0.35/0/0)
- tcp 0 0 127.0.0.1:45632 127.0.0.1:1080 ESTABLISHED 5886/firefox keepalive (335.28/0/0)
- tcp 0 0 192.168.0.103:49940 59.56.78.189:443 ESTABLISHED 5882/chrome keepalive (26.36/0/0)
但可惜的是,這樣的定時器時間太長了,并且它不能代表應用程序能夠正常工作,能夠正常收發數據,因此應用層常常也會實現一個心跳機制。
總結
本文花了大量篇幅介紹了TIME_WAIT狀態,這也是面試中常問的問題,重新梳理TCP的四次揮手是很有必要的。