歡迎來到Linux教程網
Linux教程網
Linux教程網
Linux教程網
您现在的位置: Linux教程網 >> UnixLinux >  >> Linux基礎 >> 關於Linux

Linux下的socket編程實踐(四)TCP的粘包問題和常用解決方案

TCP粘包問題的產生

由於TCP協議是基於字節流並且無邊界的傳輸協議, 因此很有可能產生粘包問題。此外,發送方引起的粘包是由TCP協議本身造成的,TCP為提高傳輸效率,發送方往往要收集到足夠多的數據後才發送一個TCP段。若連續幾次需要send的數據都很少,通常TCP會根據優化算法把這些數據合成一個TCP段後一次發送出去,但是接收方並不知道要一次接收多少字節的數據,這樣接收方就收到了粘包數據。具體可以見下圖:

\

 

假設主機A send了兩條消息M1和M2 各10k 給主機B,由於主機B一次提取的字節數是不確定的,接收方提取數據的情況可能是:

? 一次性提取20k 數據
? 分兩次提取,第一次5k,第二次15k
? 分兩次提取,第一次15k,第二次5k
? 分兩次提取,第一次10k,第二次10k(僅此正確)
? 分三次提取,第一次6k,第二次8k,第三次6k
? 其他任何可能

粘包問題產生的多種原因:

 

1、SQ_SNDBUF 套接字本身有緩沖區大小的限制 (發送緩沖區、接受緩沖區)

2、TCP傳送的端 MSS大小限制

3、鏈路層也有MTU大小限制,如果數據包大於>MTU要在IP層進行分片,導致數據分割。

4、TCP的流量控制和擁塞控制,也可能導致粘包

5、文章開始提到的TCP延遲確認機制等

 

注: 關於MTU和MSS

MSS指的是TCP中的一個概念。MTU是一個沒有固定到特定OSI層的概念,不受其他特定協議限制。也就是說第二層會有MTU,第三層會有MTU,像MPLS這樣的第2.5層協議,也有自己的MTU值。並且不同層之間存在關聯關系。舉個例子:如果你要搬家,需要把東西打包,用車運走。這樣的情況下,車的大小受路的寬度限制;箱子的大小受車限制;能夠搬運的東西的大小受箱子的限制。這時可以將路的寬度理解成第二層的MTU,車的大小理解成第三層的MTU,箱子的大小理解成第四層的MTU,搬運的東西理解成MSS。

\

粘包問題的解決方案(本質上是要在應用層維護消息和消息之間的邊界)

 

(1)定長包

該方式並不實用: 如果所定義的長度過長, 則會浪費網絡帶寬,增加網絡負擔;而又如果定義的長度過短, 則一條消息又會拆分成為多條, 僅在TCP的應用一層就增加了合並的開銷。

(2)包尾加\r\n(FTP使用方案)

如果消息本身含有\r\n字符,則也分不清消息的邊界;

(3)報文長度+報文內容,自定義包結構

(4)更復雜的應用層協議

注:簡單的使用 setsockopt 設置開啟TCP_NODELAY禁用 Nagle’s Algorithm可以解決上述第5個問題(延遲確認機制)。

 

static void _set_tcp_nodelay(int fd) {
    int enable = 1;
    setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, (void*)&enable, sizeof(enable));
}
著名的Nginx服務器 默認是開啟了這個選項的.....

 

 

因為TCP協議是面向流的,read和write調用的返回值往往小於參數指定的字節數。對於read調用(套接字標志為阻塞),如果接收緩沖區中有20字節,請求讀100個字節,就會返回20;對於write調用,如果請求寫100個字節,而發送緩沖區中只有20個字節的空閒位置,那麼write會阻塞,直到把100個字節全部交給發送緩沖區才返回;還有信號中斷之後需要處理為 繼續讀寫;為避免這些情況干擾主程序的邏輯,確保讀寫我們所請求的字節數,我們實現了兩個包裝函數readn和writen,如下所示。

 

/**實現: 
這兩個函數只是按需多次調用read和write系統調用直至讀/寫了count個數據 
**/  
/**返回值說明: 
    == count: 說明正確返回, 已經真正讀取了count個字節 
    == -1   : 讀取出錯返回 
    <  count: 讀取到了末尾 
**/  
ssize_t readn(int fd, void *buf, size_t count)  
{  
    size_t nLeft = count;  
    ssize_t nRead = 0;  
    char *pBuf = (char *)buf;  
    while (nLeft > 0)  
    {  
        if ((nRead = read(fd, pBuf, nLeft)) < 0)  
        {  
            //如果讀取操作是被信號打斷了, 則說明還可以繼續讀  
            if (errno == EINTR)  
                continue;  
            //否則就是其他錯誤  
            else  
                return -1;  
        }  
        //讀取到末尾  
        else if (nRead == 0)  
            return count-nLeft;  
  
        //正常讀取  
        nLeft -= nRead;  
        pBuf += nRead;  
    }  
    return count;  
}  

