天天看點

TCP粘包、拆包與解決方案、C++ 實作

說明:

TCP(transport control protocol,傳輸控制協定)是面向連接配接的,面向流的,提供高可靠性服務。收發兩端(用戶端和伺服器端)都要有一一成對的socket,是以,發送端為了将多個發往接收端的包,更有效的發到對方,使用了優化方法(Nagle算法),将多次間隔較小且資料量小的資料,合并成一個大的資料塊,然後進行封包。這樣,接收端,就難于分辨出來了,必須提供科學的拆包機制。即面向流的通信是無消息保護邊界的。      

TCP粘包、拆包圖解

TCP粘包、拆包與解決方案、C++ 實作

假設用戶端分别發送了兩個資料包D1和D2給服務端,由于服務端一次讀取到位元組數是不确定的,故可能存在以下四種情況:

  1. 服務端分兩次讀取到了兩個獨立的資料包,分别是D1和D2,沒有粘包和拆包
  2. 服務端一次接受到了兩個資料包,D1和D2粘合在一起,稱之為TCP粘包
  3. 服務端分兩次讀取到了資料包,第一次讀取到了完整的D1包和D2包的部分内容,第二次 讀取到了D2包的剩餘内容,這稱之為TCP拆包
  4. 服務端分兩次讀取到了資料包,第一次讀取到了D1包的部分内容D1_1,第二次讀取到了D1包的剩餘部分内容D1_2和完整的D2包。

發生原因:

  1. socket緩沖區與滑動視窗
  2. MSS/MTU限制
  3. Nagle算法

解決方案:定義通信協定

通過定義應用的協定(protocol)來解決。協定的作用就定義傳輸資料的格式。這樣在接受到的資料的時候,如果粘包了,就可以根據這個格式來區分不同的包,如果拆包了,就等待資料可以構成一個完整的消息來處理。目前業界主流的協定(protocol)方案可以歸納如下:

定長協定:

假設我們規定每3個位元組,表示一個有效封包,如果我們分4次總共發送以下9個位元組:

+---+----+------+----+
 | A | BC | DEFG | HI |
 +---+----+------+----+      

那麼根據協定,我們可以判斷出來,這裡包含了3個有效的請求封包

+-----+-----+-----+
 | ABC | DEF | GHI |
 +-----+-----+-----+      

特殊字元分隔符協定

在包尾部增加回車或者空格符等特殊字元進行分割

例如,按行解析,遇到字元\n、\r\n的時候,就認為是一個完整的資料包。對于以下二進制位元組流:

+--------------+
 | ABC\nDEF\r\n |
 +--------------+      

那麼根據協定,我們可以判斷出來,這裡包含了2個有效的請求封包

+-----+-----+
 | ABC | DEF |
 +-----+-----+      

長度編碼:

将消息分為消息頭和消息體,消息頭中用一個int型資料(4位元組),表示消息體長度的字段。在解析時,先讀取内容長度Length,其值為實際消息體内容(Content)占用的位元組數,之後必須讀取到這麼多位元組的内容,才認為是一個完整的資料封包。

header    body
+--------+----------+
| Length | Content |
+--------+----------+      

長度編碼方案C++ 實作:

關于資料包的標頭大小可以根據自己的實際需求進行設定,這裡沒有啥特殊需求,是以規定標頭的固定大小為4個位元組,用于存儲目前資料塊的總位元組數。      
TCP粘包、拆包與解決方案、C++ 實作

發送端:

資料的發送分為 4 步:

  1. 根據待發送的資料長度 N 動态申請一塊固定大小的記憶體:N+4(4 是標頭占用的位元組數)
  2. 将待發送資料的總長度寫入申請的記憶體的前四個位元組中,此處需要将其轉換為網絡位元組序(大端)
  3. 将待發送的資料拷貝到標頭後邊的位址空間中,将完整的資料包發送出去(字元串沒有位元組序問題)
  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;
}      
  1. 首先接收 4 位元組資料,并将其從網絡位元組序轉換為主機位元組序,這樣就得到了即将要接收的資料的總長度
  2. 根據得到的長度申請固定大小的堆記憶體,用于存儲待接收的資料
  3. 根據得到的資料塊長度接收固定數目的資料儲存到申請的堆記憶體中
  4. 處理接收的資料
  5. 釋放存儲資料的堆記憶體
/*
函數描述: 接收指定的位元組數
函數參數:
    - 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 -> 這個位元組存儲\0
    char *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;
}