作為業務 SRE,我們所運維的業務,常常以 Linux+TCP/UDP daemon 的形式對外提供服務。SRE 需要對伺服器數據包的接收和發送路徑有全面的了解,以方便在服務異常時能快速定位問題。
以 tcp 協議為例,本文將對 Linux 內核網絡數據包接收的路徑進行整理和說明,希望對大家所有幫助。
Linux 數據包接收路徑的整體說明
接收數據包是一個複雜的過程,涉及很多底層的技術細節 , 這裡先做一下大概的說明 :
NIC (network interface card) 在系統啟動過程中會向系統註冊自己的各種信息,系統會分配專門的內存緩衝區,
NIC 接收到數據包之後,就會存放在內存緩衝區,通過硬體中斷通知內核有新的數據包需要處理 .
內核從緩衝區取走 NIC 接收過來的數據,交給 TCP/IP 協議棧處理。
內核的 TCP/IP 協議棧代碼進行處理後,更新協議的各種狀態,然後交給應用程式的 socket buffer。
然後應用程式就可以通過 read() 系統調用,從對應的 socket 文件中,讀取數據。
對內核數據包接收的路徑做一下分層,總體可分為三層 :
- 網卡層面
- 1.1 網卡接收到數據包
- 1.2 將數據包從網卡硬體轉移到主機內存中 .
- 內核層面
- 2.1 TCP/IP 協議逐層處理
- 應用程式層面
- 3.1 應用程式通過 read() 系統調用 , 從 socket buffer 讀取數據
如下圖 :
接下來解釋一下什麼是 NAPI
什麼是 NAPI
系統啟動時會為網卡分配 Ring Buffer (環形緩衝區 ), Ring Buffer 放的是一個個 Packet Descriptor(數據包描述符),是實際數據包的指針。實際的數據包是存放在另一塊內存區域中(由網卡 Driver 預先申請好),稱為 sk_buffers, sk_buffers 是可以由 DMA(https://en.wikipedia.org/wiki/DMA) 直接訪問的 .
Ring Buffer 里的 Packet Descriptor ,有兩種狀態:ready 和 used 。初始時 Descriptor 是空的,指向一個空的 sk_buffer,處在 ready 狀態。當有數據時,DMA 負責從 NIC 取數據,並在 Ring Buffer 上按順序找到下一個 ready 的 Descriptor,將數據存入該 Descriptor 指向的 sk_buffer 中,並標記 Descriptor 為 used。因為是按順序找 ready 的 Descriptor, 所以 Ring Buffer 是個 FIFO 的隊列。
內核採用 struct sk_buffer(https://elixir.bootlin.com/linux/v4.4/source/include/linux/skbuff.h#L545) 來描述一個收到的數據包, sk_buffer 內有個 data 指針會指向實際的物理內存。
當通過 DMA 機制存放完數據之後,NIC 會觸發一個 IRQ(硬體中斷) 讓 CPU 去處理收到的數據。因為每次觸發 IRQ 後 CPU 都要花費時間去處理 Interrupt Handler,如果 NIC 每收到一個 Packet 都觸發一個 IRQ 會導致 CPU 花費大量的時間執行 Interrupt Handler,而每次執行只能從 Ring Buffer 中拿出一個 Packet,雖然 Interrupt Handler 執行時間很短,但這麼做非常低效,並會給 CPU 帶來很多負擔。所以目前都是採用一個叫做 New API(NAPI)(https://wiki.linuxfoundation.org/networking/napi) 的機制,去對 IRQ 做合併以減少 IRQ 次數,目前大部分網卡 Driver 都支持 NAPI 機制。NAPI 機制是如何合併和減少 IRQ 次數的 , 可以簡單理解為: 中斷 + 輪詢 。在數據量大時,一次中斷後通過輪詢接收一定數量數據包再返回,避免產生多次中斷 , 具體細節大家可以參考這篇文章 (https://ylgrgyq.github.io/2017/07/23/linux-receive-packet-1/).
概括一下網卡層面整個數據包的接收過程:
- 驅動程序事先在內存中分配一片緩衝區來接收數據包 , 叫做 sk_buffers.
- 將上述緩衝區的地址和大小(即數據包描述符),加入到 rx ring buffer。描述符中的緩衝區地址是 DMA 使用的物理地址 ;
- 驅動程序通知網卡有新的描述符 (或者說有空閒可用的描述符 )
- 網卡從 rx ring buffer 中取出描述符 , 從而獲取緩衝區的地址和大小 .
- 當一個新的數據包到達,網卡 (NIC) 調用 DMA engine,把數據包放入 sk_buffer.
如果整個過程正常 , 網卡會發起中斷,通知內核的中斷程序將數據包傳遞給 IP 層,進入 TCP/IP 協議棧處理。
每個數據包經過 TCP 層一系列複雜的步驟,更新 TCP 狀態機,最終到達 socket 的 recv Buffer,等待被應用程式接收處理。
然後 , 內核應該會把剛占用掉的描述符重新放入 ring buffer,這樣網卡就可以繼續使用描述符了。
我們可以使用 ethtool 命令,進行 Ring Buffer 的查看和設置 .
1 查看網卡當前的設置(包括Ring Buffer): ethtool -g eth1
2 改變Ring Buffer大小: ethtool -G eth1 rx 4096 tx 4096
四 中斷處理程序如何把數據包傳遞給網絡協議層
我們通過一張圖來說明下 ,
上圖中涉及到非常多的技術細節,限於篇幅我們只做總體的說明 :
- NIC 發起的硬體中斷(也稱為中斷處理的上半部),被內核執行之後,開啟了軟中斷(中斷處理的下半部),並馬上退出硬體中斷處理程序 , 以便其他硬體可以繼續發起硬體中斷 .
- 軟中斷處理程序中,通過 poll 循環把數據從 Ring Buffer 取走,傳給網絡協議層處理,然後重新開啟之前已經禁用的網卡硬體中斷 .
- 當有新的數據包到達網卡時 , 回到第 1 步 .
這裡有幾點需要額外說明 :
什麼是中斷處理的上半部和下半部
我們知道中斷隨時可能發生,因此中斷處理程序也就隨時可能執行。所以必須保證中斷處理程序能夠快速執行,這樣才能儘快恢復被中斷的代碼。因此儘管對硬體而言,作業系統能迅速對其中斷進行服務非常重要,而對於系統其他部分而言,讓中斷處理程序儘可能在短時間內完成運行也同樣重要。所以我們一般把中斷處理切為 2 個部分,上半部在接收到一個中斷時立刻開始執行,但他只做必要的工作,例如對接收的中斷進行應答或復位硬體,這些工作都是在所有中斷被禁止的情況下完成的。而那些允許被稍後執行的工作,都會推到下半部去,下半部並不會馬上執行,而是會在稍後適當的時機執行。
網卡的軟中斷處理
現在的網卡基本都支持 RSS(Receive Side Scaling)(https://en.wikipedia.org/wiki/Network_interface_controller#RSS),也就是多對列技術。一張網卡有多個隊列,每個隊列都有各自的 IRQ 號和 Ring Buffer,但是默認情況下網卡的軟中斷都是在 CPU0 上處理,在流量大的時候,會造成 CPU0 負載打滿,引起丟包. 我們可以通過綁定中斷和 CPU 的親和性,把中斷處理均衡到多核心上 (https://www.vpsee.com/2010/07/load-balancing-with-irq-smp-affinity/),提升系統整體性能 .
什麼是 RPS
RPS 全稱是 Receive Packet Steering, 採用軟體模擬的方式,實現了多隊列網卡所提供的功能,分散了在多 CPU 系統上數據接收時的軟中斷負載, 把軟中斷分到各個 CPU 處理,而不需要硬體支持,在多核 CPU 和單隊列網卡的情況下,開啟 RPS 可以大大提升網絡性能 .
如果系統開了 RPS, 數據包會被緩衝在 TCP 層之前的隊列中 , 我們可以通過 net.core.netdev_max_backlog 適當加大這個隊列的長度,以保證上層的處理時間 .
TCP/IP 協議棧層面
此時數據包已經接入內核處理區域,由內核的 TCP/IP 協議棧處理
(一) 連接建立
大家知道,兩個基於 tcp 協議的 socket 要通信,首先要進行連接建立的過程,然後才是數據傳輸的過程。
我們先簡單看下連接的建立過程,客戶端向 server 發送 SYN 包,server 回復 SYN+ACK,同時將這個處於 SYN_RECV 狀態的連接保存到半連接隊列。客戶端返回 ACK 包完成三次握手,server 將 ESTABLISHED 狀態的連接移入 accept 隊列,等待應用調用 accept()。
可以看到建立連接涉及兩個隊列:
- 半連接隊列 (SYN Queue): 保存 SYN_RECV 狀態的連接。隊列長度由 net.ipv4.tcp_max_syn_backlog 設置
- 完整連接隊列 (ACCEPT Queue): 保存 ESTABLISHED 狀態的連接。隊列長度為 min(net.core.somaxconn, backlog)。其中 backlog 是我們創建 ServerSocket(int port,int backlog) 時指定的參數,最終會傳遞給 listen 方法:
#include
int listen(int sockfd, int backlog);
如果我們設置的 backlog 大於 net.core.somaxconn,完整連接隊列的長度將被設置為 net.core.somaxconn。
注意:不同的程式語言都有相應的 socket 申請方法 , 比如 Python 是 socket 模塊.在服務端監聽一個埠,底層都要經過 3 個步驟:
申請 socket、bind 相應的 IP 和 port、調用 listen 方法進行監聽。這個 listen 方法 python 會進行封裝,別的程式語言也會進行封裝,但最終都是調用系統的 listen() 調用
我們對這兩個隊列做一下總結 :
(二) 數據傳輸
連接建立後 , 就到了 socket 數據傳輸的層面。此時 kernel 能夠為應用程式做的,就是通過 socket Recv Buffer 緩存數據 , 儘量保證上層處理時間 .
1 Recv Buffer 自動調節機制
kernel 可以根據實際情況,自動調節 Recv Buffer 的大小 , 以期找到性能和資源的平衡點 .
當 net.ipv4.tcp_moderate_rcvbuf 設置為 1 時,自動調節機制生效,每個 TCP 連接的 recv Buffer 由下面的 3 元數組指定 (min, default, max):
net.ipv4.tcp_rmem = 4096 87380 16777216
最初 Recv Buffer 被設置為 87380,同時這個預設值會覆蓋 net.core.rmem_default 的設置 , 隨後 recv buffer 根據實際情況在最大值和最小值之間動態調節。
當 net.ipv4.tcp_moderate_rcvbuf 被設置為 0,或者設置了 socket 選項 SO_RCVBUF,緩衝的動態調節機制被關閉。
如果緩衝的動態調節機制被關閉 , 同時 socket 自己也沒有設置 SO_RCVBUF 選項,那麼一個 socket 的默認 Buffer 大小將由 net.core.rmem_default 決定,但是應用程式仍然可以通過 setsockopt() 系統調用,加大自己的 Recv Buffer, 最大不能超過 net.core.rmem_max 的設定 .
因此,我們可以得出如下總結 :
- 沒有特殊情況 , 建議打開 net.ipv4.tcp_moderate_rcvbuf=1, 這樣 kernel 會自動調整每個 socket 的 Recv Buffer
- 我們應該把 net.ipv4.tcp_rmem 中 max 值和 net.core.rmem_max 值設置成一致,這樣假設應用程式沒有關注到這個點,仍然可以由 kernel 把它自動調節成系統最大的 Recv Buffer.
- Recv Buffer 的默認值可以適當進行提高 , 包括 net.core.rmem_default 和 net.ipv4.tcp_rmem 中的 default 設置 , 以更加激進的方式傳輸數據 .