/**返回值說明: 
    == count: 說明正確返回, 已經真正寫入了count個字節 
    == -1   : 寫入出錯返回 
**/  
ssize_t writen(int fd, const void *buf, size_t count)  
{  
    size_t nLeft = count;  
    ssize_t nWritten = 0;  
    char *pBuf = (char *)buf;  
    while (nLeft > 0)  
    {  
        if ((nWritten = write(fd, pBuf, nLeft)) < 0)  
        {  
            //如果寫入操作是被信號打斷了, 則說明還可以繼續寫入  
            if (errno == EINTR)  
                continue;  
            //否則就是其他錯誤  
            else  
                return -1;  
        }  
        //如果 ==0則說明是什麼也沒寫入, 可以繼續寫  
        else if (nWritten == 0)  
            continue;  
  
        //正常寫入  
        nLeft -= nWritten;  
        pBuf += nWritten;  
    }  
    return count;  
}  

 

報文長度+報文內容(自定義包結構)

發報文時:前四個字節長度+報文內容一次性發送;

收報文時:先讀前四個字節,求出報文內容長度;根據長度讀數據

自定義包結構:
struct Packet  
{  
    unsigned int      msgLen;     //數據部分的長度(注:這是網絡字節序)  
    char            text[1024]; //報文的數據部分  
};
//echo 回射client端發送與接收代碼
...
    struct Packet buf;
    memset(&buf, 0, sizeof(buf));
    while (fgets(buf.text, sizeof(buf.text), stdin) != NULL)
    {
        /**寫入部分**/
        unsigned int lenHost = strlen(buf.text);
        buf.msgLen = htonl(lenHost);
        if (writen(sockfd, &buf, sizeof(buf.msgLen)+lenHost) == -1)
            err_exit("writen socket error");

        /**讀取部分**/
        memset(&buf, 0, sizeof(buf));
        //首先讀取首部
        ssize_t readBytes = readn(sockfd, &buf.msgLen, sizeof(buf.msgLen));
        if (readBytes == -1)
            err_exit("read socket error");
        else if (readBytes != sizeof(buf.msgLen))
        {
            cerr << "server connect closed... \nexiting..." << endl;
            break;
        }

        //然後讀取數據部分
        lenHost = ntohl(buf.msgLen);
        readBytes = readn(sockfd, buf.text, lenHost);
        if (readBytes == -1)
            err_exit("read socket error");
        else if (readBytes != lenHost)
        {
            cerr << "server connect closed... \nexiting..." << endl;
            break;
        }
        //將數據部分打印輸出
        cout << buf.text;
        memset(&buf, 0, sizeof(buf));
    }
...

//server端echo部分的改進代碼  
void echo(int clientfd)  
{  
    struct Packet buf;  
    int readBytes;  
    //首先讀取首部  
    while ((readBytes = readn(clientfd, &buf.msgLen, sizeof(buf.msgLen))) > 0)  
    {  
        //網絡字節序 -> 主機字節序  
        int lenHost = ntohl(buf.msgLen);  
        //然後讀取數據部分  
        readBytes = readn(clientfd, buf.text, lenHost);  
        if (readBytes == -1)  
            err_exit("readn socket error");  
        else if (readBytes != lenHost)  
        {  
            cerr << "client connect closed..." << endl;  
            return ;  
        }  
        cout << buf.text;  
  
        //然後將其回寫回socket  
        if (writen(clientfd, &buf, sizeof(buf.msgLen)+lenHost) == -1)  
            err_exit("write socket error");  
        memset(&buf, 0, sizeof(buf));  
    }  
    if (readBytes == -1)  
        err_exit("read socket error");  
    else if (readBytes != sizeof(buf.msgLen))  
        cerr << "client connect closed..." << endl;  
}  
注:網絡字節序和本機字節序之間是必要的轉換。 按行讀取(由\r\n判斷)
ssize_t recv(int sockfd, void *buf, size_t len, int flags);  
ssize_t send(int sockfd, const void *buf, size_t len, int flags);  
與read相比,recv只能用於套接字文件描述符,但是多了一個flags,這個flags能夠幫助我們實現解決粘包問題的操作。

