一文徹底搞通TCP之send & recv原理

  • 2021 年 10 月 20 日
  • 筆記

接觸過網路開發的人,大抵都知道,上層應用使用send函數發送數據,使用recv來接收數據,而send和recv的實現原理又是怎樣的呢?

在前面的幾篇文章中,我們有提過,TCP是個可靠的、全雙工協議。其流量控制或者擁塞控制依賴於滑動窗口和擁塞窗口的滑動來實現,而這兩個窗口的滑動實現則是依賴於TCP中的兩個buffer,這兩個buffer則是TCP socket在內核中的發送緩衝區(send buffer)和接收緩衝區(recv buffer)。

在本文中,我們首先會簡單介紹下TCP中發送緩衝區和接收緩衝區的作用(對於後面理解send和recv非常重要),然後講解Linux系統下,TCP發送和接收數據是如何實現的。

緩衝區

緩衝區,可以理解為是一個臨時快取。

對於發送端來說,socket將數據拷貝到發送臨時緩衝區,就立即返回到應用層去做其他的事情,而剩下的將臨時緩衝區的數據通過內核發送到對端,這就是tcp的事。

對於接收端來說,內核將網路中的數據拷貝到緩衝區,等待上層應用讀取。

發送緩衝區

上面有講,進程在調用send()發送的數據的時候,最簡單情況(也是一般情況), 將數據拷貝進入socket的內核發送緩衝區之中,然後send便會立即返回。

換句話說,在應用層調用send()返回之時,數據不一定會發送到對端去(和write寫文件有點類似),send()僅僅是把應用層buffer的數據拷貝進socket的內核發送buffer中。

TCP socket有兩種模式,即阻塞模式和非阻塞模式。

  • 在阻塞模式下, send函數的過程是將應用程式請求發送的數據拷貝到發送快取中發送並得到確認後再返回.但由於發送快取的存在,表現為:如果發送快取大小比請求發送的大小要大,那麼send函數立即返回,同時向網路中發送數據;否則,send向網路發送快取中不能容納的那部分數據,並等待對端確認後再返回(接收端只要將數據收到接收快取中,就會確認,並不一定要等待應用程式調用recv)

  • 在非阻塞模式下,send函數的過程僅僅是將數據拷貝到協議棧的快取區而已,如果快取區可用空間不夠,則盡能力的拷貝,返回成功拷貝的大小;如快取區可用空間為0,則返回-1,同時設置errno為EAGAIN.

在Linux內核中,有兩種方式可以查看tcp緩衝區buffer大小。

1、通過查看/etc/sysctl.ronf下的net.ipv4.tcp_wmem值

2、 通過命令’cat /proc/sys/net/ipv4/tcp_wmem’

cat /proc/sys/net/ipv4/tcp_wmem
4096 16384 4194304

從上面可以看出,在筆者所在的伺服器上,tcp send緩衝區buffer有3個值,分別是4096 16384 4194304。

  • 第一個值是socket的發送快取區分配的最少位元組數,
  • 第二個值是默認值(該值會被net.core.wmem_default覆蓋),快取區在系統負載不重的情況下可以增長到這個值
  • 第三個值是發送快取區空間的最大位元組數(該值會被net.core.wmem_max覆蓋)

我們可以通過程式,來修改當前tcp socket的發送緩衝區大小,需要注意的是,如下的程式碼修改,只會修改當前特定的socket。

int buffer_len = 10240;
setsockopt(fd, SOL_SOCKET, SO_SNDBUF, (void*)&buffer_len, buffer_len);
接收緩衝區

接收緩衝區被TCP用來快取網路上來的數據,一直保存到應用進程讀走為止。

對於TCP,如果應用進程一直沒有讀取,接收緩衝區滿了之後,發生的動作是:收端通知發端,接收窗口關閉(win=0)。這個便是滑動窗口的實現。保證TCP套介面接收緩衝區不會溢出,從而保證了TCP是可靠傳輸。因為對方不允許發出超過所通告窗口大小的數據。 這就是TCP的流量控制,如果對方無視窗口大小而發出了超過窗口大小的數據,則接收方TCP將丟棄它。

與查看發送緩衝區大小的方式一樣,接收緩衝區也是通過如上的兩種方式。 1、通過查看/etc/sysctl.ronf下的net.ipv4.tcp_rmem值

2、通過命令’cat /proc/sys/net/ipv4/tcp_rmem’

cat /proc/sys/net/ipv4/tcp_rmem
4096 87380 4194304

