1. 程式人生 > >Linux 網路協議棧收訊息過程-Ring Buffer

Linux 網路協議棧收訊息過程-Ring Buffer

想看能不能完整梳理一下收訊息過程。從 NIC 收資料開始,到觸發軟中斷,交付資料包到 IP 層再經由路由機制到 TCP 層,最終交付使用者程序。會盡力介紹收訊息過程中的各種配置資訊,以及各種監控資料。知道了收訊息的完整過程,瞭解了各種配置,明白了各種監控資料後才有可能在今後的工作中做優化配置。

所有參考內容會列在這個系列最後一篇文章中。

Ring Buffer 相關的收訊息過程大致如下:

图片来自参考1,对 raise softirq 的函数名做了修改,改为了 napi_schedule

圖片來自參考1,對 raise softirq 的函式名做了修改,改為了 napi_schedule

NIC (network interface card) 在系統啟動過程中會向系統註冊自己的各種資訊,系統會分配 Ring Buffer 佇列也會分配一塊專門的核心記憶體區域給 NIC 用於存放傳輸上來的資料包。struct sk_buff 是專門存放各種網路傳輸資料包的記憶體介面,在收到資料存放到 NIC 專用核心記憶體區域後,

sk_buff 內有個 data 指標會指向這塊記憶體。Ring Buffer 佇列記憶體放的是一個個 Packet Descriptor ,其有兩種狀態: ready 和 used 。初始時 Descriptor 是空的,指向一個空的 sk_buff,處在 ready 狀態。當有資料時,DMA 負責從 NIC 取資料,並在 Ring Buffer 上按順序找到下一個 ready 的 Descriptor,將資料存入該 Descriptor 指向的 sk_buff 中,並標記槽為 used。因為是按順序找 ready 的槽,所以 Ring Buffer 是個 FIFO 的佇列。

當 DMA 讀完資料之後,NIC 會觸發一個 IRQ 讓 CPU 去處理收到的資料。因為每次觸發 IRQ 後 CPU 都要花費時間去處理 Interrupt Handler,如果 NIC 每收到一個 Packet 都觸發一個 IRQ 會導致 CPU 花費大量的時間在處理 Interrupt Handler,處理完後又只能從 Ring Buffer 中拿出一個 Packet,雖然 Interrupt Handler 執行時間很短,但這麼做也非常低效,並會給 CPU 帶去很多負擔。所以目前都是採用一個叫做 

New API(NAPI)的機制,去對 IRQ 做合併以減少 IRQ 次數。

接下來介紹一下 NAPI 是怎麼做到 IRQ 合併的。它主要是讓 NIC 的 driver 能註冊一個 poll 函式,之後 NAPI 的 subsystem 能通過 poll 函式去從 Ring Buffer 中批量拉取收到的資料。主要事件及其順序如下:

  1. NIC driver 初始化時向 Kernel 註冊 poll 函式,用於後續從 Ring Buffer 拉取收到的資料
  2. driver 註冊開啟 NAPI,這個機制預設是關閉的,只有支援 NAPI 的 driver 才會去開啟
  3. 收到資料後 NIC 通過 DMA 將資料存到記憶體
  4. NIC 觸發一個 IRQ,並觸發 CPU 開始執行 driver 註冊的 Interrupt Handler
  5. driver 的 Interrupt Handler 通過 napi_schedule 函式觸發 softirq (NET_RX_SOFTIRQ) 來喚醒 NAPI subsystem,NET_RX_SOFTIRQ 的 handler 是 net_rx_action 會在另一個執行緒中被執行,在其中會呼叫 driver 註冊的 poll 函式獲取收到的 Packet
  6. driver 會禁用當前 NIC 的 IRQ,從而能在 poll 完所有資料之前不會再有新的 IRQ
  7. 當所有事情做完之後,NAPI subsystem 會被禁用,並且會重新啟用 NIC 的 IRQ
  8. 回到第三步

