效能優化:如何更快地接收資料

又拍雲發表於2021-09-14

從網路卡到應用程式,資料包會經過一系列元件,其中驅動做了什麼?核心做了什麼?為了優化,我們又能做些什麼?整個過程中涉及到諸多細微可調的軟硬體引數,並且相互影響,不存在一勞永逸的“銀彈”。本文中又拍雲系統開發高階工程師楊鵬將結合自己的的實踐經驗,介紹在深入理解底層機制的基礎上如何做出“場景化”的最優配置。

文章根據楊鵬在又拍雲 Open Talk 技術沙龍北京站主題演講《效能優化:更快地接收資料》整理而成,現場視訊及 PPT 可點選閱讀原文檢視。

大家好,我是又拍雲開發工程師楊鵬,在又拍雲工作已有四年時間,期間一直從事 CDN 底層系統開發的工作,負責排程、快取、負載均衡等 CDN 的核心元件,很高興來跟大家分享在網路資料處理方面的經驗和感受。今天分享的主題是《如何更快地接收資料》,主要介紹加速網路資料處理的方法和實踐。希望能幫助大家更好的瞭解如何在系統的層面,儘量在應用程式無感的情況下做到極致的優化。言歸正傳,進入主題。

首先需要清楚在嘗試做任何優化的時候,想到的第一件事情應該是什麼?個人覺得是衡量指標。做任何改動或優化之前,都要明確地知道,是怎樣的指標反映出了當前的問題。那麼在做了相應的調整或改動之後,也才能通過指標去驗證實際效果與作用。

針對要分享的主題,有一個圍繞上面指標核心的基本原則。在網路層面做優化,歸根結底只需要看一點,假如可以做到網路棧的每個層次,加入能監控到對應層次的丟包率,這樣核心的指標,就可以明確地知道問題出在哪一層。有了明確可監控的指標,之後做相應的調整與實際效果的驗證也就很簡單了。當然上述兩點相對有點虛,接下來就是比較乾的部分了。

如上圖所示,當收到一個資料包,從進入網路卡,一直到達應用層,總的資料流程有很多。在當前階段,無需關注每個流程,留意其中幾個核心的關鍵路徑即可:

  • 第一個,資料包到達網路卡;

  • 第二個,網路卡在收到資料包時,它需要產生一箇中斷,告訴 CPU 資料已經到了;

  • 第三步,核心從這個時候開始進行接管,把資料從網路卡中拿出來,交到後面核心的協議棧去處理。

以上是三個關鍵的路徑。上圖中右邊的手繪圖指的就是這三個步驟,並有意區分了兩個顏色。之所以這麼區分是因為接下來會按這兩部分進行分享,一是上層驅動部分,二是下層涉及到核心的部分。 當然核心比較多,通篇只涉及到核心網路子系統,更具體來說是核心跟驅動互動部分的內容。

網路卡驅動

網路卡驅動的部分,網路卡是硬體,驅動(driver)是軟體,包括了網路卡驅動部分的大部分。這部分可簡單分四個點,依次是初始化、啟動、監控與調優驅動它的初始化流程。

網路卡驅動-初始化

驅動初始化的過程和硬體相關,無需過分關注。但需注意一點就是註冊 ethool 的一系列操作,這個工具可以對網路卡做各種各樣的操作,不止可以讀取網路卡的配置,還可以更改網路卡的配置引數,是一個非常強大的工具。

那它是如何控制網路卡的呢?每個網路卡的驅動在初始化時,通過介面,去註冊支援 ethool 工具的一系列操作。ethool 是一套很通用的介面,比如說它支援 100 個功能,但每個型號的網路卡,只能支援一個子集。所以具體支援哪些功能,會在這一步進行宣告。

上圖擷取的部分,是在初始化時結構體的賦值。前面兩個可以簡單看一下,驅動在初始化的時候會告訴核心,如果想要操作這塊網路卡對應的回撥函式,其中最主要的是啟動和關閉,有用 ifconfig 工具操作網路卡的應該都很熟悉,當用 ifconfig up/down 一張網路卡的時候,呼叫的都是它初始化時指定的這幾個函式。

