目錄
一、應用層
1.1 序列化和反序列化? ? ? ??
1.2 HTTP協議? ? ? ??
1.2.1 URL
1.2.2?HTTP協議格式? ? ? ?
1.2.3?HTTP服務器示例? ? ? ?
二、傳輸層? ? ? ??
2.1?端口號? ? ? ?
2.1.1?netstat? ? ? ?
2.1.2 pidof
2.2?UDP協議
2.2.1 UDP的特點? ? ? ??
2.2.2?基于UDP的應用層協議
2.3?TCP協議
2.3.1?確認應答(ACK)機制? ? ? ?
2.3.2?超時重傳機制? ??
2.3.3?連接管理機制? ? ? ??
2.3.4?TIME_WAIT狀態? ? ? ?
2.3.5?CLOSE_WAIT 狀態? ? ? ?
2.3.6?滑動窗口? ? ? ?
2.3.7 流量控制? ? ? ??
2.3.8 擁塞控制
2.3.9 延遲應答? ? ? ??
2.3.10?面向字節流
2.3.11?粘包問題
2.3.12?TCP異常情況
2.3.13?TCP小結
extra?用UDP實現可靠傳輸
1.?引入序列號
2.?引入確認應答
3.?引入超時重傳
4.?數據包的格式
5.?重傳機制
6. 簡單示例代碼(C++)
一、應用層
????????在 OSI 七層模型中,應用層是最頂層,它直接與用戶應用程序互動。程序員寫的網絡應用程序,像是 HTTP 客戶端、FTP 客戶端等,都工作在應用層,解決具體的業務需求。而協議是通信雙方在應用層交換數據時所遵循的規則和約定,它定義了數據如何組織、如何發送、如何接收。通常,網絡通信中的數據是以字節流的形式傳輸的,這些字節流需要在發送端和接收端之間按某種約定進行解析。網絡接口(如 socket API)在發送和接收數據時,默認處理的是“字節流”或“字符串”格式。但是如果我們需要傳輸更復雜的結構化數據(比如一個對象、一個數組,或者包含多個字段的復雜數據)該怎么辦呢??????
1.1 序列化和反序列化? ? ? ??
????????例如,我們需要實現一個服務器版的加法器。我們需要客戶端把要計算的兩個加數發過去,然后由服務器進行計算,最后再把結果返回給客戶端。
方案一:直接發送表達式字符串
????????在這個方案中,客戶端將加法表達式(例如 "1+1")發送給服務器,服務器解析字符串并計算結果。然后,服務器將結果以某種格式返回給客戶端。
方案二:使用結構體序列化和反序列化
????????在這個方案中,我們不直接傳輸字符串,而是定義一個結構體來表示加法操作的信息。然后,我們將結構體序列化為字符串進行傳輸,接收方接收到字符串后進行反序列化,恢復原始數據結構進行加法計算。
struct AddRequest {int num1; // 第一個加數int num2; // 第二個加數 };struct AddResponse {int result; // 計算結果 };// 客戶端代碼 #include <iostream> #include <string> #include <sys/socket.h> #include <arpa/inet.h>struct AddRequest {int num1;int num2; };int main() {int sockfd;struct sockaddr_in server_addr;AddRequest request = {1, 1}; // 客戶端傳遞的加法請求// 創建 socketsockfd = socket(AF_INET, SOCK_STREAM, 0);if (sockfd < 0) {std::cerr << "Error creating socket!" << std::endl;return 1;}// 設置服務器地址server_addr.sin_family = AF_INET;server_addr.sin_port = htons(12345);server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");// 連接到服務器if (connect(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {std::cerr << "Connection failed!" << std::endl;return 1;}// 發送結構體數據send(sockfd, &request, sizeof(request), 0);// 接收結果char buffer[1024];int n = recv(sockfd, buffer, sizeof(buffer), 0);buffer[n] = '\0';std::cout << "Server result: " << buffer << std::endl;close(sockfd);return 0; }// 服務器端代碼 #include <iostream> #include <sys/socket.h> #include <arpa/inet.h> #include <unistd.h>struct AddRequest {int num1;int num2; };struct AddResponse {int result; };int main() {int sockfd, new_sockfd;struct sockaddr_in server_addr, client_addr;socklen_t addr_size;AddRequest request;AddResponse response;// 創建 socketsockfd = socket(AF_INET, SOCK_STREAM, 0);if (sockfd < 0) {std::cerr << "Error creating socket!" << std::endl;return 1;}// 設置服務器地址server_addr.sin_family = AF_INET;server_addr.sin_port = htons(12345);server_addr.sin_addr.s_addr = INADDR_ANY;// 綁定服務器地址if (bind(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {std::cerr << "Bind failed!" << std::endl;return 1;}// 監聽端口if (listen(sockfd, 10) == 0) {std::cout << "Server listening on port 12345..." << std::endl;} else {std::cerr << "Listen failed!" << std::endl;return 1;}// 接受客戶端連接addr_size = sizeof(client_addr);new_sockfd = accept(sockfd, (struct sockaddr*)&client_addr, &addr_size);// 接收數據recv(new_sockfd, &request, sizeof(request), 0);// 進行加法運算response.result = request.num1 + request.num2;// 發送結果send(new_sockfd, &response, sizeof(response), 0);close(new_sockfd);close(sockfd);return 0; }
????????無論是采用方案一、方案二,還是其他方案,只要確保通信雙方在發送和接收數據時遵循一定的規則(即協議),就能夠確保數據在兩端能夠正確地解析和理解。這個規則或約定就被稱為 應用層協議。????????
????????????????
1.2 HTTP協議? ? ? ??
????????雖然作為程序員,我們可以自定義應用層協議,但其實很多情況下,已經有很多成熟且經過廣泛使用的應用層協議可以供我們直接參考和使用,例如,HTTP(HyperText Transfer Protocol)就是當前互聯網應用中最廣泛使用的協議之一。
1.2.1 URL
????????平時我們常說的“網址”其實就是指 URL(統一資源定位符,Uniform Resource Locator)。URL是用來表示互聯網上資源位置的字符串,它告訴瀏覽器如何找到某個特定的網頁、文件或其他網絡資源。?例如:
????????在 URL 中,某些字符具有特定的意義。例如,
/
用于路徑分隔,?
用于分隔查詢參數,&
用于連接多個查詢參數,#
用于錨點。因此,當這些字符出現在 URL 的某個部分時,如果需要作為數據的一部分而不是特殊意義的分隔符,就必須進行 轉義,以確保它們不會引起歧義。urlencode
和urldecode
是常見的兩個操作,它們用于處理 URL 中的特殊字符。? ? ? ? ? ????????urlencode
是將字符串中的特殊字符轉換為 URL 編碼格式。它的規則是將字符轉換為 百分號編碼(Percent Encoding),也稱為 URL 編碼。這個過程將字符轉化為它們對應的 ASCII 碼的十六進制表示,并在前面加上%
符號。urldecode
是將 URL 編碼的字符串解碼回原始的字符串。這個過程會把%
后跟著的十六進制值轉換為對應的字符。原字符:Hello World! URL編碼:Hello%20World%21空格" " 轉換為 %20/ 轉換為 %2F? 轉換為 %3F: 轉換為 %3A! 被編碼為 %21 編碼規則:取字符的 ASCII 碼值(例如字符 "A" 的 ASCII 碼是 65,十六進制是 41)。將該 ASCII 碼值轉為兩位十六進制表示(即 %41)。對每個字符進行類似的編碼。
????????
1.2.2?HTTP協議格式? ? ? ?
HTTP請求格式
- 首行: [方法] + [url] + [版本]
- Header: 由多個鍵值對組成,每一組鍵值對由冒號(
:
)分隔,且每一組屬性之間使用換行符(\n
)分隔,遇到空行表示Header部分結束- Body: 空行后面的內容都是Body,Body允許為空字符串,如果Body存在,則在Header中會有一個Content-Length屬性來標識Body的長度
POST /submit HTTP/1.1 //首行 Host: www.example.com User-Agent: Mozilla/5.0 Accept: text/html, application/xhtml+xml Content-Length: 45name=John&age=30
HTTP響應格式????????
- 首行: [版本號] + [狀態碼] + [狀態碼解釋]
- Header: 由多個鍵值對組成,每一組鍵值對由冒號(
:
)分隔,且每一組屬性之間使用換行符(\n
)分隔,遇到空行表示Header部分結束- Body: 空行后面的內容都是Body,Body允許為空字符串,如果Body存在,則在Header中會有一個Content-Length屬性來標識Body的長度;如果服務器返回了一個html頁面,那么html頁面內容就是在body中
HTTP/1.1 200 OK Content-Type: text/html Content-Length: 1024 Server: Apache/2.4.1<html><head><title>Example</title></head><body><h1>Hello, World!</h1></body> </html>
HTTP的方法
方法 說明 支持的HTTP協議版本 GET 獲取資源 1.0、1.1 POST 傳輸實體主體 1.0、1.1 PUT 傳輸文件 1.0、1.1 HEAD 獲得報文首部 1.0、1.1 DELETE 刪除文件 1.0、1.1 OPTIONS 詢問支持的方法 1.1 TRACE 追蹤路徑 1.1 CONNECT 要求用隧道協議連接代理 1.1 LINK 建立和資源之間的聯系 1.0 UNLINE 斷開連接關系 1.0 HTTP的狀態碼? ? ? ??
類別 原因短語 1XX Informational(信息性狀態碼) 2XX Success(成功狀態碼) 3XX Redirection(重定向狀態碼) 4XX Client Error(客戶端錯誤狀態碼) 5XX Server Error(服務器錯誤狀態碼) ????????常見的狀態碼, 比如 200(OK), 404(Not Found), 403(Forbidden), 302(Redirect, 重定向), 504(Bad Gateway).
????????
HTTP常見Header
- Content-Type: 數據類型(text/html等);
- Content-Length: Body的長度;
- Host: 客戶端告知服務器, 所請求的資源是在哪個主機的哪個端口上;
- User-Agent: 聲明用戶的操作系統和瀏覽器版本信息;
- referer: 當前頁面是從哪個頁面跳轉過來的;
- location: 搭配3xx狀態碼使用, 告訴客戶端接下來要去哪里訪問;
- Cookie: 用于在客戶端存儲少量信息. 通常用于實現會話(session)的功能
1.2.3?HTTP服務器示例? ? ? ?
#include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <unistd.h> #include <stdio.h> #include <string.h> #include <stdlib.h> void Usage() {printf("usage: ./server [ip] [port]\n"); } int main(int argc, char* argv[]) {if (argc != 3) {Usage();return 1;}int fd = socket(AF_INET, SOCK_STREAM, 0);if (fd < 0) {perror("socket");return 1;}struct sockaddr_in addr;addr.sin_family = AF_INET;addr.sin_addr.s_addr = inet_addr(argv[1]);addr.sin_port = htons(atoi(argv[2]));int ret = bind(fd, (struct sockaddr*)&addr, sizeof(addr));if (ret < 0) {perror("bind");return 1;}ret = listen(fd, 10);if (ret < 0) {perror("listen");return 1;}for (;;) {struct sockaddr_in client_addr;socklen_t len;int client_fd = accept(fd, (struct sockaddr*)&client_addr, &len);if (client_fd < 0) {perror("accept");continue;}char input_buf[1024 * 10] = { 0 }; // 用一個足夠大的緩沖區直接把數據讀完.ssize_t read_size = read(client_fd, input_buf, sizeof(input_buf) - 1);if (read_size < 0) {return 1;}printf("[Request] %s", input_buf);char buf[1024] = { 0 };const char* hello = "<h1>hello world</h1>";sprintf(buf, "HTTP/1.0 200 OK\nContent-Length:%lu\n\n%s", strlen(hello), hello);write(client_fd, buf, strlen(buf));}return 0; }
????????如果是在云服務器上,可以先復制ssh渠道,在一端啟動服務器,另一端使用curl命令進行測試。? ? ? ??
????????
二、傳輸層? ? ? ??
傳輸層負責數據能夠從發送端傳輸到接收端。? ? ? ??
2.1?端口號? ? ? ?
????????端口號(Port)標識了一個主機上進行通信的不同的應用程序; 在TCP/IP協議中, 用 "源IP", "源端口號", "目的IP", "目的端口號", "協議號" 這樣一個五元組來標識一個通信(可以通過
netstat -n查看).端口號范圍劃分
- 0 - 1023: 常見端口號, HTTP, FTP, SSH等這些廣為使用的應用層協議, 它們的端口號都是固定的.?我們寫一個程序使用端口號時, 要避開這些知名端口號
- ssh服務器, 使用22端口
- ftp服務器, 使用21端口
- telnet服務器, 使用23端口
- http服務器, 使用80端口
- https服務器, 使用443
- 1024 - 65535: 操作系統動態分配的端口號.? 客戶端程序的端口號, 就是由操作系統從這個范圍分配的
注意:
????????一個進程可以?
bind
?多個端口號。你可以通過創建多個套接字,每個套接字綁定到不同的端口來實現。????????一個端口號通常只能被一個進程綁定。但是,在某些特定的條件下,多個進程可以綁定到相同的端口,尤其在高并發或多進程/多線程環境下。
????????
2.1.1?netstat? ? ? ?
????????netstat是一個用來查看網絡狀態的重要工具.
語法:netstat [選項]? ? ? ??
功能:查看網絡狀態
常用選項:
-t
:顯示 TCP 連接。-u
:顯示 UDP 連接。-l
:僅顯示在監聽狀態的端口。-p
:顯示哪個進程在使用相應的端口。-n
:以數字方式顯示地址和端口號,而不是將其解析為主機名和服務名。-a
:顯示所有的連接和監聽端口。-r
:顯示路由信息。-i
:顯示網絡接口的信息。-s
:顯示網絡統計信息。//netstat 的輸出示例如下(部分): Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN 1234/sshd tcp6 0 0 :::80 :::* LISTEN 5678/nginx
????????
2.1.2 pidof
????????
pidof
是一個用于查找給定程序名稱對應的進程 ID (PID) 的命令語法:pidof [進程名]
功能:通過進程名, 查看進程id
????????有時,你可能希望查看某個端口正在被哪個進程使用,這時候netstat
與pidof
可以結合使用。例如,假設你想要查看端口 8080 被哪個進程占用,你可以使用以下步驟:? ? ? ??$ netstat -tulnp | grep :8080 tcp6 0 0 :::8080 :::* LISTEN 1234/nginx$ pidof nginx 1234
????????
2.2?UDP協議
????????源端口號(Source Port):16 位,表示發送端的端口號;如果不需要返回信息,源端口可以設置為 0。
????????目的端口號(Destination Port):16 位,表示接收端的端口號。此字段由應用程序根據目標服務來設置。
????????長度(Length):16 位,表示 UDP 數據報的總長度(包括頭部和數據部分);UDP 最小長度為 8 字節(只包含頭部),最大長度為 65535 字節(最大 16 位長度)。
????????校驗和(Checksum):16 位,用于錯誤檢測;如果校驗和出錯, 就會直接丟棄。
????????數據(Data):數據部分是可變長度的,具體的大小由?
Length
?字段指定;包含應用程序需要傳輸的內容。????????
2.2.1 UDP的特點? ? ? ??
- 無連接:UDP 在數據傳輸前不需要建立連接,發送方知道接收方的 IP 地址和端口號后,就可以直接發送數據;
- 不可靠:UDP 不提供數據傳輸的確認機制,也沒有重傳機制,若數據包丟失或發生錯誤,UDP 不會向應用層報告錯誤;
- 面向數據報:UDP 以獨立的數據報形式發送數據,每個數據報都是一個獨立的單位,不能控制數據的讀取次數和數量,且數據長度固定。
? ? ? ? 在 UDP 中,應用層的數據是作為一個整體(數據報)直接傳輸的,UDP 協議會原樣發送應用層傳遞的報文,并不會對數據進行拆分或合并,具體來說:如果應用層發送 100 字節的數據,UDP 就會以 100 字節的完整數據報形式發送出去。UDP 不會將其拆分成多個小數據包,且接收端必須接收與發送端相同大小的數據。例如,發送端調用
sendto
發送 100 字節數據,那么接收端必須通過recvfrom
一次性接收 100 字節數據,不能分多次調用recvfrom
來接收數據。? ? ? ??????????
2.2.2?基于UDP的應用層協議
- NFS:允許客戶端通過網絡訪問和共享遠程服務器上的文件;
- TFTP:用于通過網絡傳輸小文件,通常用于設備啟動和固件升級;
- DHCP:自動為網絡中的設備分配 IP 地址和其他配置信息;
- BOOTP:為無盤工作站等設備提供啟動所需的網絡配置信息;
- DNS:將域名解析為 IP 地址,便于用戶訪問互聯網資源。
????????
2.3?TCP協議
字段 長度 說明 源端口 16位 表示發送端的端口號 目標端口 16位 表示接收端的端口號 序列號 32位 包含數據流中的字節序列號,表示數據段的開始位置 確認號 32位 如果ACK標志位設置為1,確認號表示接收到的下一個期望字節的序列號 數據偏移(首部長度) 4位 表示TCP頭部的長度,單位是4字節 保留字段 6位 保留為0,未來擴展使用 標志位(Flags) 6位 URG(緊急指針有效)、ACK(確認序列號有效)、PSH(推送功能)、RST(重置連接)、SYN(同步序列號)、FIN(結束連接) 窗口大小 16位 用于流量控制,表示接收窗口的大小 校驗和 16位 用于數據的校驗,確保數據傳輸過程中沒有發生錯誤 緊急指針 16位 如果URG標志位為1,緊急指針指出緊急數據的結束位置 選項 可變長度 可選字段,通常用于TCP的擴展功能(如最大段大小MSS、時間戳等) 數據 可變長度 TCP段中實際傳輸的應用數據 ????????
2.3.1?確認應答(ACK)機制? ? ? ?
????????確認應答(ACK)機制是TCP協議用來確保數據可靠傳輸的方式。簡單來說,就是接收方在收到數據后,會給發送方一個確認信號(ACK),表示它已成功接收到數據。
2.3.2?超時重傳機制? ??
? ? ? ? 那TCP如何確定超時的時間呢?TCP的超時重傳機制動態調整超時時間,以適應不同的網絡環境。理想情況下,超時時間應確保確認應答能夠在指定時間內返回,但實際應用中,網絡條件的變化會影響這一時間。如果超時時間設置過長,可能降低重傳效率;如果設置過短,可能導致重復包的發送。為保證高效的通信,TCP動態計算最大超時時間。在Linux(以及BSD Unix和Windows)系統中,超時以500ms為單位,每次重傳的超時時間按2的指數倍遞增(500ms、1000ms、2000ms等)。如果多次重傳后仍未收到應答,TCP會認為網絡或對端主機存在問題,最終強制關閉連接。? ? ? ??
2.3.3?連接管理機制? ? ? ??
服務端狀態轉化:
- [CLOSED -> LISTEN] 服務器端調用listen后進入LISTEN狀態, 等待客戶端連接;
- [LISTEN -> SYN_RCVD] 一旦監聽到連接請求, 就將該連接放入內核等待隊列中, 并向客戶端發送SYN確認報文, 響應客戶端的連接請求;
- [SYN_RCVD -> ESTABLISHED] 服務端一旦收到客戶端的確認報文, 就進入ESTABLISHED狀態, 可以進行讀寫數據了;
- [ESTABLISHED -> CLOSE_WAIT] 當客戶端主動關閉連接(調用close), 服務器會收到結束報文段, 服務器返回確認報文段并進入CLOSE_WAIT;
- [CLOSE_WAIT -> LAST_ACK] 進入CLOSE_WAIT后說明服務器準備關閉連接(需要處理完之前的數據); 當服務器真正調用close關閉連接時, 會向客戶端發送FIN, 此時服務器進入LAST_ACK狀態, 等待最后一個ACK到來(這個ACK是客戶端確認收到了FIN);
- [LAST_ACK -> CLOSED] 服務器收到了對FIN的ACK, 徹底關閉連接.
客戶端狀態轉化:
- [CLOSED -> SYN_SENT] 客戶端調用connect, 發送同步報文段;
- [SYN_SENT -> ESTABLISHED] connect調用成功, 則進入ESTABLISHED狀態, 開始讀寫數據;
- [ESTABLISHED -> FIN_WAIT_1] 客戶端主動調用close時, 向服務器發送結束報文段, 同時進入FIN_WAIT_1;
- [FIN_WAIT_1 -> FIN_WAIT_2] 客戶端收到服務器對結束報文段的確認, 則進入FIN_WAIT_2, 開始等待服務器的結束報文段;
- [FIN_WAIT_2 -> TIME_WAIT] 客戶端收到服務器發來的結束報文段, 進入TIME_WAIT, 并發出LAST_ACK;
- [TIME_WAIT -> CLOSED] 客戶端要等待一個2MSL(Max Segment Life, 報文最大生存時間)的時間, 才會進入CLOSED狀態.? ? ? ??
2.3.4?TIME_WAIT狀態? ? ? ?
????????做一個測試,首先啟動server,然后啟動client,接著用Ctrl-C使server終止,最后馬上再運行server,結果會報出這條錯誤信息:
????????bind error: Address already in use
? ? ? ? 出現該錯誤的原因是,盡管服務器應用程序已經終止,但TCP協議層的連接未完全斷開,導致端口仍然被占用。這通常是因為TCP連接關閉后,主動關閉連接的一方會進入 TIME_WAIT 狀態,等待一定時間(通常為2個最大報文生存時間,MSL),以確保網絡中的延遲報文被清除。在此期間,端口無法重新綁定或監聽。可以使用netstat
命令查看端口占用情況,確認是否有連接仍在 TIME_WAIT 狀態,或是否有其他進程在使用該端口。????????根據TCP協議,主動關閉連接的一方會進入 TIME_WAIT 狀態,并必須等待兩個MSL后才能返回 CLOSED 狀態。當通過Ctrl-C終止服務器時,服務器是主動關閉連接的一方,因此在 TIME_WAIT 狀態期間,無法重新監聽相同的端口。默認的MSL值在RFC 1122中規定為兩分鐘,但不同操作系統的實現有所不同。例如,在CentOS 7中,默認的MSL值為60秒。
可以通過命令
? ? ? ?
cat /proc/sys/net/ipv4/tcp_fin_timeout
查看當前MSL的配置值。
? ? ? ? 注意,TIME_WAIT狀態持續2個MSL(最大報文生存時間),是為了確保所有可能遲到的報文段在兩個傳輸方向上都已經消失。MSL定義了TCP報文在網絡中的最大生存時間,因此,在TIME_WAIT期間,能夠確保即使服務器重啟,也不會收到來自上一個連接的過期數據,這些數據很可能是無效的。其次,TIME_WAIT狀態還保證了最后一個報文的可靠到達。如果最后一個ACK丟失,服務器會重新發送FIN報文,即使客戶端的進程已經結束,TCP連接仍然存在,允許重發LAST_ACK,以確保連接的正常關閉。????????
????????
????????在服務器的TCP連接未完全斷開之前,無法重新監聽端口,但在某些情況下,這種限制可能不合理。例如,服務器需要處理大量客戶端連接,雖然每個連接的生存時間較短,但請求量非常大,且每秒都有大量客戶端請求。在這種情況下,如果服務器主動關閉連接(如清理不活躍的客戶端),會產生大量的 TIME_WAIT 連接。
????????由于請求量龐大,TIME_WAIT 狀態的連接數量可能很高,每個連接占用一個通信五元組(源IP、源端口、目標IP、目標端口和協議)。當新客戶端連接時,如果其目標IP、目標端口與某個 TIME_WAIT 連接的五元組重復,就會出現端口占用問題。
????????為了解決這個問題,可以通過使用
setsockopt()
設置 socket 描述符的選項SO_REUSEADDR
為 1,允許創建端口號相同但IP地址不同的多個 socket 描述符,從而避免端口被 TIME_WAIT 狀態占用。int opt = l; setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
2.3.5?CLOSE_WAIT 狀態? ? ? ?
? ? ? ? 在作者的《網絡編程套接字》博客中的4.3實現了一個TCP通用服務器,如果在代碼中刪去?new_sock.Close(); 這條語句,然后再編譯并運行服務器,啟動客戶端進行連接,檢查 TCP 狀態,發現客戶端和服務器均處于 ESTABLISHED 狀態,正常運行。當關閉客戶端程序時,觀察到服務器進入 CLOSE_WAIT 狀態。結合四次揮手的流程圖分析,可以推測四次揮手未能正確完成。
????????服務器出現大量 CLOSE_WAIT 狀態的原因是服務器未正確關閉 socket,導致四次揮手未能完成。這個問題是一個 BUG,通過在代碼中添加適當的 close 操作即可解決。
//tcp_server.hpp#pragma once #include <functional> #include "tcp_socket.hpp"// 定義一個 Handler 類型,用于處理客戶端請求和生成響應 typedef std::function<void(const std::string& req, std::string* resp)> Handler;class TcpServer { public:// 構造函數,初始化服務器的 IP 和端口TcpServer(const std::string& ip, uint16_t port) : ip_(ip), port_(port) {}// 啟動服務器,處理客戶端請求bool Start(Handler handler) {// 1. 創建監聽用的 socketCHECK_RET(listen_sock_.Socket());// 2. 綁定服務器 IP 和端口到監聽 socketCHECK_RET(listen_sock_.Bind(ip_, port_));// 3. 設置監聽隊列大小為 5CHECK_RET(listen_sock_.Listen(5));// 4. 進入事件循環,不斷接受客戶端連接for (;;) {// 5. 等待并接受客戶端的連接TcpSocket new_sock;std::string ip;uint16_t port = 0;if (!listen_sock_.Accept(&new_sock, &ip, &port)) {continue; // 接受失敗則跳過}// 輸出客戶端連接信息printf("[client %s:%d] connect!\n", ip.c_str(), port);// 6. 進入與客戶端的讀寫循環for (;;) {std::string req;// 7. 從客戶端接收請求數據,若失敗則斷開連接bool ret = new_sock.Recv(&req);if (!ret) {printf("[client %s:%d] disconnect!\n", ip.c_str(), port);// [注意!] 客戶端斷開連接時需要關閉與客戶端的 socket// new_sock.Close(); // !!!!!! 刪除這條語句 break; // 退出循環,處理下一個客戶端}// 8. 處理請求并生成響應std::string resp;handler(req, &resp);// 9. 將響應數據發送回客戶端new_sock.Send(resp);// 輸出請求和響應數據printf("[%s:%d] req: %s, resp: %s\n", ip.c_str(), port, req.c_str(), resp.c_str());}}return true; // 啟動成功}private:TcpSocket listen_sock_; // 監聽用的 TcpSocket 對象std::string ip_; // 服務器 IP 地址uint64_t port_; // 服務器端口號 };
2.3.6?滑動窗口? ? ? ?
? ? ? ??在剛才的討論中,我們提到了一種確認應答策略,即每發送一個數據段都需要等待一個 ACK 確認應答,然后再發送下一個數據段。該策略的一個主要缺點是性能較差,尤其是在數據傳輸往返時間較長時。
????????為了提高性能,我們可以一次性發送多個數據段,而不需要等待每個數據段的確認應答。通過重疊等待時間,能夠顯著提升吞吐量。圖中的窗口大小表示無需等待確認應答即可繼續發送數據的最大量(例如,4000 字節,即四個數據段)。在發送前四個數據段時,無需等待任何 ACK,直接發送;在收到第一個 ACK 后,滑動窗口向后移動,繼續發送下一個數據段,依此類推。
????????操作系統內核會通過發送緩沖區來維護滑動窗口,記錄哪些數據段還沒有收到 ACK。只有在接收到對應的 ACK 后,數據才會從緩沖區中刪除。窗口越大,能夠并行發送的數據就越多,從而提高網絡的吞吐率。
????????
當發生丟包時,重傳機制可以通過以下兩種情況進行處理:
情況一:ACK丟失
????????如果數據包已成功到達接收端,但部分 ACK 丟失,通常不會造成問題,因為發送端可以通過后續的 ACK 確認已收到的數據包。情況二:數據包丟失
????????當某一數據包丟失時,接收端會持續向發送端發送相同的 ACK(例如 "1001"),表示接收端期待重新接收該數據。若發送端連續收到三次相同的 ACK(如 "1001"),則會重新發送丟失的數據段(例如 1001 至 2000)。接收端在收到重傳數據后,會返回新的 ACK(如 "7001"),這表示接收端已成功接收到之前的 2001 至 7000 的數據,這些數據已被存儲在接收緩沖區中。????????這種機制被稱為“快重傳”或“高速重發控制”,它通過快速識別丟包并觸發重傳,有效提高數據傳輸的可靠性和效率。
2.3.7 流量控制? ? ? ??
????????接收端的處理速度是有限的。如果發送端發送數據過快,可能導致接收端的緩沖區被填滿,從而引發丟包、重傳等一系列問題。為了避免這種情況,TCP采用流量控制機制(Flow Control),根據接收端的處理能力來調整發送端的發送速率。
????????在流量控制機制中,接收端將自身可用的緩沖區大小放在 TCP 頭部的“窗口大小”字段中,通過 ACK 報文通知發送端。窗口大小越大,表示接收端能夠處理的數據量越多,網絡的吞吐量也越高。
????????當接收端發現緩沖區快滿時,會將窗口大小設置為較小的值并通知發送端,要求發送端減緩數據發送速度。如果緩沖區已滿,接收端會將窗口大小設置為0,發送端此時暫停數據發送。然而,發送端仍會定期發送窗口探測數據段,以便接收端告知當前的窗口大小,并恢復正常的數據傳輸。
????????接收端通過 TCP 頭部中的 16 位窗口字段來向發送端告知窗口大小。該字段存儲的是窗口大小的值,最大可表示 65535。然而,這并不意味著 TCP 窗口的最大大小就是 65535 字節。實際上,TCP 頭部的 40 字節選項部分還包含一個窗口擴大因子 M。實際的窗口大小是通過將窗口字段的值左移 M 位來計算的。????????
2.3.8 擁塞控制
????????擁塞控制的核心目標是 在保證可靠性和高效性的同時,盡量避免過載網絡。通過慢啟動算法快速探索帶寬,避免一開始就向網絡發送過多數據;通過慢啟動閾值、指數增長和線性增長相結合的方式,平衡網絡的吞吐量和穩定性;在發生丟包或超時時,及時調整擁塞窗口,以適應當前的網絡狀況。
????????
擁塞窗口(Congestion Window,cwnd):擁塞窗口是 TCP 協議中控制數據流量的一個重要參數。它決定了發送方每次可以發送多少數據。該值動態變化,根據網絡的擁塞狀態來調整。
慢啟動:TCP 連接初始階段,擁塞窗口從 1 開始,每收到一個確認應答(ACK),擁塞窗口大小增加 1(或按某些實現為增長一段更大的值),以指數方式增長。初期階段,TCP 慢啟動算法嘗試快速探索網絡的帶寬容量,以便盡快達到一個適合的發送速度。
慢啟動閾值(ssthresh):在慢啟動過程中,當擁塞窗口達到一個閾值(ssthresh)時,窗口增長方式會發生變化:從指數增長轉為線性增長。此時,擁塞窗口的增加速度減慢,避免網絡過度擁堵。該閾值的設置非常關鍵,合理的閾值有助于平衡吞吐量和網絡擁堵的關系。如果網絡中發生了丟包或超時重傳,通常會觸發調整閾值的操作。每次超時后,慢啟動閾值會減半,并且擁塞窗口會重新設置為 1。
擁塞控制的其他機制:快重傳和快恢復:當接收端收到重復的 ACK 時,TCP 會認為某些數據包丟失,立即觸發快速重傳,減少擁塞的延遲,并盡快恢復正常的發送速率。擁塞避免:當擁塞窗口大于慢啟動閾值時,TCP 會逐漸增加擁塞窗口的大小,通常是每經過一個 RTT(往返時延),擁塞窗口會增加 1(線性增長)。這種方式避免了指數增長帶來的過度擁堵。
網絡擁堵的感知:丟包:如果發送的數據包丟失,通常是因為網絡的擁塞。TCP 通過檢測丟包情況來判斷網絡是否發生擁塞,并相應調整發送速率。超時重傳:如果數據包的確認 ACK 在預定時間內沒有到達,TCP 會重傳該數據包并觸發擁塞控制機制,減緩數據發送速率。
2.3.9 延遲應答? ? ? ??
????????當接收端接收到數據后,通常會立即返回 ACK 來確認接收情況。但如果每次都立刻發送 ACK,網絡上的 ACK 包數量就會增加,造成帶寬浪費。在延遲應答中,接收端會在一定時間內或在收到一定數量的數據后再發送 ACK。這可以讓窗口的大小更大,從而提高吞吐量和傳輸效率。
????????延遲應答有兩種限制:數量限制:即在接收到一定數量的數據包后才進行 ACK 應答。例如,接收端可能設定每接收到 2 個數據包才返回一次 ACK。時間限制:即如果接收端在某段時間內沒有收到新的數據包,它會在設定的最大延遲時間(如 200ms)內發送 ACK。
????????延遲應答可以使接收端能夠在一個 ACK 中傳遞更大的窗口大小。由于接收端在延遲應答時已經處理掉一部分數據,它可以返回一個更大的窗口大小,允許發送方發送更多的數據。更大的接收窗口意味著可以發送更多數據,從而增加吞吐量。但這必須在不導致網絡擁塞的情況下進行,否則會產生反效果。
????????
????????捎帶應答(Piggybacking)是一種優化策略,通常在延遲應答的基礎上進行。它通過將 ACK 消息和數據消息一起發送,從而減少網絡中獨立 ACK 的數量。具體來說,當客戶端發送請求并等待服務器的回應時,服務器不僅僅會響應請求的數據,還可以在回應數據的同時,返回客戶端的 ACK 消息。
????????
2.3.10?面向字節流
????????創建一個 TCP 套接字時,內核會同時為該套接字分配一個發送緩沖區和一個接收緩沖區。當調用
write
函數時,數據首先會被寫入發送緩沖區。如果待發送的數據字節數較大,系統會將數據分割成多個 TCP 數據包發送;如果數據量較小,則會在緩沖區中等待,直到積累到一定的長度或其他合適的時機,再進行發送。????????接收數據時,數據通過網卡驅動程序傳入內核的接收緩沖區,然后應用程序可以調用
read
函數從接收緩沖區讀取數據。????????TCP 連接的特點是,每個連接都有獨立的發送緩沖區和接收緩沖區,因此在同一連接中,既可以進行數據的讀取,也可以進行數據的寫入,這種機制被稱為全雙工通信。
????????由于緩沖區的存在,TCP 的讀寫操作不需要嚴格匹配。例如,向緩沖區寫入 100 字節數據時,可以通過一次
write
操作完成,也可以通過 100 次write
操作,每次寫入一個字節。同樣,讀取 100 字節數據時,應用程序不需要關心數據是如何寫入的,可以一次性調用read
讀取 100 字節,也可以調用 100 次read
,每次讀取一個字節。2.3.11?粘包問題
????????在 TCP 中,由于其流式傳輸的特性,數據以字節流的方式進行傳送,并沒有明確的“邊界”來分隔不同的數據包。這就導致了應用層無法直接區分哪些字節屬于同一個數據包,哪些字節屬于另一個數據包。為了解決這個問題,應用層需要通過以下方式來明確邊界:
- 固定長度包:對于定長的數據包,應用層可以按照固定大小讀取緩沖區的內容。例如,如果每個數據包都是 1024 字節,應用程序可以每次讀取 1024 字節來獲得一個完整的數據包。
- 包頭包含數據長度:對于變長數據包,應用層可以在包頭中預留一個字段來存儲該包的總長度。這樣,在接收數據時,就可以通過讀取包頭來知道整個數據包的長度,從而確保正確讀取完整數據包。
- 特殊分隔符:應用層也可以設計特定的分隔符來分隔不同的數據包,只要這些分隔符在數據正文中不會出現。
? ? ? ?在 UDP 中,由于每個 UDP 數據包都是獨立傳輸的,并且 UDP 本身是面向數據報的協議,存在一個很明確的數據邊界。因此,UDP 不存在類似 TCP 中的粘包問題。
2.3.12?TCP異常情況
????????進程終止:當一個進程終止時,操作系統會釋放它所持有的文件描述符,但這并不意味著 TCP 連接立刻關閉。系統會發送一個?FIN?包,表示連接的另一端可以開始進行正常的連接關閉流程。與正常的 TCP 連接關閉類似,進程終止不會導致連接立即被重置。
????????機器重啟:機器重啟和進程終止的情況類似,所有與該機器相關的網絡連接都會被丟棄。當機器重啟時,連接會被強制斷開,接收方依然認為連接存在,直到它嘗試進行數據寫入時才會發現連接已經不存在,通常會收到?RST?包(重置連接)作為響應。
????????機器掉電/網線斷開:機器掉電或者網線斷開時,接收端會認為連接依然存在,并不會立刻察覺到連接已中斷。等到接收端嘗試進行寫入操作時,它會發現連接已經斷開,這時 TCP 會發送?RST?包來重置連接。在這種情況下,接收端可能通過定期探測來判斷連接的存活狀態。比如 TCP 有內置的?保活定時器,用于檢查連接是否還存活。如果檢測到連接已經斷開,它會自動釋放該連接。
????????應用層協議檢測:很多應用層協議(例如 HTTP 長連接)也會實現定期的心跳檢測機制,用于確保連接的有效性。例如,HTTP 長連接會定期發送?ping/pong?消息,或者通過設置超時時間來檢測連接是否仍然有效。即使在即時通訊軟件(如 QQ)中,斷開連接后,客戶端通常會嘗試周期性地重新連接。
2.3.13?TCP小結
????????既要保證可靠性,同時又盡可能的提高性能,這就注定了TCP非常復雜。
????????可靠性機制:校驗和、序列號(按序到達)、確認應答、超時重發、連接管理、流量控制、擁塞控制;
????????提高性能:滑動窗口、快速重傳、延遲應答、捎帶應答;
????????基于TCP應用層協議:HTTP、HTTPS、SSH、Telnet、FTP、SMTP。
extra?用UDP實現可靠傳輸
????????使用 UDP 實現可靠傳輸協議的核心思路是參考 TCP 的可靠性機制,包括順序控制、確認應答、超時重傳等。因為 UDP 本身并不保證數據的可靠傳輸,所以我們可以在應用層設計和實現這些機制。
? ? ? ??
1.?引入序列號
????????為了保證數據按順序傳輸,需要給每個數據包分配一個序列號。接收方根據序列號來確定數據包的順序。
- 每個數據包都包含一個?序列號。
- 接收方接收到數據包后,檢查序列號,如果序列號順序正確,進行處理;否則,丟棄或請求重發。
2.?引入確認應答
????????每個數據包傳輸后,接收方會發送一個確認應答(ACK)給發送方,表示該數據包已成功接收。如果發送方在超時前沒有收到確認,應當進行重傳。
- 每個數據包發送后,發送方等待接收方的確認。
- 如果接收方收到數據包且順序正確,發送一個?ACK?確認消息給發送方。
- 如果發送方未在規定時間內收到確認,則重新發送數據包。
3.?引入超時重傳
????????超時重傳機制是可靠傳輸協議的核心。發送方會設置一個 定時器,在一定時間內等待確認應答。如果超時沒有收到確認,重新發送數據包。
- 每發送一個數據包后,啟動定時器。
- 如果在定時器超時前收到了確認應答,停止定時器。
- 如果超時仍未收到確認,重發數據包。
4.?數據包的格式
????????每個數據包的格式需要包括以下字段:
- 序列號:標識數據包的順序。
- 數據:傳輸的實際數據。
- 確認號(可選):如果是確認應答包,表示接收到的數據包的序列號。
- 校驗和:保證數據的完整性。
5.?重傳機制
????????我們還需要一個 滑動窗口 機制,用來控制哪些數據包已經成功接收,并允許發送方根據接收方的確認信息進行有序重傳。
6. 簡單示例代碼(C++)
#include <iostream> #include <string> #include <chrono> #include <thread> #include <iomanip> #include <cstring> #include <cstdlib> #include <unistd.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h>#define PORT 12345 #define MAX_RETRIES 5 #define TIMEOUT 2 // 超時設置為2秒// 發送方 void send_data(const std::string &data, const std::string &receiver_ip) {int sockfd = socket(AF_INET, SOCK_DGRAM, 0);if (sockfd < 0) {std::cerr << "Socket creation failed!" << std::endl;return;}struct sockaddr_in receiver_addr;receiver_addr.sin_family = AF_INET;receiver_addr.sin_port = htons(PORT);receiver_addr.sin_addr.s_addr = inet_addr(receiver_ip.c_str());int seq_num = 0;int retries = 0;while (retries < MAX_RETRIES) {// 構造數據包:序列號 + 數據std::string packet = std::to_string(seq_num) + ":" + data;// 發送數據包ssize_t sent = sendto(sockfd, packet.c_str(), packet.size(), 0, (struct sockaddr*)&receiver_addr, sizeof(receiver_addr));if (sent < 0) {std::cerr << "Failed to send data!" << std::endl;break;}std::cout << "Sent: " << packet << std::endl;// 設置接收超時struct timeval tv;tv.tv_sec = TIMEOUT;tv.tv_usec = 0;setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, (const char*)&tv, sizeof(tv));// 等待確認char ack_buffer[1024];socklen_t len = sizeof(receiver_addr);ssize_t recv_len = recvfrom(sockfd, ack_buffer, sizeof(ack_buffer), 0, (struct sockaddr*)&receiver_addr, &len);if (recv_len >= 0) {ack_buffer[recv_len] = '\0'; // 確保字符串結束int ack_seq_num = std::stoi(ack_buffer); // 獲取確認號std::cout << "Received ACK: " << ack_seq_num << std::endl;// 如果確認號與發送的序列號一致,表示確認成功if (ack_seq_num == seq_num) {std::cout << "Data successfully acknowledged." << std::endl;break; // 數據發送成功,退出重傳} else {std::cout << "Incorrect ACK received." << std::endl;}} else {std::cout << "Timeout, resending..." << std::endl;retries++;seq_num++; // 增加序列號,準備重發}}if (retries == MAX_RETRIES) {std::cout << "Max retries reached. Failed to send data." << std::endl;}close(sockfd); }// 接收方 void receive_data() {int sockfd = socket(AF_INET, SOCK_DGRAM, 0);if (sockfd < 0) {std::cerr << "Socket creation failed!" << std::endl;return;}struct sockaddr_in server_addr;server_addr.sin_family = AF_INET;server_addr.sin_port = htons(PORT);server_addr.sin_addr.s_addr = INADDR_ANY;if (bind(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {std::cerr << "Bind failed!" << std::endl;close(sockfd);return;}int expected_seq_num = 0;while (true) {char buffer[1024];struct sockaddr_in client_addr;socklen_t len = sizeof(client_addr);ssize_t recv_len = recvfrom(sockfd, buffer, sizeof(buffer), 0, (struct sockaddr*)&client_addr, &len);if (recv_len < 0) {std::cerr << "Failed to receive data!" << std::endl;continue;}buffer[recv_len] = '\0'; // 確保字符串結束std::string received_data(buffer);size_t colon_pos = received_data.find(":");if (colon_pos != std::string::npos) {int seq_num = std::stoi(received_data.substr(0, colon_pos));std::string content = received_data.substr(colon_pos + 1);// 如果序列號匹配,處理數據并發送確認if (seq_num == expected_seq_num) {std::cout << "Data received: " << content << std::endl;expected_seq_num++; // 更新期望的序列號// 發送確認消息std::string ack = std::to_string(seq_num);sendto(sockfd, ack.c_str(), ack.size(), 0, (struct sockaddr*)&client_addr, len);} else {std::cout << "Out-of-order packet. Expected " << expected_seq_num << ", but got " << seq_num << "." << std::endl;std::string ack = std::to_string(expected_seq_num - 1); // 發送最后一次成功的確認sendto(sockfd, ack.c_str(), ack.size(), 0, (struct sockaddr*)&client_addr, len);}}}close(sockfd); }int main() {std::thread receiver_thread(receive_data);std::this_thread::sleep_for(std::chrono::seconds(1)); // 等待接收方啟動send_data("Hello, UDP!", "127.0.0.1");receiver_thread.join();return 0; }
????????
????????
????????
????????
????????
????????
????????