TCP接收緩衝區buffer有3個值,分別是4096 87380 4194304。

  • 第一個值是socket的接收快取區的最少位元組數,
  • 第二個值是默認值(該值會被net.core.rmem_default覆蓋),快取區在系統負載不重的情況下可以增長到這個值
  • 第三個值是接收快取區空間的最大位元組數(該值會被net.core.rmem_max覆蓋)

同樣的,可以通過如下程式碼,修改接收緩衝區的大小。

int buffer_len = 10240;
setsockopt(fd, SOL_SOCKET, SO_RCVBUF, (void*)&buffer_len, buffer_len);

實現原理

為了便於我們理解TCP的整個傳輸過程,我們先了解下TCP的四層模型以及四冊模型在數據傳輸中的流向。後面我們將從四層模型的角度來分析send和recv函數在每層中都做了什麼。

send原理
NAME
       send, sendto, sendmsg - send a message on a socket

SYNOPSIS
       #include <sys/types.h>
       #include <sys/socket.h>

       ssize_t send(int sockfd, const void *buf, size_t len, int flags);

DESCRIPTION
       The system calls send(), sendto(), and sendmsg() are used to transmit a message to another socket.

當調用該函數時,send函數: 1、先比較待發送數據的長度len和套接字sockfd的可用發送緩衝區的長度

  • 如果數據長度len大於發送緩衝區的長度,則分多次發送
  • 如果果len小於或者等於sockfd的緩衝區長度,那麼send先檢查協議是否正在發送sockfd的發送緩衝中的數據
    • 如果是就等待協議把數據發送完
    • 否則,如果協議還沒有開始發送s的發送緩衝中的數據或者s的發送緩衝中沒有數據,那麼send就比較sockfd的發送緩衝區的剩餘空間和len
      • 如果len大於剩餘空間大小,send就一直等待協議把s的發送緩衝中的數據發送完
      • 如果len小於剩餘空間大小,send就僅僅把buf中的數據copy到剩餘空間里。 如果send函數copy數據成功,就返回實際copy的位元組數,如果send在copy數據時出現錯誤,那麼send就返回SOCKET_ERROR; 如果send在等待協議傳送數據時網路斷開的話,那麼send函數也返回SOCKET_ERROR。 需要注意send函數把buf中的數據成功copy到s的發送緩衝的剩餘空間里後它就返回了,但是此時這些數據並不一定馬上被傳到連接的另一端。 如果協議在後續的傳送過程中出現網路錯誤的話,那麼下一個socket函數就會返回SOCKET_ERROR.(每一個除send外的socket函數在執行的最開始總要先等待套接字的發送緩衝中的數據被協議傳送完畢才能繼續,如果在等待時出現網路錯誤,那麼該socket函數就返回SOCKET_ERROR)。

如果對具體實現不是很感興趣,可直接此部分

從四層模型的角度來分析send實現。

應用層

對於TCP,應用程式在創建socket之後,調用connect()函數,通過socket使客戶端和服務端建立連接。然後就可以調用send函數發送數據。

傳輸層

數據在傳輸層進行處理,以TCP協議為例,其主要有以下功能:

  • 1、構造TCP段
  • 2、計算校驗和
  • 3、發送回復(ACK)包
  • 4、滑動窗口(sliding windown)等操作保證可靠性。

不同的協議有不同的發送函數,TCP調用tcp_sendmsg函數,而UDP則調用的是sock_sendmsg函數。

tcp_sendmsg()的主要工作是傳輸用戶層的數據,將數據放入skb中。然後調用tcp_push()發送,tcp_push函數調用tcp_write_xmit() 函數,依次調用發送函數tcp_transmit_skb將skb封裝tcp頭之後,回調ip_queue_xmit。

網路層

ip_queue_xmit(skb)主要有路由查找校驗、封裝ip頭和ip選項,最後通過ip_local_out發送數據包。

數據鏈路層

數據鏈路層在不可靠的物理介質上提供可靠的傳輸。該層的功能包括:物理地址定址、數據成幀、流量控制、數據錯誤檢測、重發等。這一層的數據單位稱為幀(frame)。

上圖為send函數源碼的調用邏輯圖,對源碼有興趣的話,可以在net/tcp.c找到對應的實現。

recv原理
NAME
       recv, recvfrom, recvmsg - receive a message from a socket

SYNOPSIS
       #include <sys/types.h>
       #include <sys/socket.h>

       ssize_t recv(int sockfd, void *buf, size_t len, int flags);

       ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
                        struct sockaddr *src_addr, socklen_t *addrlen);

       ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);