從上面的描述可以看出來還缺一些東西,Ring Buffer 上的資料被 poll 走之後是怎麼交付上層網路棧繼續處理的呢?以及被消耗掉的 sk_buff 是怎麼被重新分配重新放入 Ring Buffer 的呢?

這兩個工作都在 poll 中完成,上面說過 poll 是個 driver 實現的函式,所以每個 driver 實現可能都不相同。但 poll 的工作基本是一致的就是:

  1. 從 Ring Buffer 中將收到的 sk_buff 讀取出來
  2. 對 sk_buff 做一些基本檢查,可能會涉及到將幾個 sk_buff 合併因為可能同一個 Frame 被分散放在多個 sk_buff 中
  3. 將 sk_buff 交付上層網路棧處理
  4. 清理 sk_buff,清理 Ring Buffer 上的 Descriptor 將其指向新分配的 sk_buff 並將狀態設定為 ready
  5. 更新一些統計資料,比如收到了多少 packet,一共多少位元組等

如果拿 intel igb 這個網絡卡的實現來看,其 poll 函式在這裡:linux/drivers/net/ethernet/intel/igb/igb_main.c - Elixir - Free Electrons

首先是看到有 tx.ring 和 rx.ring,說明收發訊息都會走到這裡。發訊息先不管,先看收訊息,收訊息走的是 igb_clean_rx_irq。收完訊息後執行 napi_complete_done 退出 polling 模式,並開啟 NIC 的 IRQ。從而我們知道大部分工作是在 igb_clean_rx_irq 中完成的,其實現大致上還是比較清晰的,就是上面描述的幾步。裡面有個 while 迴圈通過 buget 控制,從而在 Packet 特別多的時候不要讓 CPU 在這裡無窮迴圈下去,要讓別的事情也能夠被執行。迴圈內做的事情如下:

  1. 先批量清理已經讀出來的 sk_buff 並分配新的 buffer 從而避免每次讀一個 sk_buff 就清理一個,很低效
  2. 找到 Ring Buffer 上下一個需要被讀取的 Descriptor ,並檢查描述符狀態是否正常
  3. 根據 Descriptor 找到 sk_buff 讀出來
  4. 檢查是否是 End of packet,是的話說明 sk_buff 內有 Frame 的全部內容,不是的話說明 Frame 資料比 sk_buff 大,需要再讀一個 sk_buff,將兩個 sk_buff 資料合併起來
  5. 通過 Frame 的 Header 檢查 Frame 資料完整性,是否正確之類的
  6. 記錄 sk_buff 的長度,讀了多少資料
  7. 設定 Hash、checksum、timestamp、VLAN id 等資訊,這些資訊是硬體提供的。
  8. 通過 napi_gro_receive 將 sk_buff 交付上層網路棧
  9. 更新一堆統計資料
  10. 回到 1,如果沒資料或者 budget 不夠就退出迴圈

看到 budget 會影響到 CPU 執行 poll 的時間,budget 越大當資料包特別多的時候可以提高 CPU 利用率並減少資料包的延遲。但是 CPU 時間都花在這裡會影響別的任務的執行。

budget 預設 300,可以調整
sysctl -w net.core.netdev_budget=600

napi_gro_receive會涉及到 GRO 機制,稍後再說,大致上就是會對多個數據包做聚合,napi_gro_receive 最終是將處理好的 sk_buff 通過呼叫 netif_receive_skb,將資料包送至上層網路棧。執行完 GRO 之後,基本可以認為資料包正式離開 Ring Buffer,進入下一個階段了。在記錄下一階段的處理之前,補充一下收訊息階段 Ring Buffer 相關的更多細節。

Generic Receive Offloading(GRO)

GRO 是 Large receive offload 的一個實現。網路上大部分 MTU 都是 1500 位元組,開啟 Jumbo Frame 後能到 9000 位元組,如果傳送的資料超過 MTU 就需要切割成多個數據包。LRO 就是在收到多個數據包的時候將同一個 Flow 的多個數據包按照一定的規則合併起來交給上層處理,這樣就能減少上層需要處理的資料包數量。

