從Linux內核角度看TCP粘包
今天我們來學習TCP粘包,TCP粘包是工作和面試中經常會碰到的問題。網上有一些資料也介紹過TCP粘包,但是這些資料更多的講述的是TCP粘包的解決方案,并沒有對問題的原因做詳細的介紹。
本文從內核的角度出發,將TCP粘包原因和解決方案給大家講清楚。
1.快速了解TCP粘包
TCP粘包是指發送方多次發送的TCP數據在接收方被合并成一個數據包。這種現象會導致接收方無法準確區分每個數據包的邊界,從而影響數據的正確解析。
圖1 TCP粘包
如圖1所示,發送方執行了send操作,分別發送字符串“aaaaaaaaaa”、“bbbbbbbbbb”、“cccccccccc”。對于發送端而言,這三組數據都是獨立的消息,不能合并在一起。由于采用TCP協議傳輸數據,接收端可能只接收到一個數據包“aaaaaaaaaabbbbbbbbbbcccccccccc”,顯然這個數據包接收端是無法解析的。
TCP粘包的原因在于TCP協議是面向字節流的協議,TCP將數據視為連續的字節流,不保留消息邊界。TCP的這種特性會導致數據包粘在一起。
為什么UDP不會出現粘包現象?
UDP不會出現粘包現象,因為它是一種面向報文的協議,用戶消息會被封裝為獨立的UDP數據報進行傳輸。
圖2 UDP不粘包
如圖2所示,UDP同樣執行三次sendto操作,每次發送都會生成一個UDP數據報。UDP接收端接收數據時,需要執行三次recvfrom操作才能接收所有UDP數據報。UDP不粘包的原因為:不管每次發送的數據的長度為多大,UDP都會將發送的數據當成一個UDP數據報來處理,發送和接收數據的基本單位為UDP數據報。
2.內核如何發送TCP數據包
初步了解了TCP粘包后,我們基于TCP粘包問題來深入理解內核是如何收發TCP數據包的。首先來了解發送過程,如圖3所示。
圖3 內核發送TCP數據包
我們可以把發送過程分為兩個部分:
- 部分1:TCP數據從用戶緩沖區拷貝至套接字發送緩沖區。
- 部分2:TCP數據從套接字發送緩沖區發送至網卡。
首先來看第1個部分,客戶端執行一次send操作,如發送字符串“aaaaaaaaaa”,數據傳輸至內核后,內核首先會找到TCP套接字,然后將數據存儲至其發送緩沖區。
TCP套接字發送緩沖區是一個由skb組成的雙向鏈表,每個skb的存儲空間通常為1-N倍MSS(MSS通常1460字節)長度。用戶程序每次發送的數據長度是不確定的。如果用戶數據長度小于skb剩余存儲空間時,一個skb就能夠存儲用戶數據;否則,需要多個skb才能存儲用戶數據。
用戶數據存儲完畢后,內核會嘗試將套接字發送緩沖區的數據發送至網卡。注意:嘗試發送說明并不是每次都會發送數據。內核輪詢套接字發送緩沖區鏈表,每次輪詢取出一個skb。取出來的skb并不會立即發送,內核需要判斷是否滿足發送條件才能發送,判斷條件有4個:流量控制、擁塞窗口、Nagle算法、小隊列。
- 流量控制:TCP pacing(速率控制),TCP為了避免網絡突發流量導致網絡擁塞,需要對發送速率進行控制,在速率控制期間,內核不會發送skb。
- 擁塞窗口:檢查當前擁塞窗口(cwnd)是否允許發送skb。
- Nagle算法:算法核心通過限制小數據包的發送頻率,提高網絡效率。Nagle算法生效后,內核不會發送小的skb。想要詳細了解Nagle算法,可參考我的這篇文章:一文搞懂TCP Nagle算法。
- 小隊列:TCP Small Queues (TSQ)機制,控制qdisc/設備隊列中的數據包數量在2個數據包左右,或相當于約1ms的數據量。TSQ生效后,內核不會發送skb。
注意,以上4個條件都是TCP粘包的原因,所以并不只有Nagle算法會導致TCP粘包。當有一個條件沒有滿足時,內核會退出輪詢,結束skb發送。由于本次存儲在套接字發送緩沖區的數據未發送出去,還停留套接字發送緩沖區。用戶程序第二次發送的數據“bbbbbbbbbb”將會和第一次發送的數據粘在一起。第二次發送如果滿足發送skb的4個條件,內核會嘗試將套接字發送緩沖區的數據發送至網卡(每發送一個skb都需要再次判斷4個條件);否則,第二次發送的數據也會停留在套接字發送緩沖區。后續發送操作依此類推。
接下來,我們來介紹第2部分:TCP數據從套接字發送緩沖區發送至網卡。4個條件判斷完后,內核發送一個skb包。skb包存儲的數據長度是不確定的(1~N*MSS字節)。如果用戶數據小于MSS,用戶數據將封裝成TCP數據段(完整段)直接向下交付;否則,用戶數據需要進行分段,內核會通過GSO技術對用戶數據進行分段。所謂TCP分段,就是將用戶數據以MSS(1460字節)為單位分為若干小的TCP數據段,這樣可以避免后續IP分片,提高傳輸效率。
TCP數據段(TCP完整段和TCP分段)將繼續向下交付。如果IP數據包(IP頭加TCP數據段)長度小于MTU(通常為1500字節),直接向下交付;否則,IP數據包需要進行IP分片(以MTU為單位,將一個大的IP數據包切分為多個IP分片)。
最后,內核會將IP數據包和IP分片包從網卡發送出去。
3.內核如何接收TCP數據包
內核接收TCP數據包的流程相對來說比較簡單,如圖4所示。
圖4 內核接收TCP數據包
內核接收TCP數據包過程同樣分為兩個部分:
- 部分1:內核將數據包從網卡接收至套接字接收緩沖區。
- 部分2:用戶程序拷貝套接字接收緩沖區數據至用戶緩沖區。
網卡中的以太網幀會以DMA方式拷貝至網卡驅動環形緩沖區。拷貝完成后,網卡觸發硬中斷,硬中斷會觸發軟中斷,再由軟中斷函數(如igb_poll函數)處理TCP數據包。
igb_poll函數會輪詢網卡驅動環形緩沖區,然后將網卡驅動環形緩沖區中的TCP數據轉儲至skb。如果skb滿足GRO條件,內核會嘗試將新skb和GRO鏈表中的skb進行GRO合并。
GRO是一種軟件實現的網絡優化技術,它允許網絡接口卡(NIC)驅動程序在將數據包傳遞給操作系統內核協議棧之前,對多個小數據包進行合并,形成一個較大的數據包。
新skb的五元組(源IP、目的IP、源端口、目的端口、協議)必須和GRO鏈表中的skb相同,才能進行合并。合并完成后,會生成一個大的skb(大的TCP段)并向上交付。
如果skb是一個IP分片包,則不會進行GRO合并,而是直接向上交付給網絡層,由網絡層進程IP分片重組。IP分片重組后,同樣會生成一個大的TCP段,TCP段最終會像水流一個匯入TCP套接字接收緩沖區。
用戶程序調用recv函數接收數據時,需要指定此次接收的數據長度。由于TCP是面向字節流,如果套接字接收緩沖區中有足夠的數據,內核會將數據全部拷貝至用戶緩沖區。
注意,用戶程序接收數據的過程也會產生TCP粘包。如果用戶程序沒有及時接收數據,那么會有源源不斷的數據匯入套接字接收緩沖區。這些數據都會粘在一起。
4.TCP粘包幾種解決方案
了解了內核收發TCP數據包流程后,我們會發現TCP粘包并不是真正的問題,只是流式傳輸的一個特性。用戶程序需要設計解決方案來規避TCP粘包。 常見的解決方案有:固定長度法、消息分割法、消息長度字段法。
4.1 固定長度法
如圖5所示,固定長度法規定每條消息都是固定長度(如1300字節),發送方需按照固定長度發送數據(不足固定長度的部分用特殊字符(如空格、\0)填充),接收方按固定長度接收數據,每次接收都是一條完整消息。
固定長度法實現簡單,解析效率高,但是很浪費帶寬,不夠靈活。適用于數據長度固定的場景。
圖5 固定長度法
固定長度法示例代碼如下:
/******客戶端******/
#define BUF_LEN 1500
#define FIXED_LEN 1300
int fill_fixed_buf(char *buf, int len, char c) {
memset(buf, 0, BUF_LEN);
memset(buf, c, len);
return len;
}
int main(int argc, char *argv[]) {
......
char sbuf[BUF_LEN] = {0};
int slen = 0;
int i = 0;
while(1) {
//填充固定長度緩沖區
slen = fill_fixed_buf(sbuf, FIXED_LEN, 'a' + i);
//發送固定長度數據
ret = send(sockfd, sbuf, slen, 0);
if (ret <= 0) {
perror("send");
break;
}
i++;
usleep(200 * 1000);
}
return0;
}
/******服務端******/
#define BUF_LEN 1500
#define FIXED_LEN 1300
int main(int argc, char *argv[]) {
......
char rbuf[BUF_LEN] = {0};
int len = FIXED_LEN;
while(1) {
memset(rbuf, 0, BUF_LEN);
//接收固定長度數據
recv(new_sockfd, rbuf, len, 0);
printf("ret:%d, rbuf:%s\n", ret, rbuf);
sleep(1);
}
return0;
}4.2 消息分隔符法
如圖6所示,消息分隔符法是指在每條消息后添加特殊標識符作為分隔符(如'\n'、''\r\n''或自定義符號)。接收方根據分隔符來判斷每條消息的結束位置。
消息分隔符法簡單直觀,適合文本協議,但消息內容不能包含分隔符(需轉義處理)。適用于文本協議(如HTTP、SMTP、FTP等)。
圖6 消息分隔符法
示例代碼如下:
/******客戶端******/
#define BUF_LEN 1500
int fill_delim_buf(char *buf, int len, char c, char *delim) {
memset(buf, 0, BUF_LEN);
memset(buf, c, len);
memcpy(buf + len, delim, strlen(delim));
return len+strlen(delim);
}
int main(int argc, char *argv[]) {
......
char sbuf[BUF_LEN] = {0};
int slen = 0;
int i = 0;
while(1) {
//填充緩沖區并在尾部添加‘\n’分隔符
slen = fill_delim_buf(sbuf, FIXED_LEN+i, 'a'+i, "\n");
printf("slen:%d\n", slen);
//發送帶‘\n’分隔符數據
ret = send(sockfd, sbuf, slen, 0);
if (ret <= 0) {
perror("send");
break;
}
i++;
usleep(200 * 1000);
}
return0;
}
/******服務端******/
#define BUF_LEN 1500
int main(int argc, char *argv[]) {
......
char rbuf[BUF_LEN] = {0};
int len = 0;
while(1) {
len = 1500;
memset(rbuf, 0, BUF_LEN);
//接收數據
recv(new_sockfd, rbuf, len, 0);
//通過‘\n’分隔符解析數據
char *msg = strtok(rbuf, "\n");
while (msg != NULL) {
printf("msg len:%lu, msg:%s\n", strlen(msg), msg);
msg = strtok(NULL, "\n");
}
sleep(1);
}
return0;
}4.3 消息長度字段法
如圖7所示,消息長度字段法是指在消息頭部添加固定字節表示消息體的長度(如4字節int)。接收方先讀取長度字段,再根據長度字段讀取相應長度的數據。
消息長度字段法高效、靈活,是最常用的方案。
圖7 消息長度字段法
消息長度字段法示例代碼如下:
/******客戶端******/
#define BUF_LEN 1500
int fill_len_buf(char *buf, int len, char c) {
memset(buf, 0, BUF_LEN);
memcpy(buf, &len, sizeof(len));//填充數據長度(4字節)
memset(buf + sizeof(len), c, len); //填充數據
returnsizeof(len) + len;
}
int main(int argc, char *argv[]) {
......
char sbuf[BUF_LEN] = {0};
int slen = 0;
int i = 0;
while(1) {
slen = fill_len_buf(sbuf, 64 + i, 'a' + i);
ret = send(sockfd, sbuf, slen, 0);
if (ret <= 0) {
perror("send");
break;
}
i++;
usleep(200 * 1000);
}
return0;
}
/******服務端******/
#define BUF_LEN 1500
int main(int argc, char *argv[]) {
......
int len = 0;
while(1) {
//先接收數據長度
recv(new_sockfd, &len, sizeof(len), 0);
printf("len:%d\n", len);
//已知數據長度,指定數據長度接收數據
memset(rbuf, 0, BUF_LEN);
recv(new_sockfd, rbuf, len, 0);
printf("rbuf:%s\n", rbuf);
sleep(1);
}
return0;
}總結
TCP粘包是面向字節流協議的特性,并不是軟件問題。用戶程序需要自行去規避TCP粘包問題。了解了內核如何收發TCP數據包,我們會對TCP粘包有更全面的了解。


