DESCRIPTION
       The recvfrom() and recvmsg() calls are used to receive messages from a socket, and may be used to receive data on a socket whether or not it is connection-oriented.

       If  src_addr  is not NULL, and the underlying protocol provides the source address, this source address is filled in.  When src_addr is NULL, nothing is filled in; in this case, addrlen is not used, and should also be NULL.  The argument
       addrlen is a value-result argument, which the caller should initialize before the call to the size of the buffer associated with src_addr, and modified on return to indicate the actual size of the source address.  The returned address is
       truncated if the buffer provided is too small; in this case, addrlen will return a value greater than was supplied to the call.

       The recv() call is normally used only on a connected socket (see connect(2)) and is identical to recvfrom() with a NULL src_addr argument.

當調用該函數時候:

  • 先檢查套接字sockfd的接收緩衝區
  • 如果sockfd接收緩衝區中沒有數據或者協議正在接收數據,那麼recv就一直等待,直到協議把數據接收完畢。
  • 當協議把數據接收完畢,recv函數就把sockft的接收緩衝中的數據copy到buf中,recv函數返回其實際copy的位元組數。
  • 如果recv在copy時出錯,那麼它返回SOCKET_ERROR;
  • 如果recv函數在等待協議接收數據時網路中斷了,那麼它返回0 。
  • 對方優雅的關閉socket並不影響本地recv的正常接收數據;
  • 如果協議緩衝區內沒有數據,recv返回0,指示對方關閉;
  • 如果協議緩衝區有數據,則返回對應數據(可能需要多次recv),在最後一次recv時,返回0,指示對方關閉。

如果對具體實現不是很感興趣,可直接此部分

從四層模型的角度來分析recv實現。

數據鏈路層

當數據包到達機器的物理網卡時會觸發一個中斷,中斷處理程式分配skb_buff數據結構,並將從網卡I/O接收到的數據幀複製到skb_buff緩衝區,並設置skb_buff相應的參數。

然後發出軟中斷,通知內核接收新的數據幀。進入軟中斷處理流程,調用net_rx_action函數。進入 netif _receive_skb 處理流程。

netif_receive_skb 根據在全局數組 ptype_all 和 ptype_base 中註冊的網路層數據報類型,將數據報發送到不同的網路層協議接收函數(INET域主要是ip_rcv和arp_rcv)。

網路層

ip_rcv函數為網路層的入口函數。該函數做的第一件事就是數據校驗,然後調用ip_rcv_finish這個函數。

ip_rcv_finish函數會調用ip_route_input函數來更新路由,然後尋找路由,決定消息是發送到本地機器,轉發還是丟棄。

如果發送到本機,則調用ip_local_deliver函數,可以進行碎片整理(合併多個包),並調用ip_local_deliver_finish。最後調用下一層介面,包括tcp_v4_rcv(TCP)、udp_rcv(UDP)、icmp_rcv(ICMP)、igmp_rcv(IGMP)。如果需要轉發,則進入轉發流程,調用dev_queue_xmit,進入鏈路層處理流程。如果不是發送到本機,應該是轉發,調用 ip_forward 進行轉發 。

傳輸層

在該層,我們會做一些完整性檢查,如果發現問題就丟包。如果是tcp,則調用tcp_v4_do_rcv。

然後sk->sk_state == TCP_ESTABLISHED,調用tcp_rcv_builted,調用 tcp_data_queue 方法將消息放入隊列。然後使用 tcp_ofo_queue 方法將消息插入接收到 Queued 。

應用層

應用程式調用讀取或者 recv 的時候,該調用被映射到 /net/socket.c 中的sys_recv系統調用,然後調用 sock_recvmsg 函數。

TCP 會調用 tcp_recvmsg。該函數從套接字緩衝區複製數據到緩衝區。

上述過程,我們總結下就是: 1、數據幀從外部網路到達網卡 2、網卡把幀DMA到記憶體Ring Buffer中 3、硬中斷通知CPU 4、CPU響應硬中斷,簡單處理後發憷軟中斷 5、軟中斷進程處理軟中斷,調用網卡驅動註冊的pool函數開始收包 6、幀被從Ring Buffer中摘下來,存儲到skb中 7、協議層開始處理網路幀,並將處理完成後的數據放入socket的接收緩衝區中

上圖為整個網路數據接收的函數調用過程,對月接收端來說,當有數據來的時候,都是通過終端來通知內核,最終通過回調,調用系統函數。

下圖是send和recv完整的函數調用過程

常見問題

在實際應用中,如果發送端是非阻塞發送,由於網路的阻塞或者接收端處理過慢,通常出現的情況是,發送應用程式看起來發送了10k的數據,但是只發送了2k到對端快取中,還有8k在本機快取中(未發送或者未得到接收端的確認).那麼此時,接收應用程式能夠收到的數據為2k.假如接收應用程式調用recv函數獲取了1k的數據在處理,在這個瞬間,發生了以下情況之一,雙方表現為:

  1. 發送應用程式認為send完了10k數據,關閉了socket:

