目錄
TCP相關實驗
理解CLOSE_WAIT狀態
理解???TIME_WAIT狀態
解決TIME_WAIT狀態引起的bind失敗的方法
理解listen的第二個參數
?編輯
使用Wireshark分析TCP通信流程
TCP與UDP
TCP與UDP對比
用UDP實現可靠傳輸(經典面試題)
TCP相關實驗
理解CLOSE_WAIT狀態
當客戶端和服務器在進行TCP通信時,如果客戶端調用close函數關閉對應的文件描述符,此時客戶端底層操作系統就會向服務器發起FIN請求,服務器收到該請求后會對其進行ACK響應。
但如果當服務器收到客戶端的FIN請求后,服務器端不調用close函數關閉對應的文件描述符,那么服務器就不會給客戶端發送FIN請求,相當于只完成了四次揮手當中的前兩次揮手(只是客戶端一方的意愿),此時客戶端和服務器的連接狀態分別會變為FIN_WAIT_2和CLOSE_WAIT。
我們可以編寫一個簡單的TCP套接字來模擬出該現象,實際我們只需要編寫服務器端的代碼,而采用一些網絡工具來充當客戶端向我們的服務器發起連接請求。
服務器的初始化需要進行套接字的創建、綁定以及監聽,然后主線程就可以通過調用accept函數從底層獲取建立好的連接了。獲取到連接后主線程創建新線程為該連接提供服務,而新線程只需執行一個死循環邏輯即可。
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <strings.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>using namespace std;const uint16_t Serverport = 8080;
const int backlog = 5;void* Routine(void* args)
{pthread_detach(pthread_self());int fd = *(int*)args;delete (int*)args;while(true){std::cout << "socket " << fd << " is serving the client" << std::endl;sleep(1);}return nullptr;
}int main()
{//創建套接字int listensockfd = socket(AF_INET, SOCK_STREAM, 0);if(listensockfd < 0){perror("create sockfd fail!!!");exit(-1);}struct sockaddr_in Server;// bzero(&Server, sizeof(Server));memset(&Server, 0, sizeof(Server));Server.sin_family = AF_INET;Server.sin_addr.s_addr = INADDR_ANY;Server.sin_port = htons(Serverport);socklen_t len = sizeof(Server);//bindint n = bind(listensockfd, (const sockaddr*)&Server, len);if(n < 0){perror("bind fail!!!");exit(-1);}//listenif(listen(listensockfd, backlog) < 0){perror("listen fail!!!");exit(-1);}cout<<"success"<<endl;struct sockaddr_in Client;memset(&Client, 0, sizeof(Client));len = sizeof(Client);for(;;){//acceptint sockfd = accept(listensockfd, (struct sockaddr*)&Client, &len);cout << sockfd << endl;if(sockfd < 0) {cout << "try request connect" << endl;continue;}cout<< "get a new link" << endl;pthread_t tid;int* p = new int(sockfd);pthread_create(&tid, nullptr, Routine, (void*)p); }return 0;
}
代碼編寫完畢后運行服務器,并用telnet工具連接我們的服務器,此時通過以下監控腳本就可以看到兩條狀態為ESTABLISHED的連接。
while :; do sudo netstat -ntp|head -2&&sudo netstat -ntp | grep 8081; sleep 1; echo "##################"; done
現在我們讓telnet退出,就相當于客戶端向服務器發起了連接斷開請求,但此時服務器端并沒有調用close函數關閉對應的文件描述符,所以當telnet退出后,客戶端維護的連接的狀態會變為FIN_WAIT_2,而服務器維護的連接的狀態會變為CLOSE_WAIT。
理解???TIME_WAIT狀態
當客戶端和服務器在進行TCP通信時,客戶端調用close函數關閉對應的文件描述符,如果服務器收到后也調用close函數進行了關閉,那么此時雙方將正常完成四次揮手。但主動發起四次揮手的一方在四次揮手后,不會立即進入CLOSED狀態,而是進入短暫的TIME_WAIT狀態等待若干時間,最終才會進入CLOSED狀態。
要讓客戶端和服務器繼續完成后兩次揮手,就需要服務器端調用close函數關閉對應的文件描述符。雖然服務器代碼當中沒有調用close函數,但因為文件描述符的生命周期是隨進程的,當進程退出的時候,該進程所對應的文件描述符都會自動關閉。
因此只需要在telnet退出后讓服務器進程退出就行了,此時服務器進程所對應的文件描述符會自動關閉,此時服務器底層TCP就會向客戶端發送FIN請求,完成剩下的兩次揮手。
四次揮手后客戶端維護的連接就會進入到TIME_WAIT狀態,而服務器維護的連接則會立馬進入到CLOSED狀態。
主動斷開連接的一方,在最后四次揮手完完成之后,要進入TIME_WAIT狀態,等待若干時長之后,自動釋放
解決TIME_WAIT狀態引起的bind失敗的方法
主動發起四次揮手的一方在四次揮手后,會進入TIME_WAIT狀態。如果在有客戶端連接服務器的情況下服務器進程退出了,就相當于服務器主動發起了四次揮手,此時服務器維護的連接在四次揮手后就會進入TIME_WAIT狀態。
在該連接處于TIME_WAIT期間,如果服務器想要再次重新啟動,就會出現綁定失敗的問題。
因為在TIME_WAIT期間,這個連接并沒有被完全釋放,也就意味著服務器綁定的port和ip正在被使用,此時服務器想要繼續綁定該端口號啟動,就只能等待TIME_WAIT結束。
但當服務器崩潰后最重要實際是讓服務器立馬重新啟動,如果想要讓服務器崩潰后在TIME_WAIT期間也能立馬重新啟動,需要讓服務器在調用socket函數創建套接字后,繼續調用setsockopt函數設置端口復用,這也是編寫服務器代碼時的推薦做法。
setsockopt函數
setsockopt函數可以設置端口復用,該函數的函數原型如下:
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
參數說明:
- sockfd:需要設置的套接字對應的文件描述符。
- level:被設置選項的層次。比如在套接字層設置選項對應就是SOL_SOCKET。
- optname:需要設置的選項。該選項的可取值與設置的level參數有關。
- optval:指向存放選項待設置的新值的指針。
- optlen:待設置的新值的長度。
返回值說明:
- 設置成功返回0,設置失敗返回-1,同時錯誤碼會被設置。
我們這里要設置的就是監聽套接字,將監聽套接字在套接字層設置端口復用選項SO_REUSEADDR,該選項設置為非零值表示開啟端口復用。
int opt = 1;
setsockopt(listen_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
此時當服務器崩潰后我們就可以立馬重新啟動服務器,而不用等待TIME_WAIT結束。
連接是由TCP管理的
從上面的實驗中可以看到,即便通信雙方對應的進程都退出了,但服務器端依然存在一個處于TIME_WAIT狀態的連接,這也更加說明了進程管理和連接管理是兩個相對獨立的單元。連接是由TCP自行管理的,連接不一定會隨進程的退出而關閉。
理解listen的第二個參數
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <strings.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>using namespace std;const uint16_t Serverport = 8081;
const int backlog = 1;int main()
{//創建套接字int listensockfd = socket(AF_INET, SOCK_STREAM, 0);if(listensockfd < 0){perror("create sockfd fail!!!");exit(-1);}int opt = 1;setsockopt(listensockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));struct sockaddr_in Server;// bzero(&Server, sizeof(Server));memset(&Server, 0, sizeof(Server));Server.sin_family = AF_INET;Server.sin_addr.s_addr = INADDR_ANY;Server.sin_port = htons(Serverport);socklen_t len = sizeof(Server);//bindint n = bind(listensockfd, (const sockaddr*)&Server, len);if(n < 0){perror("bind fail!!!");exit(-1);}//listenif(listen(listensockfd, backlog) < 0){perror("listen fail!!!");exit(-1);}cout<<"success"<<endl;struct sockaddr_in Client;memset(&Client, 0, sizeof(Client));len = sizeof(Client);for(;;){}return 0;
}
運行服務器后使用netstat -nltp
命令,可以看到該服務器當前正處于監聽狀態。
我們分別使用三個客戶機對服務器發出三次SYN請求,前二次建立連接成功,第三次請求連接時,客戶端沒有繼續新增狀態為ESTABLISHED的連接,而是新增了一個狀態為SYN_SENT的連接。
?
而對于剛才狀態為SYN_SENT的連接,由于服務器長時間不對其進行應答,三次握手失敗后該連接會被自動釋放。
總結一下上面的實驗現象:
- 無論有多少客戶端向服務器發起連接請求,最終在服務器端最多只有2個連接會建立成功。
- 當發來第3個連接請求時,服務器只是收到了該客戶端發來的SYN請求,但并沒有對其進行響應。
- 當發來更多的連接請求時,服務器會直接拒絕這些連接請求。
listen的第二個參數
實際TCP在進行連接管理時會用到兩個連接隊列:
- 全連接隊列(accept隊列)。全連接隊列用于保存處于ESTABLISHED狀態,但沒有被上層調用accept取走的連接。
- 半連接隊列。半連接隊列用于保存處于SYN_SENT和SYN_RCVD狀態的連接,也就是還未完成三次握手的連接。
而全連接隊列的長度實際會受到listen第二個參數的影響,一般TCP全連接隊列的長度就等于listen第二個參數的值加一。
因為我們實驗時設置listen第二個參數的值為2,此時在服務器端全連接隊列的長度就為3,因此服務器最多只允許有三個處于ESTABLISHED狀態的連接。
如果將剛才代碼中listen的第二個參數值設置為3,此時服務器端最多就允許存在4個處于ESTABLISHED狀態的連接。在服務器端已經有4個ESTABLISHED狀態的連接的情況下,再有客戶端發來建立連接請求,此時客戶端就會新增狀態為SYN_SENT的連接,該連接實際就是放在半連接隊列當中的。
為什么底層要維護連接隊列?
如果沒有連接隊列,當上層將連接處理完之后就需要重新等待客戶端建立新的連接,這樣效率太低了
為什么連接隊列不能太長?
全連接隊列不能太長,系統一般設置為5
雖然維護連接隊列能讓服務器處于幾乎滿載工作的狀態,但連接隊列也不能設置得太長。
- 如果隊列太長,也就意味著在隊列較尾部的連接需要等待較長時間才能得到服務,此時客戶端的請求也就遲遲得不到響應。
- 此外,服務器維護連接也是需要成本的,連接隊列設置的越長,系統就要花費越多的成本去維護這個隊列。
- 但與其與其維護一個長連接,造成客戶端等待過久,并且占用大量暫時用不到的資源,還不如將部分資源節省出來給服務器使用,讓服務器更快的為客戶端提供服務。
因此雖然需要維護連接隊列,但連接隊列不能維護的太長。
全連接隊列的長度
全連接隊列的長度由兩個值決定:
- 用戶層調用listen時傳入的第二個參數backlog。
- 系統變量net.core.somaxconn,默認值為128。
通過以下命令可以查看系統變量net.core.somaxconn的值。
sudo sysctl -a | grep net.core.somaxconn
全連接隊列的長度實際等于listen傳入的backlog和系統變量net.core.somaxconn中的較小值加一。
SYN洪水攻擊
連接正常建立的過程:
- 當客戶端向服務器發起連接建立請求后,服務器會對其進行SYN+ACK響應,并將該連接放到半連接隊列(syns queue)當中。
- 當服務器發出的SYN+ACK得到客戶端響應后,就會將該連接由半連接隊列移到全連接隊列(accept queue)當中。
- 此時上層就可以通過調用accept函數,從全連接隊列當中獲取建立好的連接了。
連接建立異常:
- 但如果客戶端在發起連接建立請求后突然死機或掉線,那么服務器發出的SYN+ACK就得不到對應的ACK應答。
- 這種情況下服務器會進行重試(再次發送SYN+ACK給客戶端)并等待一段時間,服務器并不會長時間維護,最終服務器會因為收不到ACK應答而將這個連接丟棄,這段時間長度就稱為SYN timeout。
- 在SYN timeout時間內,這個連接會一直維護在半連接隊列當中。
此時服務器雖然需要短暫維護這些異常連接,但這種情況畢竟是少數,不會對服務器造成太大影響。
但如果有一個惡意用戶故意大量模擬這種情況:向服務器發送大量的連接建立請求,但在收到服務器發來的SYN+ACK后故意不對其進行ACK應答。
- 此時服務器就需要維護一個非常大的半連接隊列,并且這些連接最終都不會建立成功,也就不會被移到全連接隊列當中供上層獲取,最后會導致半連接隊列越來越長。
- 當半連接隊列被占滿后,新來的連接就會直接被拒絕,哪怕是正常的連接建立請求,此時就會導致正常用戶無法訪問服務器。
- 這種向服務器發送大量SYN請求,但并不對服務器的SYN+ACK進行ACK響應,最終可能導致服務器無法對外提供服務,這種攻擊方式就叫做SYN洪水攻擊(SYN Flood)。
如何解決SYN洪水攻擊?
首先這一定是一個綜合性的解決方案,TCP作為傳輸控制協議需要對其進行處理,而上層應用層也要盡量避免遭到SYN洪水攻擊。
- 比如應用層可以記錄,向服務器發起連接建立請求的主機信息,如果發現某個主機多次向服務器發起SYN請求,但從不對服務器的SYN+ACK進行ACK響應,此時就可以對該主機進行黑名單認證,此后該主機發來的SYN請求一概不進行處理。
TCP為了防范SYN洪水攻擊,引入了syncookie機制:
- 現在核心的問題就是半連接隊列被占滿了,但不能簡單的擴大半連接隊列,就算半連接隊列再大,惡意用戶也能發送更多的SYN請求來占滿,并且維護半連接隊列當中的連接也是需要成本的。
- 因此TCP引入了syncookie機制,當服務器收到一個連接建立請求后,會根據這個SYN包計算出一個cookie值,將其作為將要返回的SYN+ACK包的初始序號,然后將這個連接放到一個暫存隊列當中。
- 當服務器收到客戶端的ACK響應時,會提取出當中的cookie值進行對比,對比成功則說明是一個正常連接,此時該連接就會從暫存隊列當中移到全連接隊列供上層讀取。
白話解釋:
想象服務器是個餐廳,半連接隊列是 “等叫號” 的區域。惡意用戶瘋狂發 SYN 請求,就像一堆 “假顧客” 來拿號,把等叫號的地方全占滿了。正常顧客(真實連接請求)反而沒地方,而且服務器維護這些 “假顧客”(半連接)還得花精力(成本),光擴大等號區也沒用,惡意用戶能一直塞假號
服務器收到連接請求(SYN)時,不再直接把請求放進 “等叫號區(半連接隊列)”,而是算個 “驗證碼(cookie 值)”,把它當 SYN+ACK 包的初始序號,然后把這請求臨時擱到一個 “暫存區”。
等客戶端回復 ACK 時,服務器會檢查這個 “驗證碼” 對不對:
- 要是正常客戶端(真實用戶),會乖乖帶著正確驗證碼回復,服務器驗證通過,就把這連接從 “暫存區” 挪到 “已連接隊列”,正常提供服務(就像真顧客拿號、叫號、入座)。
- 要是惡意請求(假顧客),不會回復 ACK 或者回復的驗證碼不對,這些請求就一直堆在 “暫存區”,不會占滿關鍵的半連接隊列,服務器還能正常接待真顧客。
注意:syncookie機制會跳過半連接隊列,將連接放到臨時隊列+驗證cookie的方式解決SYN洪水
引入了syncookie機制的好處:
- 引入syncookie機制后,這些異常連接就不會堆積在半連接隊列隊列當中了,也就不會出現半連接隊列被占滿的情況了。
- 對于正常的連接,一般會立即對服務器的SYN+ACK進行ACK應答,因此正常連接會很快建立成功。
- 而異常的連接,不會對服務器的SYN+ACK進行ACK應答,因此異常的連接最終都會堆積到暫存隊列當中。
使用Wireshark分析TCP通信流程
在使用Wireshark時可以通過設置過濾器,來抓取滿足要求的數據包。
針對IP進行過濾:
- 抓取指定源地址的包:ip.src == 源IP地址。
- 抓取指定目的地址的包:ip.dst == 目的IP地址。
- 抓取源或目的地址滿足要求的包:ip.addr == IP地址等價于ip.src == 源IP地址 or ip.dst == 目的IP地址。
- 抓取除指定IP地址之外的包:!(表達式)。
針對協議進行過濾:
- 抓取指定協議的包:協議名(只能小寫)。
- 抓取多種指定協議的包:協議名1 or 協議名2。
- 抓取除指定協議之外的包:not 協議名 或 !協議名。
針對端口進行過濾(以TCP協議為例):
- 抓取指定端口的包:tcp.port == 端口號。
- 抓取多個指定端口的包:tcp.port >= 2048(抓取端口號高于2048的包)。
- 針對長度和內容進行過濾:抓取指定長度的包:udp.length < 30 http.content_length <= 20。
- 抓取指定內容的包:http.request.urimatches "指定內容"。
針對長度和內容進行過濾:
- 抓取指定長度的包:udp.length < 10 http.content_length <= 20。
- 抓取指定內容的包:http.request.urimaches?”指定內容。
抓包示例
這里我們抓取指定源IP地址或目的IP地址的數據包。
當我們用telnet命令連接該服務器后,就可以抓取到三次握手時雙方交互的數據包。
而當我們退出telnet命令后,就可以抓取到四次揮手時雙方交互的數據包。(此處四次揮手時進行了捎帶應答,第二次揮手和第三次揮手合并在了一起)
TCP與UDP
TCP與UDP對比
TCP協議
TCP協議叫做傳輸控制協議(Transmission Control Protocol),TCP協議是一種面向連接的、可靠的、基于字節流的傳輸層通信協議。
TCP協議是面向連接的,如果兩臺主機之間想要進行數據傳輸,那么必須要先建立連接,當連接建立成功后才能進行數據傳輸。其次,TCP協議是保證可靠的協議,數據在傳輸過程中如果出現了丟包、亂序等情況,TCP協議都有對應的解決方法。
UDP協議
UDP協議叫做用戶數據報協議(User Datagram Protocol),UDP協議是一種無需建立連接的、不可靠的、面向數據報的傳輸層通信協議。
使用UDP協議進行通信時無需建立連接,如果兩臺主機之間想要進行數據傳輸,那么直接將數據發送給對端主機就行了,但這也就意味著UDP協議是不可靠的,數據在傳輸過程中如果出現了丟包、亂序等情況,UDP協議本身是不知道的。
TCP/UDP對比
TCP協議雖然是保證可靠性的協議,但不能說TCP就一定比UDP好,因為TCP保證可靠性也就意味著TCP需要做更多的工作,而UDP不保證可靠性也就意味著UDP足夠簡單。
- TCP常用于可靠傳輸的情況,應用于文件傳輸,重要狀態更新等場景。
- UDP常用于對高速傳輸和實時性較高的通信領域,例如早期的QQ、視頻傳輸等。另外UDP可以用于廣播。
也就是說,UDP和TCP沒有誰最好,只有誰最合適,網絡通信時具體采用TCP還是UDP完全取決于上層的應用場景。
用UDP實現可靠傳輸(經典面試題)
當面試官讓你用UDP實現可靠傳輸時,你一定要立馬想到TCP協議,因為TCP協議就是當前比較完善的保證可靠性的協議,面試官讓你用UDP這個不可靠的協議來實現可靠傳輸,無非就是讓你在應用層來實現可靠性,此時就可以參考TCP協議保證可靠性的各種機制。
例如:
- 引入序列號,保證數據按序到達。
- 引入確認應答,確保對端接收到了數據。
- 引入超時重傳,如果隔一段時間沒有應答,就進行數據重發。
- …
但TCP保證可靠性的機制太多了,當你被面試官問到這個問題時,最好與面試官進一步進行溝通,比如問問這個用UDP實現可靠傳輸的應用場景是什么。因為TCP保證可靠性的機制太多了,但在某些場景下可能只需要引入TCP的部分機制就行了,因此在不同的應用場景下模擬實現UDP可靠傳輸的側重點也是不同的。