網路卡驅動-啟動

驅動初始化過程之後就是啟動(open)中的流程了,一共分為四步:分配 rx/tx 佇列記憶體、

開啟 NAPI、註冊中斷處理函式、開啟中斷。其中註冊中斷處理函式和開啟中斷是理所當然的,任何一個硬體接入到機器上都需要做這個操作。當後面收到一些事件時,它需要通過中斷去通知系統,然後開啟中斷。

第二步的 NAPI 後面會詳細說明,這裡先重點關注啟動過程中對記憶體的分配。網路卡在收到資料時,都必須把資料從鏈路層拷貝到機器的記憶體裡,而這塊記憶體就是網路卡在啟動時,通過介面向核心、向作業系統申請而來的。記憶體一旦申請下來,地址確定之後,後續網路卡在收到資料的時候,就可以直接通過 DMA 的機制,直接把資料包傳送到記憶體固定的地址中去,甚至不需要 CPU 的參與。

到佇列記憶體的分配可以看下上圖,很早之前的網路卡都是單佇列的機制,但現代的網路卡大多都是多佇列的。好處就是機器網路卡的資料接收可以被負載均衡到多個 CPU 上,因此會提供多個佇列,這裡先有個概念後面會詳細說明。

下面來詳細介紹啟動過程中的第二步 NAPI,這是現代網路資料包處理框架中非常重要的一個擴充套件。之所以現在能支援 10G、20G、25G 等非常高速的網路卡,NAPI 機制起到了非常大的作用。當然 NAPI 並不複雜,其核心就兩點:中斷、輪循。一般來說,網路卡在接收資料時肯定是收一個包,產生一箇中斷,然後在中斷處理函式的時候將包處理掉。處在收包、處理中斷,下一個收包,再處理中斷,這樣的迴圈中。而 NAPI 機制優勢在於只需要一次中斷,收到之後就可以通過輪循的方式,把佇列記憶體中所有的資料都拿走,達到非常高效的狀態。

網路卡驅動-監控

接下來就是在驅動這層可以做的監控了,需要去關注其中一些資料的來源。


$ sudo ethtool -S eth0
NIC statistics:
     rx_packets: 597028087
     tx_packets: 5924278060
     rx_bytes: 112643393747
     tx_bytes: 990080156714
     rx_broadcast: 96
     tx_broadcast: 116
     rx_multicast:20294528
     .... 

首先非常重要的是 ethool 工具,它可以拿到網路卡中統計的資料、接收的包數量、處理的流量等等常規的資訊,而我們更多的是需要關注到異常資訊。


$ cat /sys/class/net/eth0/statistics/rx_dropped
2

通過 sysfs 的介面,可以看到網路卡的丟包數,這就是系統出現異常的一個標誌。

三個途徑拿到的資訊與前面差不多,只是格式有些亂,僅做了解即可。

上圖是要分享的一個線上案例。當時業務上出現異常,經過排查最後是懷疑到網路卡這層,為此需要做進一步的分析。通過 ifconfig 工具可以很直觀的檢視到網路卡的一些統計資料,圖中可以看到網路卡的 errors 資料指標非常高,明顯出現了問題。但更有意思的一點是, errors 右邊最後的 frame 指標數值跟它完全相同。因為 errors 指標是網路卡中很多錯誤累加之後的指標,與它相鄰的 dropped、overruns 這倆個指標都是零,也就是說在當時的狀態下,網路卡的錯誤大部分來自 frame。

當然這只是瞬時的狀態,上圖中下面部分是監控資料,可以明顯看到波動的變化,確實是某一臺機器異常了。frame 錯誤一般是在網路卡收到資料包,進行 RCR 校驗時失敗導致的。當收到資料包,會對該包中的內容做校驗,當發現跟已經存下來的校驗不匹配,說明包是損壞的,因此會直接將其丟掉。

