一道高頻騰訊面試題:tcp數(shù)據(jù)發(fā)送問題
問題引出
好幾個讀者私信說在騰訊面試過程中,被面試官問到了一個問題:“一個tcp服務(wù)端和一個tcp客戶端,客戶端和服務(wù)端建立連接后,服務(wù)端一直sleep,然后客戶端一直發(fā)送數(shù)據(jù)會是什么現(xiàn)象”。
要回答這個問題,需要我們清楚tcp協(xié)議的特點(diǎn)和tcp發(fā)送數(shù)據(jù)的大體過程。
tcp發(fā)送數(shù)據(jù)過程
恐怕接觸過網(wǎng)絡(luò)的同學(xué)都知道tcp是面向連接的可靠傳輸協(xié)議,意味著客戶端發(fā)送的數(shù)據(jù)服務(wù)端是一定能夠收到的,那么對于上面的問題就不可能存在數(shù)據(jù)的丟棄。下面我們分析一下tcp的傳輸過程。

如圖所示,tcp數(shù)據(jù)包的傳輸過程主要有如下幾個步驟:
? 1.應(yīng)用程序調(diào)用write系列函數(shù)發(fā)送數(shù)據(jù) ,數(shù)據(jù)首先由應(yīng)用程序緩沖區(qū)復(fù)制到發(fā)送端的內(nèi)核中的 套接字發(fā)送緩沖區(qū),然后write成功返回;需要特別注意的是write成功返回只是說明數(shù)據(jù)成功的由應(yīng)用進(jìn)程緩沖區(qū)復(fù)制到了套接字發(fā)送緩沖區(qū),并不代表數(shù)據(jù)發(fā)送到了對端主機(jī)。
? 2.內(nèi)核協(xié)議棧將套接字發(fā)送緩沖區(qū)中的數(shù)據(jù)發(fā)送到對端主機(jī),這個過程不受應(yīng)用程序控制,而是發(fā)送端內(nèi)核協(xié)議棧完成;
? 3.數(shù)據(jù)到達(dá)接收端主機(jī)的套接字接收緩沖區(qū),注意這個接收過程也不受應(yīng)用程序控制,而是由接收端內(nèi)核協(xié)議棧完成;
? 4.數(shù)據(jù)由套接字接收緩沖區(qū)復(fù)制到接收端應(yīng)用程序緩沖區(qū),注意這個過程是由類似read等函數(shù)來完成。
清楚了tcp的傳輸過程,現(xiàn)在我們分情況來討論上面的問題。
阻塞方式的情況
write系列函數(shù)的工作方式默認(rèn)是阻塞方式:調(diào)用write函數(shù)時,內(nèi)核從應(yīng)用進(jìn)程的緩沖區(qū)到套接字的發(fā)送緩沖區(qū)復(fù)制數(shù)據(jù)。如果其發(fā)送緩沖區(qū)中沒有空間,進(jìn)程將進(jìn)入睡眠,直到有空間為止。
因此,阻塞方式下,如果服務(wù)端一直sleep不接收數(shù)據(jù),而客戶端一直write,也就是只能執(zhí)行上述過程中的前三步,這樣最后接收端的套接字接收緩沖區(qū)和發(fā)送端套接字發(fā)送緩沖區(qū)都被填滿,這樣write就無法繼續(xù)將數(shù)據(jù)從應(yīng)用程序復(fù)制到發(fā)送端的套接字發(fā)送緩沖區(qū)了,從而發(fā)送端進(jìn)程進(jìn)入睡眠。可以用下面的程序驗證。
tcpClient.c是客戶端代碼用來發(fā)送數(shù)據(jù),客戶端每次write成功一次,將計數(shù)器count加1,同時輸出本次write成功的字節(jié)數(shù)。count保存客戶端write成功的次數(shù)。
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <stdlib.h>
#include <memory.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#define PORT 8888
#define Buflen 1024
int main(int argc,char *argv[])
{
struct sockaddr_in server_addr;
int n,count=0;
int sockfd;
char sendline[Buflen];
sockfd= socket(AF_INET,SOCK_STREAM,0);
memset(&server_addr,0,sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(PORT);
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_addr.s_addr = inet_addr(argv[1]);
connect(sockfd,(struct sockaddr *)&server_addr,sizeof(server_addr));
//與服務(wù)器端進(jìn)行通信
memset(sendline,'x',sizeof(Buflen));
while ( (n=write(sockfd,sendline,Buflen))>0 )
{
count++;
printf("already write %d bytes -- %d\n",n,count);
}
if(n<0)
perror("write error");
close(sockfd);
}下面的tcpServer.c是服務(wù)端程序,服務(wù)端并不接收數(shù)據(jù)。
#include <stdio.h>
#include <stdlib.h>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <memory.h>
#include <unistd.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <string.h>
#define PORT 8888 //定義通信端口
#define BACKLOG 5 //定義偵聽隊列長度
#define buflen 1024
int listenfd,connfd;
int main(int argc,char *argv[])
{
struct sockaddr_in server_addr; //存儲服務(wù)器端socket地址結(jié)構(gòu)
struct sockaddr_in client_addr; //存儲客戶端 socket地址結(jié)構(gòu)
pid_t pid;
listenfd = socket(AF_INET,SOCK_STREAM,0);
memset(&server_addr,0,sizeof(server_addr));
server_addr.sin_family = AF_INET; //協(xié)議族
server_addr.sin_addr.s_addr = htonl(INADDR_ANY); //本地地址
server_addr.sin_port = htons(PORT);
bind(listenfd,(struct sockaddr *)&server_addr,sizeof(server_addr));
listen(listenfd,BACKLOG);
for(;;)
{
socklen_t addrlen = sizeof(client_addr);
connfd = accept(listenfd,(struct sockaddr *)&client_addr,&addrlen);
if(connfd<0)
perror("accept error");
printf("receive connection\n");
if((pid = fork()) == 0)
{
close(listenfd);
sleep(1000);//子進(jìn)程不接收數(shù)據(jù),sleep 1000秒
exit(0);
}
else
{
close(connfd);
}
}
}首先編譯運(yùn)行服務(wù)端,然后啟動客戶端,運(yùn)行結(jié)果如下:

可以看到客戶端write成功377次后就陷入了阻塞,注意這個時候不能說明發(fā)送端的套接字發(fā)送緩沖區(qū)一定是滿的,只能說明套接字發(fā)送緩沖區(qū)的可用空間小于write請求寫的自己數(shù)——1024。
非阻塞方式的情況
下面看一下非阻塞套接字情況下,write的工作方式:對于一個非阻塞的TCP套接字,如果發(fā)送緩沖區(qū)中根本沒用空間,輸出函數(shù)將立即返回一個EWOULDBLOCK錯誤。如果發(fā)送緩沖區(qū)中有一些空間,返回值將是內(nèi)核能夠復(fù)制到該緩沖區(qū)的字節(jié)數(shù)。這個字節(jié)數(shù)也成為“不足計數(shù)”。
這樣就可以知道非阻塞情況下服務(wù)端一直sleep,客戶端一直write數(shù)據(jù)的效果了:開始客戶端write成功,隨著客戶端write,接收端的套接字接收緩沖區(qū)和發(fā)送端的套接字發(fā)送緩沖區(qū)會被填滿。當(dāng)發(fā)送端的套接字發(fā)送緩沖區(qū)的可用空間小于write請求寫的字節(jié)數(shù)時,write立即返回-1,并將errno置為EWOULDBLOCK。
可以用下面的程序驗證,其中,服務(wù)端程序代碼和上面例子一樣,我們只看客戶端非阻塞模式代碼:
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <stdlib.h>
#include <memory.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <fcntl.h>
#include <errno.h>
#define PORT 8888
#define Buflen 1024
int main(int argc,char *argv[])
{
struct sockaddr_in server_addr;
int n,flags,count=0;
int sockfd;
char sendline[Buflen];
sockfd= socket(AF_INET,SOCK_STREAM,0);
memset(&server_addr,0,sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(PORT);
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_addr.s_addr = inet_addr(argv[1]);
connect(sockfd,(struct sockaddr *)&server_addr,sizeof(server_addr));
flags=fcntl(sockfd,F_GETFL,0); //將已連接的套接字設(shè)置為非阻塞模式
fcntl(sockfd,F_SETFL,flags|O_NONBLOCK);
memset(sendline,'a',sizeof(Buflen));
while ( (n=write(sockfd,sendline,Buflen))>0 )
{
count++;
printf("already write %d bytes -- %d\n",n,count);
}
if(n<0)
{
if(errno!=EWOULDBLOCK)
perror("write error");
else
printf("EWOULDBLOCK ERROR\n");
}
close(sockfd);
}首先編譯運(yùn)行服務(wù)端,然后啟動客戶端,運(yùn)行結(jié)果如下圖所示。

可以看到客戶端成功write 185次后就發(fā)生套接字發(fā)送緩沖區(qū)空間不足,從而返回EWOULDBLOCK錯誤。我們注意到每次write同樣的字節(jié)數(shù)(1024)阻塞模式下能write成功377次,為什么非阻塞情況下要少呢?
這是因為阻塞模式下一直write到接收端的套接字接收緩沖區(qū)和發(fā)送端的套接字發(fā)送緩沖區(qū)都滿的情況才會阻塞。而非阻塞模式情況下有可能是發(fā)送端發(fā)送過程的第二步較慢,造成發(fā)送端的套接字發(fā)送緩沖區(qū)很快寫滿,而接收端的套接字接收緩沖區(qū)還沒有滿,這樣write就會僅僅因為發(fā)送端的套接字發(fā)送緩沖區(qū)滿而返回錯誤。
本文源碼地址:
https://github.com/qinlizhong1/C/write
本文示例代碼環(huán)境:
操作系統(tǒng):macOs 12.1
編譯器:gcc
— 完 —