很多 LRO 機制是在 NIC 上實現的,沒有實現 LRO 的 NIC 就少了上述合併資料包的能力。而 GRO 是 LRO 在軟體上的實現,從而能讓所有 NIC 都支援這個功能。

napi_gro_receive 就是在收到資料包的時候合併多個數據包用的,如果收到的資料包需要被合併,napi_gro_receive 會很快返回。當合並完成後會呼叫 napi_skb_finish ,將因為資料包合併而不再用到的資料結構釋放。最終會呼叫到 netif_receive_skb 將資料包交到上層網路棧繼續處理。netif_receive_skb 上面說過,就是資料包從 Ring Buffer 出來後到上層網路棧的入口。

可以通過 ethtool 檢視和設定 GRO:

檢視 GRO
ethtool -k eth0 | grep generic-receive-offload
generic-receive-offload: on
設定開啟 GRO
ethtool -K eth0 gro on

多 CPU 下的 Ring Buffer 處理 (Receive Side Scaling)

NIC 收到資料的時候產生的 IRQ 只可能被一個 CPU 處理,從而只有一個 CPU 會執行 napi_schedule 來觸發 softirq,觸發的這個 softirq 的 handler 也還是會在這個產生 softIRQ 的 CPU 上執行。所以 driver 的 poll 函式也是在最開始處理 NIC 發出 IRQ 的那個 CPU 上執行。於是一個 Ring Buffer 上同一個時刻只有一個 CPU 在拉取資料。

從上面描述能看出來分配給 Ring Buffer 的空間是有限的,當收到的資料包速率大於單個 CPU 處理速度的時候 Ring Buffer 可能被佔滿,佔滿之後再來的新資料包會被自動丟棄。而現在機器都是有多個 CPU,同時只有一個 CPU 去處理 Ring Buffer 資料會很低效,這個時候就產生了叫做 Receive Side Scaling(RSS) 或者叫做 multiqueue 的機制來處理這個問題。WIKI 對 RSS 的介紹挺好的,簡潔幹練可以看看: Network interface controller - Wikipedia

簡單說就是現在支援 RSS 的網絡卡內部會有多個 Ring Buffer,NIC 收到 Frame 的時候能通過 Hash Function 來決定 Frame 該放在哪個 Ring Buffer 上,觸發的 IRQ 也可以通過作業系統或者手動配置 IRQ affinity 將 IRQ 分配到多個 CPU 上。這樣 IRQ 能被不同的 CPU 處理,從而做到 Ring Buffer 上的資料也能被不同的 CPU 處理,從而提高資料的並行處理能力。

RSS 除了會影響到 NIC 將 IRQ 發到哪個 CPU 之外,不會影響別的邏輯了。收訊息過程跟之前描述的是一樣的。

如果支援 RSS 的話,NIC 會為每個佇列分配一個 IRQ,通過 /proc/interrupts 能進行檢視。你可以通過配置 IRQ affinity 指定 IRQ 由哪個 CPU 來處理中斷。先通過 /proc/interrupts 找到 IRQ 號之後,將希望繫結的 CPU 號寫入 /proc/irq/IRQ_NUMBER/smp_affinity,寫入的是 16 進位制的 bit mask。比如看到佇列 rx_0 對應的中斷號是 41 那就執行:

echo 6 > /proc/irq/41/smp_affinity
6 表示的是 CPU2 和 CPU1

0 號 CPU 的掩碼是 0x1 (0001),1 號 CPU 掩碼是 0x2 (0010),2 號 CPU 掩碼是 0x4 (0100),3 號 CPU 掩碼是 0x8 (1000) 依此類推。

另外需要注意的是設定 smp_affinity 的話不能開啟 irqbalance 或者需要為 irqbalance 設定 –banirq 列表,將設定了 smp_affinity 的 IRQ 排除。不然 irqbalance 機制運作時會忽略你設定的 IRQ affinity 配置。