MSG_PEEK(可以讀數據,但不從緩存區中讀走[僅僅是一瞥],利用此特點可以方便的實現按行讀取數據;一個一個字符的讀,多次調用系統調用read方法,效率不高,但是可以判斷'\n')。

This flag causes the receive operation to return data from the beginning of

the receive queue without removing that data from the queue. Thus, a subsequent

receive call will return the same data.

 

readline實現思想:

在readline函數中,我們先用recv_peek”偷窺” 一下現在緩沖區有多少個字符並讀取到pBuf,然後查看是否存在換行符'\n'。如果存在,則使用readn連同換行符一起讀取(作用相當於清空socket緩沖區); 如果不存在,也清空一下緩沖區, 且移動pBuf的位置,回到while循環開頭,再次窺看。注意,當我們調用readn讀取數據時,那部分緩沖區是會被清空的,因為readn調用了read函數。還需注意一點是,如果第二次才讀取到了'\n',則先用returnCount保存了第一次讀取的字符個數,然後返回的ret需加上原先的數據大小。


 

/**示例: 通過MSG_PEEK封裝一個recv_peek函數(僅查看數據, 但不取走)**/  
ssize_t recv_peek(int sockfd, void *buf, size_t len)  
{  
    while (true)  
    {  
        int ret = recv(sockfd, buf, len, MSG_PEEK);  
        //如果recv是由於被信號打斷, 則需要繼續(continue)查看  
        if (ret == -1 && errno == EINTR)  
            continue;  
        return ret;  
    }  
}  
  
/**使用recv_peek實現按行讀取readline(只能用於socket)**/  
/** 返回值說明: 
    == 0:   對端關閉 
    == -1:  讀取出錯 
    其他:    一行的字節數(包含'\n') 
**/  
ssize_t readline(int sockfd, void *buf, size_t maxline)  
{  
    int ret;  
    int nRead = 0;  
    int returnCount = 0;  
    char *pBuf = (char *)buf;  
    int nLeft = maxline;  
    while (true)  
    {  
        ret = recv_peek(sockfd, pBuf, nLeft);  
        //如果查看失敗或者對端關閉, 則直接返回  
        if (ret <= 0)  
            return ret;  
        nRead = ret;  
        for (int i = 0; i < nRead; ++i)  
            //在當前查看的這段緩沖區中含有'\n', 則說明已經可以讀取一行了  
            if (pBuf[i] == '\n')  
            {  
                //則將緩沖區內容讀出  
                //注意是i+1: 將'\n'也讀出  
                ret = readn(sockfd, pBuf, i+1);  
                if (ret != i+1)  
                    exit(EXIT_FAILURE);  
                return ret + returnCount;  
            }  
  
        // 如果在查看的這段消息中沒有發現'\n', 則說明還不滿足一條消息,  
        // 在將這段消息從緩沖中讀出之後, 還需要繼續查看  
        ret = readn(sockfd, pBuf, nRead);;  
        if (ret != nRead)  
            exit(EXIT_FAILURE);  
        pBuf += nRead;  
        nLeft -= nRead;  
        returnCount += nRead;  
    }  
    //如果程序能夠走到這裡, 則說明是出錯了  
    return -1;  
}  

client端:
...  
    char buf[512] = {0};  
    memset(buf, 0, sizeof(buf));  
    while (fgets(buf, sizeof(buf), stdin) != NULL)  
    {  
        if (writen(sockfd, buf, strlen(buf)) == -1)  
            err_exit("writen error");  
        memset(buf, 0, sizeof(buf));  
        int readBytes = readline(sockfd, buf, sizeof(buf));  
        if (readBytes == -1)  
            err_exit("readline error");  
        else if (readBytes == 0)  
        {  
            cerr << "server connect closed..." << endl;  
            break;  
        }  
        cout << buf;  
        memset(buf, 0, sizeof(buf));  
    }  
...  

server端:
void echo(int clientfd)  
{  
    char buf[512] = {0};  
    int readBytes;  
    while ((readBytes = readline(clientfd, buf, sizeof(buf))) > 0)  
    {  
        cout << buf;  
        if (writen(clientfd, buf, readBytes) == -1)  
            err_exit("writen error");  
        memset(buf, 0, sizeof(buf));  
    }  
    if (readBytes == -1)  
        err_exit("readline error");  
    else if (readBytes == 0)  
        cerr << "client connect closed..." << endl;  
}  

Copyright © Linux教程網 All Rights Reserved