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值。並且不同層之間存在關聯關係。舉個例子:如果你要搬家,需要把東西打包,用車運走。這樣的情況下,車的大小受路的寬度限制;箱子的大小受車限制;能夠搬運的東西的大小受箱子的限制。
粘包問題的解決方案(本質上是要在應用層維護訊息和訊息之間的邊界)
(1)定長包
該方式並不實用: 如果所定義的長度過長, 則會浪費網路頻寬,增加網路負擔;而又如果定義的長度過短, 則一條訊息又會拆分成為多條, 僅在TCP的應用一層就增加了合併的開銷。
(2)包尾加\r\n(FTP使用方案)
如果訊息本身含有\r\n字元,則也分不清訊息的邊界;
(3)報文長度+報文內容,自定義包結構
(4)更復雜的應用層協議
注:簡單的使用 setsockopt 設定開啟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;
}
最後附上 TLV格式及其編解碼的示例 http://blog.csdn.net/chexlong/article/details/6974201