Receive Packet Steering(RPS) 是在 NIC 不支援 RSS 時候在軟體中實現 RSS 類似功能的機制。其好處就是對 NIC 沒有要求,任何 NIC 都能支援 RPS,但缺點是 NIC 收到資料後 DMA 將資料存入的還是一個 Ring Buffer,NIC 觸發 IRQ 還是發到一個 CPU,還是由這一個 CPU 呼叫 driver 的 poll 來將 Ring Buffer 的資料取出來。RPS 是在單個 CPU 將資料從 Ring Buffer 取出來之後才開始起作用,它會為每個 Packet 計算 Hash 之後將 Packet 發到對應 CPU 的 backlog 中,並通過 Inter-processor Interrupt(IPI) 告知目標 CPU 來處理 backlog。後續 Packet 的處理流程就由這個目標 CPU 來完成。從而實現將負載分到多個 CPU 的目的。

RPS 預設是關閉的,當機器有多個 CPU 並且通過 softirqs 的統計 /proc/softirqs 發現 NET_RX 在 CPU 上分佈不均勻或者發現網絡卡不支援 mutiqueue 時,就可以考慮開啟 RPS。開啟 RPS 需要調整 /sys/class/net/DEVICE_NAME/queues/QUEUE/rps_cpus 的值。比如執行:

echo f > /sys/class/net/eth0/queues/rx-0/rps_cpus

表示的含義是處理網絡卡 eth0 的 rx-0 佇列的 CPU 數設定為 f 。即設定有 15 個 CPU 來處理 rx-0 這個佇列的資料,如果你的 CPU 數沒有這麼多就會預設使用所有 CPU 。甚至有人為了方便都是直接將 echo fff > /sys/class/net/eth0/queues/rx-0/rps_cpus 寫到腳本里,這樣基本能覆蓋所有型別的機器,不管機器 CPU 數有多少,都能覆蓋到。從而就能讓這個指令碼在任意機器都能執行。

注意:如果 NIC 不支援 mutiqueue,RPS 不是完全不用思考就能開啟的,因為其開啟之後會加重所有 CPU 的負擔,在一些場景下比如 CPU 密集型應用上並不一定能帶來好處。所以得測試一下。

Receive Flow Steering(RFS) 一般和 RPS 配合一起工作。RPS 是將收到的 packet 發配到不同的 CPU 以實現負載均衡,但是可能同一個 Flow 的資料包正在被 CPU1 處理,但下一個資料包被髮到 CPU2,會降低 CPU cache hit 比率並且會讓資料包要從 CPU1 發到 CPU2 上。RFS 就是保證同一個 flow 的 packet 都會被路由到正在處理當前 Flow 資料的 CPU,從而提高 CPU cache 比率。這篇文章 把 RFS 機制介紹的挺好的。基本上就是收到資料後根據資料的一些資訊做個 Hash 在這個 table 的 entry 中找到當前正在處理這個 flow 的 CPU 資訊,從而將資料發給這個正在處理該 Flow 資料的 CPU 上,從而做到提高 CPU cache hit 率,避免資料在不同 CPU 之間拷貝。當然還有很多細節,請看上面連結。

RFS 預設是關閉的,必須主動配置才能生效。正常來說開啟了 RPS 都要再開啟 RFS,以獲取更好的效能。這篇文章也有說該怎麼去開啟 RFS 以及推薦的配置值。一個是要配置 rps_sock_flow_entries

sysctl -w net.core.rps_sock_flow_entries=32768

這個值依賴於系統期望的活躍連線數,注意是同一時間活躍的連線數,這個連線數正常來說會大大小於系統能承載的最大連線數,因為大部分連線不會同時活躍。該值建議是 32768,能覆蓋大多數情況,每個活躍連線會分配一個 entry。除了這個之外還要配置 rps_flow_cnt,這個值是每個佇列負責的 flow 最大數量,如果只有一個佇列,則 rps_flow_cnt 一般是跟 rps_sock_flow_entries 的值一致,但是有多個佇列的時候 rps_flow_cnt 值就是 rps_sock_flow_entries / N, N 是佇列數量。