這個原因是比較好分析的,兩點一線,機器的網路卡通過網線接到上聯交換機。當這裡出現問題,不是網線就是機器本身的網路卡問題,或者是對端交換機的埠,也就是上聯交換機埠出現問題。當然按第一優先順序去分析,協調運維去更換了機器對應的網線,後面的指標情況也反映出了效果,指標直接突降直到完全消失,錯誤也就不復存在了,對應上層的業務也很快恢復了正常。

網路卡驅動-調優

說完監控之後來看下最後的調優。在這個層面能調整的東西不多,主要是針對網路卡多佇列的調整,比較直觀。調整佇列數目、大小,各佇列間的權重,甚至是調整雜湊的欄位,都是可以的。

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

上圖是針對多佇列的調整。為了說明剛才的概念,舉個例子,比如有個 web server 繫結到了 CPU2,而機器有多個 CPU,這個機器的網路卡也是多佇列的,其中某個佇列會被 CPU2 處理。這個時候就會有一個問題,因為網路卡有多個佇列,所以 80 埠的流量只會被分配到其中一個佇列上去。假如這個佇列不是由 CPU2 處理的,就會涉及到一些資料的騰挪。底層把資料接收上來後再交給應用層的時候,需要把這個資料移動一下。如果本來在 CPU1 處理的,需要挪到 CPU2 去,這時會涉及到 CPU cache 的失效,這對高速運轉的 CPU 來說是代價很高的操作。

那麼該怎麼做呢?我們可以通過前面提到的工具,特意把 80 埠 tcp 資料流量導向到對應 CPU2 處理的網路卡佇列。這麼做的效果是資料包從到達網路卡開始,到核心處理完再到送達應用層,都是同一個 CPU。這樣最大的好處就是快取,CPU 的 cache 始終是熱的,如此整體下來,它的延遲、效果也會非常好。當然這個例子並不實際,主要是為了說明能做到的一個效果。

核心網路子系統

說完了整個網路卡驅動部分,接下來是講解核心子系統部分,這塊會分為軟中斷與網路子系統初始化兩部分來分享。

軟中斷

上圖的 NETDEV 是 linux 網路子系統每年都會開的一個分會,其中比較有意思的點是每年大會舉辦的屆數會以一個特殊字元來表示。圖中是辦到了 0X15 屆,想必也都發現這是 16 進位制的數字,0X15 剛好就是 21 年,也是比較極客範。對網路子系統感興趣的可以去關注一下。

言歸正傳,核心延時任務有多種機制,而軟中斷只是其中一種。上圖是 linux 的基本結構,上層是使用者態,中間是核心,下層是硬體,很抽象的一個分層。使用者態和核心態之間會有兩種互動的方式:通過系統呼叫,或者通過異常可以陷入到核心態裡面。那底層的硬體跟核心又是怎麼互動的呢?答案是中斷,硬體跟核心互動的時候必須通過中斷,處理任何事件都需要產生一箇中斷訊號來告知 CPU 與核心。

不過這樣的機制一般情況下也許沒有問題,但是對網路資料來說,一個資料包一箇中斷,這樣會有很明顯的兩個問題。

問題一:中斷在處理期間,會遮蔽之前的中斷訊號。當一箇中斷處理的時間很長,在處理期間收到的中斷訊號都會丟掉。 如果處理一個包用了十秒,在這十秒期間又收到了五個資料包,但因為中斷訊號丟了,即便前面的處理完了,後面的資料包也不會再處理了。對應到 tcp 這邊,假如客戶端給服務端發了一個資料包,幾秒後處理完了,但在處理期間客戶端又發了後續的三個包,但是服務端後面並不知道,以為只收到了一個包,這時客戶端又在等待服務端的回包,如此會導致兩邊都卡住了,也說明了訊號丟失是一個極其嚴重的問題。