發送主機作為tcp的主動關閉者,連接將處於FIN_WAIT1的半關閉狀態(等待對方的ack),並且,發送快取中的8k數據並不清除,依然會發送給對端.如果接收應用程式依然在recv,那麼它會收到餘下的8k數據(這個前題是,接收端會在發送端FIN_WAIT1狀態超時前收到餘下的8k數據.), 然後得到一個對端socket被關閉的消息(recv返回0).這時,應該進行關閉.

  1. 發送應用程式再次調用send發送8k的數據:

假如發送快取的空間為20k,那麼發送快取可用空間為20-8=12k,大於請求發送的8k,所以send函數將數據做拷貝後,並立即返回8192;

假如發送快取的空間為12k,那麼此時發送快取可用空間還有12-8=4k,send()會返回4096,應用程式發現返回的值小於請求發送的大小值後,可以認為快取區已滿,這時必須阻塞(或通過select等待下一次socket可寫的訊號),如果應用程式不理會,立即再次調用send,那麼會得到-1的值, 在linux下表現為errno=EAGAIN.

  1. 接收應用程式在處理完1k數據後,關閉了socket: 接收主機作為主動關閉者,連接將處於FIN_WAIT1的半關閉狀態(等待對方的ack).然後,發送應用程式會收到socket可讀的訊號(通常是 select調用返回socket可讀),但在讀取時會發現recv函數返回0,這時應該調用close函數來關閉socket(發送給對方ack);

如果發送應用程式沒有處理這個可讀的訊號,而是在send,那麼這要分兩種情況來考慮,假如是在發送端收到RST標誌之後調用send,send將返回-1,同時errno設為ECONNRESET表示對端網路已斷開,但是,也有說法是進程會收到SIGPIPE訊號,該訊號的默認響應動作是退出進程,如果忽略該訊號,那麼send是返回-1,errno為EPIPE(未證實);如果是在發送端收到RST標誌之前,則send像往常一樣工作;

以上說的是非阻塞的send情況,假如send是阻塞調用,並且正好處於阻塞時(例如一次性發送一個巨大的buf,超出了發送快取),對端socket關閉,那麼send將返回成功發送的位元組數,如果再次調用send,那麼會同上一樣.

  1. 交換機或路由器的網路斷開:

接收應用程式在處理完已收到的1k數據後,會繼續從快取區讀取餘下的1k數據,然後就表現為無數據可讀的現象,這種情況需要應用程式來處理超時.一般做法是設定一個select等待的最大時間,如果超出這個時間依然沒有數據可讀,則認為socket已不可用.

發送應用程式會不斷的將餘下的數據發送到網路上,但始終得不到確認,所以快取區的可用空間持續為0,這種情況也需要應用程式來處理.

如果不由應用程式來處理這種情況超時的情況,也可以通過tcp協議本身來處理,具體可以查看sysctl項中的: net.ipv4.tcp_keepalive_intvl net.ipv4.tcp_keepalive_probes net.ipv4.tcp_keepalive_time

結論

  • TCP協議本身是為了保證可靠傳輸,並不等於應用程式用tcp發送數據就一定是可靠的,必須要容錯;

  • send()只負責拷貝,拷貝到內核就返回

  • 此次send()調用所觸發的程式錯誤,可能會在本次返回,也可能在下次調用網路IO函數的時候被返回。

  • 在進行TCP協議傳輸的時候,要注意數據流傳輸的特點,recv和send不一定是一一對應的(一般情況下是一一對應),也就是說並不是send一次,就一定recv一次就接收完,有可能send一次,recv多次才接收完,也可能send多次,一次recv就接收完了。TCP協議會保證數據的有序完整的傳輸,但是如何去正確完整的處理每一條資訊,是開發人員的事情。

伺服器在循環recv,recv的緩衝區大小為100byte,客戶端在循環send,每次send 6byte數據,則recv每次收到的數據可能為6byte,12byte,18byte,這是隨機的,編程的時候注意正確的處理。

7參考

//slidetodoc.com/network-applications-user-socket-bsd-sockets-kernel-sock/ //www.programmersought.com/article/819124112/ //www.programmersought.com/article/749525105/ //www.fatalerrors.org/a/0dl00z0.html //blog.csdn.net/w839687571/article/details/44409355 //lkml.iu.edu/hypermail/linux/kernel/1405.3/01700.html //linux-kernel-labs.github.io/refs/heads/master/labs/networking.html //git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/net/ipv4/tcp.c#n1581