echo 2048 > /sys/class/net/eth0/queues/rx-0/rps_flow_cnt

Accelerated Receive Flow Steering (aRFS) 類似 RFS 只是由硬體協助完成這個工作。aRFS 對於 RFS 就和 RSS 對於 RPS 一樣,就是把 CPU 的工作挪到了硬體來做,從而不用浪費 CPU 時間,直接由 NIC 完成 Hash 值計算並將資料發到目標 CPU,所以快一點。NIC 必須暴露出來一個 ndo_rx_flow_steer 的函式用來實現 aRFS。

adaptive RX/TX IRQ coalescing

有的 NIC 支援這個功能,用來動態的將 IRQ 進行合併,以做到在資料包少的時候減少資料包的延遲,在資料包多的時候提高吞吐量。檢視方法:

ethtool -c eth1
Coalesce parameters for eth1:
Adaptive RX: off  TX: off
stats-block-usecs: 0
.....

開啟 RX 佇列的 adaptive coalescing 執行:

ethtool -C eth0 adaptive-rx on

並且有四個值需要設定:rx-usecs、rx-frames、rx-usecs-irq、rx-frames-irq,具體含義等需要用到的時候查吧。

Ring Buffer 相關監控及配置

收到資料包統計

ethtool -S eh0
NIC statistics:
     rx_packets: 792819304215
     tx_packets: 778772164692
     rx_bytes: 172322607593396
     tx_bytes: 201132602650411
     rx_broadcast: 15118616
     tx_broadcast: 2755615
     rx_multicast: 0
     tx_multicast: 10

RX 就是收到資料,TX 是發出資料。還會展示 NIC 每個佇列收發訊息情況。其中比較關鍵的是帶有 drop 字樣的統計和 fifo_errors 的統計 :

tx_dropped: 0
rx_queue_0_drops: 93
rx_queue_1_drops: 874
....
rx_fifo_errors: 2142
tx_fifo_errors: 0

看到傳送佇列和接收佇列 drop 的資料包數量顯示在這裡。並且所有 queue_drops 加起來等於 rx_fifo_errors。所以總體上能通過 rx_fifo_errors 看到 Ring Buffer 上是否有丟包。如果有的話一方面是看是否需要調整一下每個佇列資料的分配,或者是否要加大 Ring Buffer 的大小。

/proc/net/dev是另一個數據包相關統計,不過這個統計比較難看:

cat /proc/net/dev
Inter-|   Receive                                                |  Transmit
 face |bytes    packets errs drop fifo frame compressed multicast|bytes    packets errs drop fifo colls carrier compressed
    lo: 14472296365706 10519818839    0    0    0     0          0         0 14472296365706 10519818839    0    0    0     0       0          0
  eth1: 164650683906345 785024598362    0    0 2142     0          0         0 183711288087530 704887351967    0    0    0     0       0          0

調整 Ring Buffer 佇列數量

ethtool -l eth0
Channel parameters for eth0:
Pre-set maximums:
RX:             0
TX:             0
Other:          1
Combined:       8
Current hardware settings:
RX:             0
TX:             0
Other:          1
Combined:       8

看的是 Combined 這一欄是佇列數量。Combined 按說明寫的是多功能佇列,猜想是能用作 RX 佇列也能當做 TX 佇列,但數量一共是 8 個?

如果不支援 mutiqueue 的話上面執行下來會是:

Channel parameters for eth0:
Cannot get device channel parameters
: Operation not supported

看到上面 Ring Buffer 數量有 maximums 和 current settings,所以能自己設定 Ring Buffer 數量,但最大不能超過 maximus 值:

sudo ethtool -L eth0 combined 8

