TCP數據粘包的處理
- 背鍋俠TCP
- 解決方案
- 2.1 發送端
- 2.2 接收端
背鍋俠TCP
在前面介紹套接字通信的時候說到了TCP是傳輸層協議,它是一個面向連接的、安全的、流式傳輸協議。因為數據的傳輸是基于流的所以發送端和接收端每次處理的數據的量,處理數據的頻率可以不是對等的,可以按照自身需求來進行決策。
TCP協議是優勢非常明顯,但是有時也會給我們造成困擾,正所謂:成也蕭何敗蕭何。假設我們有如下需求:
客戶端和服務器之間要進行基于TCP的套接字通信
- 通信過程中客戶端會每次會不定期給服務器發送一個不定長度的有特定含義的字符串。
- 通信的服務器端每次都需要接收到客戶端這個不定長度的字符串,并對其進行解析
根據上面的描述,服務器在接收數據的時候有如下幾種情況:
- 一次接收到了客戶端發送過來的一個完整的數據包
- 一次接收到了客戶端發送過來的N個數據包,由于每個包的長度不定,無法將各個數據包拆開
- 一次接收到了一個或者N個數據包 + 下一個數據包的一部分,還是很悲劇,無法將數據包拆開
- 一次收到了半個數據包,下一次接收數據的時候收到了剩下的一部分+下個數據包的一部分,更悲劇,頭大了
- 另外,還有一些不可抗拒的因素:比如客戶端和服務器端的網速不一樣,發送和接收的數據量也會不一致
對于以上描述的現象很多時候我們將其稱之為TCP的粘包問題,但是這種叫法不太對的,本身TCP就是面向連接的流式傳輸協議,特性如此,我們卻說是TCP這個協議出了問題,這只能說是使用者的無知。多個數據包粘連到一起無法拆分是我們的需求過于復雜造成的,是程序猿的問題而不是協議的問題,TCP協議表示這鍋它不想背。
現在問題來了,服務器端如果想保證每次都能接收到客戶端發送過來的這個不定長度的數據包,程序猿應該如何解決這個問題呢?下面給大家提供幾種解決方案:
- 使用標準的應用層協議(比如:http、https)來封裝要傳輸的不定長的數據包
- 在每條數據的尾部添加特殊字符, 如果遇到特殊字符, 代表當條數據接收完畢了
- 有缺陷: 效率低, 需要一個字節一個字節接收, 接收一個字節判斷一次, 判斷是不是那個特殊字符串
- 在發送數據塊之前, 在數據塊最前邊添加一個固定大小的數據頭, 這時候數據由兩部分組成:數據頭+數據塊
- 數據頭:存儲當前數據包的總字節數,接收端先接收數據頭,然后在根據數據頭接收對應大小的字節
- 數據塊:當前數據包的內容
解決方案
如果使用TCP進行套接字通信,如果發送的數據包粘連到一起導致接收端無法解析,我們通常使用添加包頭的方式輕松地解決掉這個問題。關于數據包的包頭大小可以根據自己的實際需求進行設定,這里沒有啥特殊需求,因此規定包頭的固定大小為4個字節,用于存儲當前數據塊的總字節數。
2.1 發送端
對于發送端來說,數據的發送分為4步:
- 根據待發送的數據長度N動態申請一塊固定大小的內存:N+4(4是包頭占用的字節數)
- 將待發送數據的總長度寫入申請的內存的前四個字節中,此處需要將其轉換為網絡字節序(大端)
- 待發送的數據拷貝到包頭后邊的地址空間中,將完整的數據包發送出去(字符串沒有字節序問題)
- 釋放申請的堆內存。
由于發送端每次都需要將這個數據包完整的發送出去,因此可以設計一個發送函數,如果當前數據包中的數據沒有發送完就讓它一直發送,處理代碼如下:
/*
函數描述: 發送指定的字節數
函數參數:- fd: 通信的文件描述符(套接字)- msg: 待發送的原始數據- size: 待發送的原始數據的總字節數
函數返回值: 函數調用成功返回發送的字節數, 發送失敗返回-1
*/
int writen(int fd, const char* msg, int size)
{const char* buf = msg;int count = size;while (count > 0){int len = send(fd, buf, count, 0);if (len == -1){close(fd);return -1;}else if (len == 0){continue;}buf += len;count -= len;}return size;
}
有了這個功能函數之后就可以發送帶有包頭的數據塊了,具體處理動作如下:
/*
函數描述: 發送帶有數據頭的數據包
函數參數:- cfd: 通信的文件描述符(套接字)- msg: 待發送的原始數據- len: 待發送的原始數據的總字節數
函數返回值: 函數調用成功返回發送的字節數, 發送失敗返回-1
*/
int sendMsg(int cfd, char* msg, int len)
{if(msg == NULL || len <= 0 || cfd <=0){return -1;}// 申請內存空間: 數據長度 + 包頭4字節(存儲數據長度)char* data = (char*)malloc(len+4);int bigLen = htonl(len);memcpy(data, &bigLen, 4);memcpy(data+4, msg, len);// 發送數據int ret = writen(cfd, data, len+4);// 釋放內存free(data);return ret;
}
關于數據的發送最后再次強調:字符串沒有字節序問題,但是數據頭不是字符串是整形,因此需要從主機字節序轉換為網絡字節序再發送。
完整的放在一起如下
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
#include <pthread.h>/*
函數描述: 發送指定的字節數
函數參數:- fd: 通信的文件描述符(套接字)- msg: 待發送的原始數據- size: 待發送的原始數據的總字節數
函數返回值: 函數調用成功返回發送的字節數, 發送失敗返回-1
msg是要發送的字符串指針,size是要發送的字符串的長度
再次while循環的時候,已經發送了len長度,指針后移len長度,發送的字符串長度也減len
*/
int writen(int fd, const char* msg, int size)
{const char* buf = msg;int count = size;while (count > 0){int len = send(fd, buf, count, 0);if (len == -1) // 表示發送出錯,關閉文件描述符并返回-1。{close(fd);return -1;}else if (len == 0) // 表示沒有發送任何數據{continue;}buf += len;count -= len;}return size;
}/*
函數描述: 發送帶有數據頭的數據包
函數參數:- cfd: 通信的文件描述符(套接字)- msg: 待發送的原始數據- len: 待發送的原始數據的總字節數
函數返回值: 函數調用成功返回發送的字節數, 發送失敗返回-1
*/
int sendMsg(int cfd, char* msg, int len)
{if(msg == NULL || len <= 0 || cfd <=0){return -1;}// 申請內存空間: 數據長度 + 包頭4字節(存儲數據長度)char* data = (char*)malloc(len+4);int bigLen = htonl(len);memcpy(data, &bigLen, 4);memcpy(data+4, msg, len);// 發送數據int ret = writen(cfd, data, len+4);// 釋放內存free(data);return ret;
}
2.2 接收端
了解了套接字的發送端如何發送數據,接收端的處理步驟也就清晰了,具體過程如下:
- 首先接收4字節數據,并將其從網絡字節序轉換為主機字節序,這樣就得到了即將要接收的數據的總長度
- 根據得到的長度申請固定大小的堆內存,用于存儲待接收的數據
- 根據得到的數據塊長度接收固定數目的數據保存到申請的堆內存中
- 處理接收的數據
- 釋放存儲數據的堆內存
從數據包頭解析出要接收的數據長度之后,還需要將這個數據塊完整的接收到本地才能進行后續的數據處理,因此需要編寫一個接收數據的功能函數,保證能夠得到一個完整的數據包數據,處理函數實現如下:
/*
函數描述: 接收指定的字節數
函數參數:- fd: 通信的文件描述符(套接字)- buf: 存儲待接收數據的內存的起始地址- size: 指定要接收的字節數
函數返回值: 函數調用成功返回發送的字節數, 發送失敗返回-1
*/
int readn(int fd, char* buf, int size)
{char* pt = buf;int count = size;while (count > 0){int len = recv(fd, pt, count, 0);if (len == -1){return -1;}else if (len == 0){return size - count;}pt += len;count -= len;}return size;
}
這個函數搞定之后,就可以輕松地接收帶包頭的數據塊了,接收函數實現如下:
/*
函數描述: 接收帶數據頭的數據包
函數參數:- cfd: 通信的文件描述符(套接字)- msg: 一級指針的地址,函數內部會給這個指針分配內存,用于存儲待接收的數據,這塊內存需要使用者釋放
函數返回值: 函數調用成功返回接收的字節數, 發送失敗返回-1
*/
int recvMsg(int cfd, char** msg)
{// 接收數據// 1. 讀數據頭int len = 0;readn(cfd, (char*)&len, 4);len = ntohl(len);printf("數據塊大小: %d\n", len);// 根據讀出的長度分配內存,+1 -> 這個字節存儲\0char *buf = (char*)malloc(len+1);int ret = readn(cfd, buf, len);if(ret != len){close(cfd);free(buf);return -1;}buf[len] = '\0';*msg = buf;return ret;
}
這樣,在進行套接字通信的時候通過調用封裝的sendMsg()和recvMsg()就可以發送和接收帶數據頭的數據包了,而且完美地解決了粘包的問題。
完整的放在一起如下
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
#include <pthread.h>/*
函數描述: 接收指定的字節數
函數參數:- fd: 通信的文件描述符(套接字)- buf: 存儲待接收數據的內存的起始地址- size: 指定要接收的字節數
函數返回值: 函數調用成功返回發送的字節數, 發送失敗返回-1
*/
int readn(int fd, char* buf, int size)
{char* pt = buf;int count = size;while (count > 0){int len = recv(fd, pt, count, 0);if (len == -1) // -1:接收數據失敗了{return -1;}else if (len == 0) //等于0:對方斷開了連接{return size - count;}pt += len;count -= len;}return size;
}/*
函數描述: 接收帶數據頭的數據包
函數參數:- cfd: 通信的文件描述符(套接字)- msg: 一級指針的地址,函數內部會給這個指針分配內存,用于存儲待接收的數據,這塊內存需要使用者釋放
函數返回值: 函數調用成功返回接收的字節數, 發送失敗返回-1
*/
int recvMsg(int cfd, char** msg)
{// 接收數據// 1. 讀數據頭int len = 0;readn(cfd, (char*)&len, 4);len = ntohl(len);printf("數據塊大小: %d\n", len);// 根據讀出的長度分配內存,+1 -> 這個字節存儲\0char *buf = (char*)malloc(len+1);int ret = readn(cfd, buf, len);if(ret != len){close(cfd);free(buf);return -1;}buf[len] = '\0';*msg = buf;return ret;
}