問題二:一個資料包觸發一次中斷處理的話,當有大量的資料包到來後,就會產生非常大量的中斷。 如果達到了 10 萬、50 萬、甚至百萬的 pps,那 CPU 就需要處理大量的網路中斷,也就不用幹其他事情了。

而針對以上兩點問題的解決方法就是讓中斷處理儘可能的短。 具體來說,不能在中斷處理函式,只能把它揪出來,交到軟中斷機制裡。這樣之後的實際結果是硬體的中斷處理做的事情就很少了,將接收資料等一些必須的事情交到軟中斷去完成,這也是軟中斷存在的意義。

static struct smp_hotplug_thread softirq_threads = {
  .store              = &ksoftirqd,
  .thread_should_run  = ksoftirqd_should_run,
  .thread_fn          = run_ksoftirqd,
  .thread-comm        = “ksoftirqd/%u”,
};

static _init int spawn_ksoftirqd(void)
{
  regiter_cpu_notifier(&cpu_nfb);
  
  BUG_ON(smpboot_register_percpu_thread(&softirq_threads));

  return 0;
}
early_initcall(spawn_ksoftirqd);

軟中斷機制是通過核心的執行緒來實現的。圖中是對應的一個核心執行緒。伺服器 CPU 都會有一個 ksoftirqd 這樣的核心執行緒,多 CPU 的機器會相對應的有多個執行緒。圖中結構體最後一個成員 ksoftirqd/,如果有三個 CPU 對應就會有 /0/1/2 三個核心執行緒。

軟中斷機制的資訊在 softirqs 下面可以看到。軟中斷並不多隻有幾種,其中需要關注的,跟網路相關的就是 NET-TX 和 NET-RX,網路資料收發的兩種場景。

核心初始化

鋪墊完軟中斷之後,下面來看核心初始化的流程。主要為兩步:

  • 針對每個 CPU,建立一個資料結構,這上面掛了非常多的成員,與後面的處理密切相關;

  • 註冊一個軟中斷處理函式,對應上面看到的 NET-TX 和 NET-RX 這兩個軟中斷的處理函式。

上圖是手繪的一個資料包的處理流程:

  • 第一步網路卡收到了資料包;

  • 第二步把資料包通過 DMA 拷到了記憶體裡面;

  • 第三步產生了一箇中斷告訴 CPU 並開始處理中斷。重點的中斷處理可分為兩步:一是將中斷訊號遮蔽了,二是喚醒 NAPI 機制。


static irqreturn_t igb_msix_ring(int irq, void *data)
{
  struct igb_q_vector *q_vector = data;
  
  /* Write the ITR value calculated from the previous interrupt. */
  igb_write_itr(q_vector);
  
  napi_schedule(&q_vector->napi);
  
  return IRO_HANDLED;
}

上面的程式碼是 igb 網路卡驅動中斷處理函式做的事情。如果省略掉開始的變數宣告和後面的返回,這個中斷處理函式只有兩行程式碼,非常短。需要關注的是第二個,在硬體中斷處理函式中,只用啟用外部 NIPA 軟中斷處理機制,無需做其他任何事情。因此這個中斷處理函式會返回的非常快。

NIPI 啟用


/* Called with irq disabled */
static inline void ____napi_schedule(struct softnet_data *sd, struct napi_struct *napi)
{
  list_add_tail(&napi->poll_list, &sd->poll_list);
  _raise_softirq_irqoff(NET_RX_SOFTIRQ);
}

NIPI 的啟用也很簡單,主要為兩步。核心網路系統在初始化的時每個 CPU 都會有一個結構體,它會把佇列對應的資訊插入到結構體的連結串列裡。換句話說,每個網路卡佇列在收到資料的時候,需要把自己的佇列資訊告訴對應的 CPU,將這兩個資訊繫結起來,保證某個 CPU 處理某個佇列。