如果支援對特定型別 RX 或 TX 設定佇列數量的話可以執行:

sudo ethtool -L eth0 rx 8

需要注意的是,ethtool 的設定操作可能都要重啟一下才能生效。

調整 Ring Buffer 佇列大小

先檢視當前 Ring Buffer 大小:

ethtool -g eth0
Ring parameters for eth0:
Pre-set maximums:
RX:   4096
RX Mini:  0
RX Jumbo: 0
TX:   4096
Current hardware settings:
RX:   512
RX Mini:  0
RX Jumbo: 0
TX:   512

看到 RX 和 TX 最大是 4096,當前值為 512。佇列越大丟包的可能越小,但資料延遲會增加

設定 RX 佇列大小:

ethtool -G eth0 rx 4096

調整 Ring Buffer 佇列的權重

NIC 如果支援 mutiqueue 的話 NIC 會根據一個 Hash 函式對收到的資料包進行分發。能調整不同佇列的權重,用於分配資料。

ethtool -x eth0
RX flow hash indirection table for eth0 with 8 RX ring(s):
    0:      0     0     0     0     0     0     0     0
    8:      0     0     0     0     0     0     0     0
   16:      1     1     1     1     1     1     1     1
   ......
   64:      4     4     4     4     4     4     4     4
   72:      4     4     4     4     4     4     4     4
   80:      5     5     5     5     5     5     5     5
   ......
  120:      7     7     7     7     7     7     7     7

我的 NIC 一共有 8 個佇列,一個有 128 個不同的 Hash 值,上面就是列出了每個 Hash 值對應的佇列是什麼。最左側 0 8 16 是為了能讓你快速的找到某個具體的 Hash 值。比如 Hash 值是 76 的話我們能立即找到 72 那一行:”72: 4 4 4 4 4 4 4 4”,從左到右第一個是 72 數第 5 個就是 76 這個 Hash 值對應的佇列是 4 。

ethtool -X eth0 weight 6 2 8 5 10 7 1 5

設定 8 個佇列的權重。加起來不能超過 128 。128 是 indirection table 大小,每個 NIC 可能不一樣。

更改 Ring Buffer Hash Field

分配資料包的時候是按照資料包內的某個欄位來進行的,這個欄位能進行調整。

ethtool -n eth0 rx-flow-hash tcp4
TCP over IPV4 flows use these fields for computing Hash flow key:
IP SA
IP DA
L4 bytes 0 & 1 [TCP/UDP src port]
L4 bytes 2 & 3 [TCP/UDP dst port]

檢視 tcp4 的 Hash 欄位。

也可以設定 Hash 欄位:

ethtool -N eth0 rx-flow-hash udp4 sdfn

sdfn 需要檢視 ethtool 看其含義,還有很多別的配置值。

softirq 數統計

通過 /proc/softirqs 能看到每個 CPU 上 softirq 數量統計:

cat /proc/softirqs
                    CPU0       CPU1       
          HI:          1          0
       TIMER: 1650579324 3521734270
      NET_TX:   10282064   10655064
      NET_RX: 3618725935       2446
       BLOCK:          0          0
BLOCK_IOPOLL:          0          0
     TASKLET:      47013      41496
       SCHED: 1706483540 1003457088
     HRTIMER:    1698047   11604871
         RCU: 4218377992 3049934909

看到 NET_RX 就是收訊息時候觸發的 softirq,一般看這個統計是為了看看 softirq 在每個 CPU 上分佈是否均勻,不均勻的話可能就需要做一些調整。比如上面看到 CPU0 和 CPU1 兩個差距很大,原因是這個機器的 NIC 不支援 RSS,沒有多個 Ring Buffer。開啟 RPS 後就均勻多了。

IRQ 統計

/proc/interrupts 能看到每個 CPU 的 IRQ 統計。一般就是看看 NIC 有沒有支援 multiqueue 以及 NAPI 的 IRQ 合併機制是否生效。看看 IRQ 是不