Linux 中一個網絡包的發送 - 接收流程

如果你對 Linux 是如何實現 對用戶原始的網絡包進行協議頭封裝與解析,爲什麼會粘包拆包,期間網絡包經歷了哪些緩衝區、經歷了幾次拷貝 (CPU、DMA),TCP 又是如何實現滑動 / 擁塞窗口 這幾個話題感興趣的話,不妨看下去吧~

1. Linux 發送 HTTP 網絡包圖像

圖像解析

寫入套接字緩衝區 (添加 TcpHeader)

用戶態進程通過 write() 系統調用切到內核態將用戶進程緩衝區中的 HTTP 報文數據通過 Tcp Process 處理程序爲 HTTP 報文添加 TcpHeader,並進行 CPU copy 寫入套接字發送緩衝區,每個套接字會分別對應一個 Send-Q(發送緩衝區隊列)、Recv-Q(接收緩衝區隊列),可以通過 ss -nt 語句獲取當前的套接字緩衝區的狀態;

# ss -nt
State   Recv-Q   Send-Q         Local Address:Port              Peer Address:Port
ESTAB   0        0              192.168.183.130:52454           192.168.183.130:14465
State   Recv-Q   Send-Q         Local Address:Port              Peer Address:Port
ESTAB   0        1024           192.168.183.130:52454           192.168.183.130:14465
State   Recv-Q   Send-Q         Local Address:Port              Peer Address:Port
ESTAB   0        2048           192.168.183.130:52454           192.168.183.130:14465
......
State   Recv-Q   Send-Q         Local Address:Port              Peer Address:Port
ESTAB   0        13312          192.168.183.130:52454           192.168.183.130:14465
State   Recv-Q   Send-Q         Local Address:Port              Peer Address:Port
ESTAB   0        14336          192.168.183.130:52454           192.168.183.130:14465
State   Recv-Q   Send-Q         Local Address:Port              Peer Address:Port
ESTAB   0        14480          192.168.183.130:52454           192.168.183.130:14465

套接字緩衝區發送隊列由一個個 struct sk_buff 結構體的鏈表組成,其中一個 sk_buff 數據結構對應一個網絡包;這個結構體後面會詳細講,是 Linux 實現網絡協議棧的核心數據結構。

IP 層

接着對 TCP 包在 IP Layer 層進行網絡包 IpHeader 的組裝,並經由 QDisc(排隊規則) 進行轉發;

數據鏈路層 / 物理層

接着網卡設備通過 DMA Engine 將內存中 RingBuffer 的 Tx.ring 塊中的 IP 包 (sk_buff)copy 到網卡自身的內存中,並生成 CRC 等校驗數據形成數據鏈路包頭部並進行網絡傳輸。

2. sk_buff 數據結構解析

通過對 sk_buff 數據結構解析的過程中,我們回答文章頭部的幾個問題,以及窺見 Linux 中的一些設計思想;

進行協議頭的增添

我們知道,按照網絡棧的設定,發送網絡包時,每經過一層,都會增加對應協議層的協議首部,因此 Linux 採用在 sk_buff 中的一個 Union 結構體進行標識:

struct sk_buff {
    union {
           struct tcphdr	*th; // TCP Header
           struct udphdr	*uh; // UDP Header
           struct icmphdr	*icmph; // ICMP Header
           struct igmphdr	*igmph; 
           struct iphdr	*ipiph; // IPv4 Header
           struct ipv6hdr	*ipv6h; // IPv6 Header
           unsigned char	*raw; // MAC Header
    } h;
}

結構體中存儲的是指向內存中各種協議的首部地址的指針,而在發送數據包的過程中,sk_buff 中的 data 指針指向最外層的協議頭;

網絡包的大小佔用

考慮一個包含 2bytes 的網絡包,需要包括 預留頭 (64 bytes) + Mac 頭 (14bytes) + IP 頭 (20bytes) + Tcp 頭 (32bytes) + 有效負載爲 2bytes(len) + skb_shared_info(320bytes) = 452bytes,向上取整後爲 512bytes;sk_buff 這個存儲結構佔用 256bytes;則一個 2bytes 的網絡包需要佔用 512+256=768bytes(truesize) 的內存空間;

因此當發送這個網絡包時:

skb->truesize = 768 
skb->datalen  = 0 skb_shared_info 結構有效負載 (非線性區域)
skb->len      = 2 有效負載 (線性區域 + 非線性區域(datalen),這裏暫時不考慮協議頭部)
tail_skb->truesize = 768 
tail_skb->datalen  = 0
tail_skb->len      = 4 (2 + 2)

發送窗口

我們在創建套接字的時候,通過 SO_SENDBUF 指定了發送緩衝區的大小,如果設置了大小爲 2048KB,則 Linux 在真實創建的時候會設置大小 2048*2=4096,因爲 linux 除了要考慮用戶的應用層數據,還需要考慮 linux 自身數據結構的開銷 - 協議頭部、指針、非線性內存區域結構等...

sk_buff 結構中通過 sk_wmem_queued 標識發送緩衝區已經使用的內存大小,並在發包時檢查當前緩衝區大小是否小於 SO_SENDBUF 指定的大小,如果不滿足則阻塞當前線程,進行睡眠,等待發送窗口中有包被 ACK 後觸發內存 free 的回調函數喚醒後繼續嘗試發送;

接收窗口 (擁塞窗口)

    |<---------- RCV.BUFF ---------------->|
          1             2            3
    |<-RCV.USER->|<--- RCV.WND ---->|
----|------------|------------------|------|----
              RCV.NXT

接收窗口主要分爲 3 部分:

進入網卡驅動層

NIC (network interface card) 在系統啓動過程中會向系統註冊自己的各種信息,系統會分配 RingBuffer 隊列及一塊專門的內核內存區用於存放傳輸上來的數據包。每個 NIC 對應一個 R x.ring 和一個 Tx.ring。一個 RingBuffer 上同一個時刻只有一個 CPU 處理數據。

每個網絡包對應的網卡存儲在 sk_buff 結構的 dev_input 中;

RingBuffer 隊列內存放的是一個個描述符 (Descriptor),其有兩種狀態:ready 和 used。

不同的網絡包發送函數有幾次拷貝?

read then write

常見的場景中,當我們要在網絡中發送一個文件,那麼首先需要通過 read 系統調用陷入內核態讀取 PageCache 通過 CPU Copy 數據頁到用戶態內存中,接着將數據頁封裝成對應的應用層協議報文,並通過 write 系統調用陷入內核態將應用層報文 CPU Copy 到套接字緩衝區中,經過 TCP/IP 處理後形成 IP 包,最後通過網卡的 DMA Engine 將 RingBuffer Tx.ring 中的 sk_buff 進行 DMA Copy 到網卡的內存中,並將 IP 包封裝爲幀並對外發送。

PS:如果 PageCache 中不存在對應的數據頁緩存,則需要通過磁盤 DMA Copy 到內存中。

因此 read then write 需要兩次系統調用 (4 次上下文切換,因爲系統調用需要將用戶態線程切換到內核態線程進行執行),兩次 CPU Copy、兩次 DMA Copy。

sendFile

用戶線程調用 sendFile 系統調用陷入內核態,sendFile 無需拷貝 PageCache 中的數據頁到用戶態內存中中,而是通過內核線程將 PageCache 中的數據頁直接通過 CPU Copy 拷貝到套接字緩衝區中,再經由相同的步驟經過一次網卡 DMA 對外傳輸。

因此 sendFile 需要一次系統調用,一次 CPU Copy;

相比於 write,sendFile 少了一次 PageCache 拷貝到內存的開銷,但是需要限制在網絡傳輸的是文件頁,而不是用戶緩衝區中的匿名頁,並且因爲完全在內核態進行數據 copy,因此無法添加用戶態的協議數據;

Kafka 因爲基於操作系統文件系統進行數據存儲,並且文件量比較大,因此比較適合通過 sendFile 進行網絡傳輸的實現;

但是 sendFile 仍然需要一次內核線程的 CPU Copy,因此零拷貝更偏向於無需拷貝用戶態空間中的數據。

mmap + write

相比於 sendFile 直接在內核態進行文件傳輸,mmap 則是通過在進程的虛擬地址空間中映射 PageCache,再經過 write 進行網絡寫入;比較適用於小文件的傳輸,因爲 mmap 並沒有立即將數據拷貝到用戶態空間中,所以較大文件會導致頻繁觸發虛擬內存的 page fault 缺頁異常;

RocketMQ 選擇了 mmap+write 這種零拷貝方式,適用於消息這種小塊文件的數據持久化和傳輸。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/3yzhV6et4O1aQ8huTte4xg