除此之外,還要與觸發硬中斷一樣,需要觸發軟中斷。下圖將很多步驟放到了一塊,前面講過的就不再贅述了。圖中要關注的是軟中斷是怎麼觸發的。與硬中斷差不多,軟中斷也有中斷的向量表。每個中斷號,都會對應一個處理函式,當需要處理某個中斷,只需要在對應的中斷向量表裡找就好了,跟硬中斷的處理是一模一樣的。

資料接收-監控

說完了運作機制,再來看看有哪些地方可以做監控。在 proc 下面有很多東西,可以看到中斷的處理情況。第一列就是中斷號,每個裝置都有獨立的中斷號,這是寫死的。對網路來說只需要關注網路卡對應的中斷號,圖中是 65、66、67、68 等。當然看實際的數字並沒有意義,而是需要看它的分佈情況,中斷是不是被不同 CPU 在處理,如果所有的中斷都是被一個 CPU 處理,那麼就需要做些調整,把它分散開。

資料接收-調優

中斷可以做的調整有兩個:一是中斷合併,二是中斷親和性。

自適應中斷合併

  • rx-usecs: 資料幀到達後,延遲多長時間產生中斷訊號,單位微秒

  • rx-frames: 觸發中斷前積累資料幀的最大個數

  • rx-usecs-irq: 如果有中斷處理正在執行,當前中斷延遲多久送達 CPU

  • rx-frames-irq: 如果有中斷處理正在執行,最多積累多少個資料幀

上面列的都是硬體網路卡支援的功能。NAPI 本質上也是中斷合併的機制,假如有很多包的到來,NAPI 就可以做到只產生一箇中斷,因此不需要硬體來幫助做中斷合併,實際效果是跟 NAPI 是相同的,都是減少了總的中斷數量。

中斷親和性

$ sudo bash -c ‘echo 1 > /proc/irq/8/smp_affinity’

這個與網路卡多佇列是密切相關的。如果網路卡有多個佇列,就能手動來明確指定由哪個 CPU 來處理,均衡的把資料處理的負載分散到機器的可用 CPU 上。配置也比較簡單,只需把數字寫入到 /proc 對應的這個檔案中就可以了。這是個位陣列,轉成二進位制後就會有對應的 CPU 去處理。如果寫個 1,可能就是 CPU0 來處理;如果寫個 4,轉化成二進位制是 100,那麼就會交給 CPU2 去處理。

