① TCP是個流協議,它存在粘包問題
TCP是一個基於字節流的傳輸服務,"流"意味着TCP所傳輸的數據是沒有邊界的。這不同於UDP提供基於消息的傳輸服務,其傳輸的數據是有邊界的。TCP的發送方無法保證對等方每次接收到的是一個完整的數據包。主機A向主機B發送兩個數據包,主機B的接收情況可能是

產生粘包問題的原因有以下幾個:
- 第一 。應用層調用write方法,將應用層的緩沖區中的數據拷貝到套接字的發送緩沖區。而發送緩沖區有一個SO_SNDBUF的限制,如果應用層的緩沖區數據大小大於套接字發送緩沖區的大小,則數據需要進行多次的發送。
- 第二種情況是,TCP所傳輸的報文段有MSS的限制,如果套接字緩沖區的大小大於MSS,也會導致消息的分割發送。
- 第三種情況由於鏈路層最大發送單元MTU,在IP層會進行數據的分片。
這些情況都會導致一個完整的應用層數據被分割成多次發送,導致接收對等方不是按完整數據包的方式來接收數據。
② 粘包的問題的解決思路
粘包問題的最本質原因在與接收對等方無法分辨消息與消息之間的邊界在哪。我們通過使用某種方案給出邊界,例如:
- 發送定長包。如果每個消息的大小都是一樣的,那么在接收對等方只要累計接收數據,直到數據等於一個定長的數值就將它作為一個消息。
- 包尾加上\r\n標記。FTP協議正是這么做的。但問題在於如果數據正文中也含有\r\n,則會誤判為消息的邊界。
- 包頭加上包體長度。包頭是定長的4個字節,說明了包體的長度。接收對等方先接收包體長度,依據包體長度來接收包體。
- 使用更加復雜的應用層協議。
③ 粘包解決方案一:使用定長包
這里需要封裝兩個函數:
ssize_t readn(int fd, void *buf, size_t count)
ssize_t writen(int fd, void *buf, size_t count)
這兩個函數的參數列表和返回值與read、write一致。它們的作用的讀取/寫入count個字節后再返回。其實現如下:
ssize_t readn(int fd, void *buf, size_t count)
{
int left = count ; //剩下的字節
char * ptr = (char*)buf ;
while(left>0)
{
int readBytes = read(fd,ptr,left);
if(readBytes< 0)//read函數小於0有兩種情況:1中斷 2出錯
{
if(errno == EINTR)//讀被中斷
{
continue;
}
return -1;
}
if(readBytes == 0)//讀到了EOF
{
//對方關閉呀
printf("peer close\n");
return count - left;
}
left -= readBytes;
ptr += readBytes ;
}
return count ;
}
/*
writen 函數
寫入count字節的數據
*/
ssize_t writen(int fd, void *buf, size_t count)
{
int left = count ;
char * ptr = (char *)buf;
while(left >0)
{
int writeBytes = write(fd,ptr,left);
if(writeBytes<0)
{
if(errno == EINTR)
continue;
return -1;
}
else if(writeBytes == 0)
continue;
left -= writeBytes;
ptr += writeBytes;
}
return count;
}
有了這兩個函數之后,我們就可以使用定長包來發送數據了,我抽取其關鍵代碼來講訴:
char readbuf[512];
readn(conn,readbuf,sizeof(readbuf)); //每次讀取512個字節
同理的,寫入的時候也寫入512個字節
char writebuf[512];
fgets(writebuf,sizeof(writebuf),stdin);
writen(conn,writebuf,sizeof(writebuf);
每個消息都以固定的512字節(或其他數字,看你的應用層的緩沖區大小)來發送,以此區分每一個信息,這便是以固定長度解決粘包問題的思路。定長包解決方案的缺點在於會導致增加網絡的負擔,無論每次發送的有效數據是多大,都得按照定長的數據長度進行發送。
④ 粘包解決方案二:使用結構體,顯式說明數據部分的長度
在這個方案中,我們需要定義一個‘struct packet’包結構,結構中指明數據部分的長度,用四個字節來表示。發送端的對等方接收報文時,先讀取前四個字節,獲取數據的長度,由長度來進行數據的讀取。定義一個結構體
struct packet
{
unsigned int msgLen ; //4個字節字段,說明數據部分的大小
char data[512] ; //數據部分
}
讀寫過程如下所示,這里抽取關鍵代碼進行說明:
//發送數據過程
struct packet writebuf;
memset(&writebuf,0,sizeof(writebuf));
while(fgets(writebuf.data,sizeof(writebuf.data),stdin)!=NULL)
{
int n = strlen(writebuf.data); //計算要發送的數據的字節數
writebuf.msgLen =htonl(n); //將該字節數保存在msgLen字段,注意字節序的轉換
writen(conn,&writebuf,4+n); //發送數據,數據長度為4個字節的msgLen 加上data長度
memset(&writebuf,0,sizeof(writebuf));
}
下面是讀取數據的過程,先讀取msgLen字段,該字段指示了有效數據data的長度。依據該字段再讀出data。
memset(&readbuf,0,sizeof(readbuf));
int ret = readn(conn,&readbuf.msgLen,4); //先讀取四個字節,確定后續數據的長度
if(ret == -1)
{
err_exit("readn");
}
else if(ret == 0)
{
printf("peer close\n");
break;
}
int dataBytes = ntohl(readbuf.msgLen); //字節序的轉換
int readBytes = readn(conn,readbuf.data,dataBytes); //讀取出后續的數據
if(readBytes == 0)
{
printf("peer close\n");
break;
}
if(readBytes<0)
{
err_exit("read");
}
⑤ 粘包解決方案三:按行讀取
ftp協議采用/r/n來識別一個消息的邊界,我們在這里實現一個按行讀取的功能,該功能能夠按/n來識別消息的邊界。這里介紹一個函數:
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
與read函數相比,recv函數的區別在於兩點:
- recv函數只能夠用於套接口IO。
- recv函數含有flags參數,可以指定一些選項。
recv函數的flags參數常用的選項是:
- MSG_OOB 接收帶外數據,即通過緊急指針發送的數據
- MSG_PEEK 從緩沖區中讀取數據,但並不從緩沖區中清除所讀數據
為了實現按行讀取,我們需要使用recv函數的MSG_PEEK選項。PEEK的意思是"偷看",我們可以理解為窺視,看看socket的緩沖區內是否有某種內容,而清除緩沖區。
/*
* 封裝了recv函數
返回值說明:-1 讀取出錯
*/
ssize_t read_peek(int sockfd,void *buf ,size_t len)
{
while(1)
{
//從緩沖區中讀取,但不清除緩沖區
int ret = recv(sockfd,buf,len,MSG_PEEK);
if(ret == -1 && errno == EINTR)//文件讀取中斷
continue;
return ret;
}
}
下面是按行讀取的代碼:
/*
*讀取一行內容
* 返回值說明:
== 0 :對端關閉
== -1 : 讀取錯誤
其他:一行的字節數,包含\n
*
**/
ssize_t readLine(int sockfd ,void * buf ,size_t maxline)
{
int ret ;
int nRead = 0;
int left = maxline ;
char * pbuf = (char *) buf;
int count = 0;
while(true)
{
//從socket緩沖區中讀取指定長度的內容,但並不刪除
ret = read_peek(sockfd,pbuf,left);
// ret = recv(sockfd , pbuf , left , MSG_PEEK);
if(ret<= 0)
return ret;
nRead = ret ;
for(int i = 0 ;i< nRead ; ++i)
{
if(pbuf[i]=='\n') //探測到有\n
{
ret = readn (sockfd , pbuf, i+1);
if(ret != i+1)
exit(EXIT_FAILURE);
return ret + returnCount;
}
}
//如果嗅探到沒有\n
//那么先將這一段沒有\n的讀取出來
ret = readn(sockfd , pbuf , nRead);
if(ret != nRead)
exit(EXIT_FAILURE);
pbuf += nRead ;
left -= nRead ;
count += nRead;
}
return -1;
}
⑥ 實例程序
下面的鏈接中包含了上面提到的幾種方案的代碼,各個函數封裝在common.h頭文件中,TCP粘包解決方案