另外有個小問題需要注意,很多發行版可能會自帶一個 irqbalance 的守護程式(http://irqbalance.github.io/irqbalance),會將手動中斷均衡的設定給覆蓋掉。這個程式做的核心事情就是把上面手動設定檔案的操作放到程式裡,有興趣可以去看下它的程式碼(https://github.com/Irqbalance/irqbalance/blob/master/activate.c),也是把這個檔案開啟,寫對應的數字進去就可以了。

核心-資料處理

最後是資料處理部分了。當資料到達網路卡,進入佇列記憶體後,就需要核心從佇列記憶體中將資料拉出來。如果機器的 PPS 達到了十萬甚至百萬,而 CPU 只處理網路資料的話,那其他基本的業務邏輯也就不用幹了,因此不能讓資料包的處理獨佔整個 CPU,而核心點是怎麼去做限制。

針對上述問題主要有兩方面的限制:整體的限制和單次的限制

while (!list_empty(&sd->poll_list)){
  struct napi_struct *n;
  int work,weight;
  
  /* If softirq window is exhausted then punt.
   * Allow this to run for 2 jiffies since which will allow
   * an average latency of 1.5/HZ.
   */
   if (unlikely(budget <= 0 || time_after_eq(jiffies, time_limit)))
   goto softnet_break;

整體限制很好理解,就是一個 CPU 對應一個佇列。如果 CPU 的數量比佇列數量少,那麼一個 CPU 可能需要處理多個佇列。

weight = n->weight;

work = 0;
if (test_bit(NAPI_STATE_SCHED, &n->state)) {
        work = n->poll(n,weight);
        trace_napi_poll(n);
}

WARN_ON_ONCE(work > weight);

budget -= work;

單次限制則是限制一個佇列在一輪裡處理包的數量。達到限制之後就停下來,等待下一輪的處理。

softnet_break:
  sd->time_squeeze++;
  _raise_softirq_irqoff(NET_RX_SOFTIRQ);
  goto out;

而停下來就是很關鍵的節點,幸運的是有對應的指標記錄,有 time-squeeze 這樣中斷的計數,拿到這個資訊就可以判斷出機器的網路處理是否有瓶頸,被迫中斷的頻率高低。

上圖是監控 CPU 指標的資料,格式很簡單,每行對應一個 CPU,數值之間用空格分割,輸出格式為 16 進位制。那麼每一列數值又代表什麼呢?很不幸,這個沒有文件,只能通過檢查使用的核心版本,然後去看對應的程式碼。

seq_printf(seq,
     "%08x %08x %08x %08x %08x %08x %08x %08x %08x %08x %08x\n",
     sd->processed, sd->dropped, sd->time_squeeze, 0,
     0, 0, 0, 0, /* was fastroute */
     sd->cpu_collision, sd->received_rps, flow_limit_count);

下面說明了檔案中每個欄位都是怎麼來的,實際情況可能會有所不同,因為隨著核心版本的迭代,欄位的數量以及欄位的順序都有可能發生變化,其中與網路資料處理被中斷次數相關的就是 squeeze 欄位:

  • sd->processed 處理的包數量(多網路卡 bond 模式可能多於實際的收包數量)

  • sd->dropped 丟包數量,因為佇列滿了

  • sd->time_spueeze 軟中斷處理 net_rx_action 被迫打斷的次數

  • sd->cpu_collision 傳送資料時獲取裝置鎖衝突,比如多個 CPU 同時傳送資料

  • sd->received_rps 當前 CPU 被喚醒的次數(通過處理器間中斷)

  • sd->flow_limit_count 觸發 flow limit 的次數

下圖是業務中遇到相關問題的案例,最後排查到 CPU 層面。圖一是 TOP 命令的輸出,顯示了每個 CPU 的使用量,其中紅框標出的 CPU4 的使用率存在著異常,尤其是倒數第二列的 SI 佔用達到了 89%。SI 是 softirq 的縮寫,表示 CPU 花在軟中斷處理上的時間佔比,而圖中 CPU4 在時間佔比上明顯過高。圖二則是對應圖一的輸出結果,CPU4 對應的是第五行,其中第三列數值明顯高於其他 CPU,表明它在處理網路資料的時被頻繁的打斷。

針對上面的問題推斷 CPU4 存在一定的效能衰退,也許是質量不過關或其他的原因。為了驗證是否是效能衰退,寫了一個簡單的 python 指令碼,一個一直去累加的死迴圈。每次執行時,把這段指令碼繫結到某個 CPU 上,然後觀察不同 CPU 耗時的對比。最後對比結果也顯示 CPU4 的耗時比其他的 CPU 高了幾倍,也驗證了之前的推斷。之後協調運維更換了 CPU,意向指標也就恢復正常了。

總結

以上所有操作都只是在資料包從網路卡到了核心層,還沒到常見的協議,只是完成了萬里長征第一步,後面還有一系列的步驟,例如資料包的壓縮(GRO)、網路卡多佇列軟體(RPS)還有 RFS 在負載均衡的基礎上考慮流的特徵,就是 IP 埠四元組的特徵,最後才是把資料遞交到 IP 層,以及到熟悉的 TCP 層。

總的來說,今天的分享都是圍繞驅動來做的,我想強調的效能優化的核心點在於指標,不能測量也就很難去改善,要有指標的存在,這樣一切的優化才有意義。

推薦閱讀

MySQL 那些常見的錯誤設計規範

全站 HTTPS 就一定安全了嗎?

相關文章