'如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選'

""如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選"如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

作者 | dog250

責編 | 郭芮

出品 | CSDN 博客

現在很多人都在詬病Linux內核協議棧收包效率低,不管他們是真的懂還是一點都不懂只是聽別人說的,反正就是在一味地懟Linux內核協議棧,他們的武器貌似只有DPDK。

但是,即便Linux內核協議棧收包效率真的很低,這是為什麼?有沒有辦法去嘗試著優化?而不是動不動就DPDK,一窩蜂跟上去的想法,大部分都是很low的想法。

再次重申,我不寫技術文檔,我也不分析源碼,本文只是一個思考的總結,但凡是思考,那必然是主觀的,不圖精確,甚至不圖正確。

我們從最開始說起。

"如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

作者 | dog250

責編 | 郭芮

出品 | CSDN 博客

現在很多人都在詬病Linux內核協議棧收包效率低,不管他們是真的懂還是一點都不懂只是聽別人說的,反正就是在一味地懟Linux內核協議棧,他們的武器貌似只有DPDK。

但是,即便Linux內核協議棧收包效率真的很低,這是為什麼?有沒有辦法去嘗試著優化?而不是動不動就DPDK,一窩蜂跟上去的想法,大部分都是很low的想法。

再次重申,我不寫技術文檔,我也不分析源碼,本文只是一個思考的總結,但凡是思考,那必然是主觀的,不圖精確,甚至不圖正確。

我們從最開始說起。

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

Linux內核作為一個通用操作系統內核,脫胎於UNIX那一套現代操作系統理論。但一開始不知道怎麼回事將網絡協議棧的實現塞進了內核態,從此它就一直在內核態了。既然網絡協議棧的處理在內核態進行,那麼網絡數據包必然是在內核態被處理的。

無論如何,數據包要先進入內核態,這就涉及到了進入內核態的方式:

  • 外部可以從兩個方向進入內核-從用戶態系統調用進入或者從硬件中斷進入。

也就是說,系統在任意時刻,必然處在兩個上下文中的一個:

  • 進程上下文;

  • 中斷上下文(在非中斷線程化的系統,也就是任意進程上下文)。

收包邏輯的協議棧處理顯然是自網卡而上的,它顯然是在中斷上下文中,而數據包往用戶進程的數據接收處理,顯然是在應用程序的進程上下文中, 數據包通過socket在兩個上下文中被轉接。

在socket層的數據包轉接處,必然存在著一個隊列緩存,這是一個典型的生產者-消費者模型,中斷上下文的終點作為生產者將數據包入隊,而進程上下文作為消費者從隊列消費數據包:

"如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

作者 | dog250

責編 | 郭芮

出品 | CSDN 博客

現在很多人都在詬病Linux內核協議棧收包效率低,不管他們是真的懂還是一點都不懂只是聽別人說的,反正就是在一味地懟Linux內核協議棧,他們的武器貌似只有DPDK。

但是,即便Linux內核協議棧收包效率真的很低,這是為什麼?有沒有辦法去嘗試著優化?而不是動不動就DPDK,一窩蜂跟上去的想法,大部分都是很low的想法。

再次重申,我不寫技術文檔,我也不分析源碼,本文只是一個思考的總結,但凡是思考,那必然是主觀的,不圖精確,甚至不圖正確。

我們從最開始說起。

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

Linux內核作為一個通用操作系統內核,脫胎於UNIX那一套現代操作系統理論。但一開始不知道怎麼回事將網絡協議棧的實現塞進了內核態,從此它就一直在內核態了。既然網絡協議棧的處理在內核態進行,那麼網絡數據包必然是在內核態被處理的。

無論如何,數據包要先進入內核態,這就涉及到了進入內核態的方式:

  • 外部可以從兩個方向進入內核-從用戶態系統調用進入或者從硬件中斷進入。

也就是說,系統在任意時刻,必然處在兩個上下文中的一個:

  • 進程上下文;

  • 中斷上下文(在非中斷線程化的系統,也就是任意進程上下文)。

收包邏輯的協議棧處理顯然是自網卡而上的,它顯然是在中斷上下文中,而數據包往用戶進程的數據接收處理,顯然是在應用程序的進程上下文中, 數據包通過socket在兩個上下文中被轉接。

在socket層的數據包轉接處,必然存在著一個隊列緩存,這是一個典型的生產者-消費者模型,中斷上下文的終點作為生產者將數據包入隊,而進程上下文作為消費者從隊列消費數據包:

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

非常清爽的一個圖,這個圖是兩個上下文接力處理協議棧收包邏輯的必然結果,讓我們加入一些實際必須要考慮的問題後,我們會發現這幅圖並不是那麼清爽,然後再回過頭看如何來優化。

既然兩個上下文都要在任意可能的時刻操作同一個socket進行數據包的轉交,那麼必須有一個同步機制保護socket元數據以及數據包skb本身。

由於Linux內核中斷,軟中斷可能處在任意進程上下文,唯一的同步方案几乎就是spinlock了,於是,真正的圖示應該是下面的樣子:

"如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

作者 | dog250

責編 | 郭芮

出品 | CSDN 博客

現在很多人都在詬病Linux內核協議棧收包效率低,不管他們是真的懂還是一點都不懂只是聽別人說的,反正就是在一味地懟Linux內核協議棧,他們的武器貌似只有DPDK。

但是,即便Linux內核協議棧收包效率真的很低,這是為什麼?有沒有辦法去嘗試著優化?而不是動不動就DPDK,一窩蜂跟上去的想法,大部分都是很low的想法。

再次重申,我不寫技術文檔,我也不分析源碼,本文只是一個思考的總結,但凡是思考,那必然是主觀的,不圖精確,甚至不圖正確。

我們從最開始說起。

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

Linux內核作為一個通用操作系統內核,脫胎於UNIX那一套現代操作系統理論。但一開始不知道怎麼回事將網絡協議棧的實現塞進了內核態,從此它就一直在內核態了。既然網絡協議棧的處理在內核態進行,那麼網絡數據包必然是在內核態被處理的。

無論如何,數據包要先進入內核態,這就涉及到了進入內核態的方式:

  • 外部可以從兩個方向進入內核-從用戶態系統調用進入或者從硬件中斷進入。

也就是說,系統在任意時刻,必然處在兩個上下文中的一個:

  • 進程上下文;

  • 中斷上下文(在非中斷線程化的系統,也就是任意進程上下文)。

收包邏輯的協議棧處理顯然是自網卡而上的,它顯然是在中斷上下文中,而數據包往用戶進程的數據接收處理,顯然是在應用程序的進程上下文中, 數據包通過socket在兩個上下文中被轉接。

在socket層的數據包轉接處,必然存在著一個隊列緩存,這是一個典型的生產者-消費者模型,中斷上下文的終點作為生產者將數據包入隊,而進程上下文作為消費者從隊列消費數據包:

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

非常清爽的一個圖,這個圖是兩個上下文接力處理協議棧收包邏輯的必然結果,讓我們加入一些實際必須要考慮的問題後,我們會發現這幅圖並不是那麼清爽,然後再回過頭看如何來優化。

既然兩個上下文都要在任意可能的時刻操作同一個socket進行數據包的轉交,那麼必須有一個同步機制保護socket元數據以及數據包skb本身。

由於Linux內核中斷,軟中斷可能處在任意進程上下文,唯一的同步方案几乎就是spinlock了,於是,真正的圖示應該是下面的樣子:

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

現在可以說,類似上面的這種這種保護是非常必要的,特別是對於TCP而言。

我們知道,TCP是基於事務的有狀態傳輸協議,而且攜帶複雜的流控和擁塞控制機制,這些機制所依託的就是socket當前的一些狀態數據,比如inflight、lost、retrans等等,這些狀態數據在發包和接收ACK/SACK期間會不斷變化,所以說:

  • 在一個上下文完成一次事務傳輸之前,必須鎖定socket狀態數據。

比方說發包流程。數據包的發送可以出現在兩個上下文中:

  • 進程上下文:系統調用觸發的發包;

  • 中斷上下文:ACK/SACK觸發的發包。

任何一個上下文的發包過程必須被TCP協議本身比如擁塞控制,流量控制這些所終止,而不能被中途切換到另一個上下文中,所以必須鎖定。

問題是,上圖中的鎖定是不是太狠了些,中斷上下文自旋時間完全取決於進程上下文的行文,這不利於軟中斷的快速返回,極大地降低了系統的響應度。

於是,需要把鎖的粒度進行細分。Linux內核並沒有在橫向上將鎖的粒度做劃分,而是在縱向上,採用兩個層次的鎖機制:

"如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

作者 | dog250

責編 | 郭芮

出品 | CSDN 博客

現在很多人都在詬病Linux內核協議棧收包效率低,不管他們是真的懂還是一點都不懂只是聽別人說的,反正就是在一味地懟Linux內核協議棧,他們的武器貌似只有DPDK。

但是,即便Linux內核協議棧收包效率真的很低,這是為什麼?有沒有辦法去嘗試著優化?而不是動不動就DPDK,一窩蜂跟上去的想法,大部分都是很low的想法。

再次重申,我不寫技術文檔,我也不分析源碼,本文只是一個思考的總結,但凡是思考,那必然是主觀的,不圖精確,甚至不圖正確。

我們從最開始說起。

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

Linux內核作為一個通用操作系統內核,脫胎於UNIX那一套現代操作系統理論。但一開始不知道怎麼回事將網絡協議棧的實現塞進了內核態,從此它就一直在內核態了。既然網絡協議棧的處理在內核態進行,那麼網絡數據包必然是在內核態被處理的。

無論如何,數據包要先進入內核態,這就涉及到了進入內核態的方式:

  • 外部可以從兩個方向進入內核-從用戶態系統調用進入或者從硬件中斷進入。

也就是說,系統在任意時刻,必然處在兩個上下文中的一個:

  • 進程上下文;

  • 中斷上下文(在非中斷線程化的系統,也就是任意進程上下文)。

收包邏輯的協議棧處理顯然是自網卡而上的,它顯然是在中斷上下文中,而數據包往用戶進程的數據接收處理,顯然是在應用程序的進程上下文中, 數據包通過socket在兩個上下文中被轉接。

在socket層的數據包轉接處,必然存在著一個隊列緩存,這是一個典型的生產者-消費者模型,中斷上下文的終點作為生產者將數據包入隊,而進程上下文作為消費者從隊列消費數據包:

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

非常清爽的一個圖,這個圖是兩個上下文接力處理協議棧收包邏輯的必然結果,讓我們加入一些實際必須要考慮的問題後,我們會發現這幅圖並不是那麼清爽,然後再回過頭看如何來優化。

既然兩個上下文都要在任意可能的時刻操作同一個socket進行數據包的轉交,那麼必須有一個同步機制保護socket元數據以及數據包skb本身。

由於Linux內核中斷,軟中斷可能處在任意進程上下文,唯一的同步方案几乎就是spinlock了,於是,真正的圖示應該是下面的樣子:

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

現在可以說,類似上面的這種這種保護是非常必要的,特別是對於TCP而言。

我們知道,TCP是基於事務的有狀態傳輸協議,而且攜帶複雜的流控和擁塞控制機制,這些機制所依託的就是socket當前的一些狀態數據,比如inflight、lost、retrans等等,這些狀態數據在發包和接收ACK/SACK期間會不斷變化,所以說:

  • 在一個上下文完成一次事務傳輸之前,必須鎖定socket狀態數據。

比方說發包流程。數據包的發送可以出現在兩個上下文中:

  • 進程上下文:系統調用觸發的發包;

  • 中斷上下文:ACK/SACK觸發的發包。

任何一個上下文的發包過程必須被TCP協議本身比如擁塞控制,流量控制這些所終止,而不能被中途切換到另一個上下文中,所以必須鎖定。

問題是,上圖中的鎖定是不是太狠了些,中斷上下文自旋時間完全取決於進程上下文的行文,這不利於軟中斷的快速返回,極大地降低了系統的響應度。

於是,需要把鎖的粒度進行細分。Linux內核並沒有在橫向上將鎖的粒度做劃分,而是在縱向上,採用兩個層次的鎖機制:

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

我們看到的Linux內核在處理收包邏輯時的backlog,其實抽象出來就是上面的二級鎖,它是不是很像Windows的IRQL機制呢?伴隨著APC、DPC,你可以把暫時由於高level的IRQL阻滯而無法執行的邏輯放入DPC:

  • 由於進程上下文對socket的low鎖佔有,中斷上下文將skb排入次level的backlog隊列,當進程上下文釋放low鎖的時候,順序執行次level被排入的任務,即處理backlog中的skb。

事實上這是一種非常常見且通用的設計,除了Windows的IRQL,Linux中斷的上半部/下半部也是這種基於思想設計的。

前面說了,TCP的一次事務可能非常複雜耗時,並且必須一次完成,這意味著期間必須持有socket low鎖。以發包邏輯 tcp_write_xmit 函數為例,其內部循環發包,直到受到窗口限制而終止,每一次tcp_transmit_skb返回耗時3微秒~5微秒,平均4微秒,以每次發送4個包為例,在這期間,若使用spinlock,那麼中斷上下文的收包路徑將自旋16微秒,16微秒對於spinlock而言有點久了,於是採用兩級的lock機制,非常有效!

backlog隊列機制有效降低了中斷上下文的spin時延,提高了系統的響應度,非常不錯。但問題是,UDP有必要這樣嗎?

首先,UDP是無狀態的,收包和發包都無需事務,協議棧對UDP的處理,從來都是單個報文粒度的,因此只需要保護唯一的socket接收隊列即可,即 sk_receive_queue 。

enqueue(skb, sk)
{
spin_lock(sk->sk_receive_queue->lock);
skb_queue_tail(sk->sk_receive_queue, skb);
spin_unlock(sk->sk_receive_queue->lock);
}
sk_buff dequeue(sk)
{
spin_lock(sk->sk_receive_queue->lock);
skb = skb_dequeue(sk->sk_receive_queue);
spin_unlock(sk->sk_receive_queue->lock);
return skb;
}

需要保護的接收隊列操作區間都是指令級別的時延,採用一把單一的 sk_receive_queue->lock 足矣。

確實,在Linux 2.6.25版本內核之前,就是這麼幹的。而自從2.2版本內核,TCP就已經採用二級鎖backlog隊列了。

然而,在2.6.25版本內核中,Linux協議棧的UDP收包路徑,轉而採用了兩層鎖的backlog隊列機制,和TCP一樣的邏輯:

low_lock_lock(sk)
{
spin_lock(sk->higher_level_spin_lock); // 熱點!
sk->low_lock_owned_by_process = 1;
spin_unlock(sk->higher_level_spin_lock);
}
low_lock_unlock(sk)
{
spin_lock(sk->higher_level_spin_lock);
sk->low_lock_owned_by_process = 0;
spin_unlock(sk->higher_level_spin_lock);
}

udp_rcv(skb) // 中斷上下文
{
sk = lookup(...);
spin_lock(sk->higher_level_spin_lock); // 熱點!
if (sk->low_lock_owned_by_process) {
enqueue_to_backlog(skb, sk);
} else {
enqueue(skb, sk);// 見上面的偽代碼
update_statis(sk);
wakeup_process(sk);
}
spin_unlock(sk->higher_level_spin_lock);
}

udp_recv(sk, buff) // 進程上下文
{
skb = dequeue(sk); // 見上面的偽代碼
if (skb) {
copy_skb_to_buff(skb, buff);
low_lock_lock(sk);
update_statis(sk);
low_lock_unlock(sk);
dequeue_backlog_to_receive_queue(sk);
}
}

顯然這非常沒有必要。如果你有多個線程同時操作一個UDP socket,將會直面這個熱點,但事實上,你很難遭遇這樣的場景,如果非要說一個,那麼DNS服務器可能首當其中。

之所以在2.6.25版本內核引入了二級鎖backlog隊列,大致是考慮到UDP需要統計內存全局記賬,以防UDP吃盡系統內存,可以review一下 sk_rmem_schedule 函數的邏輯。而在2.6.25版本內核之前,UDP的內存使用是不記賬的,由於UDP本身沒有任何類似流控,擁塞控制之類的約束機制,很容易被惡意程序將系統內存吃盡。

因此,除了sk_receive_queue需要保護,內存記賬邏輯也是需要保護的,比如累加當前skb對內存的佔用到全局數據結構。但即便如此,把這些統計數據的更新都塞入到spinlock的保護區域,也還是要比兩級lock要好。

在我看來,之所以引入二級鎖backlog機制來保護內存記賬邏輯,這是在 借鑑 TCP的代碼,或者說 抄代碼 更直接些。這個攜帶backlog隊列機制的UDP收包代碼存在了好多年,一直在4.9內核才終結。

事實上,僅僅下面的邏輯就可以了:

enqueue(skb, sk)
{
spin_lock(sk->sk_receive_queue->lock);
skb_queue_tail(sk->sk_receive_queue, skb);
update_statis(sk);
spin_unlock(sk->sk_receive_queue->lock);
}
sk_buff dequeue(sk)
{
spin_lock(sk->sk_receive_queue->lock);
skb = skb_dequeue(sk->sk_receive_queue);
update_statis(sk);
spin_unlock(sk->sk_receive_queue->lock);
return skb;
}
udp_rcv(skb) // 中斷上下文
{
sk = lookup(...);
spin_lock(sk->higher_level_spin_lock);
enqueue(skb, sk);// 見上面的偽代碼
spin_unlock(sk->higher_level_spin_lock);
}

udp_recv(sk, buff) // 進程上下文
{
skb = dequeue(sk); // 見上面的偽代碼
if (skb) {
copy_skb_to_buff(skb, buff);
}
}

簡單直接!Linux內核的UDP處理邏輯在4.10版本也確實去掉了兩級的lock,恢復到了2.6.25內核版本之前的邏輯。

"如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

作者 | dog250

責編 | 郭芮

出品 | CSDN 博客

現在很多人都在詬病Linux內核協議棧收包效率低,不管他們是真的懂還是一點都不懂只是聽別人說的,反正就是在一味地懟Linux內核協議棧,他們的武器貌似只有DPDK。

但是,即便Linux內核協議棧收包效率真的很低,這是為什麼?有沒有辦法去嘗試著優化?而不是動不動就DPDK,一窩蜂跟上去的想法,大部分都是很low的想法。

再次重申,我不寫技術文檔,我也不分析源碼,本文只是一個思考的總結,但凡是思考,那必然是主觀的,不圖精確,甚至不圖正確。

我們從最開始說起。

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

Linux內核作為一個通用操作系統內核,脫胎於UNIX那一套現代操作系統理論。但一開始不知道怎麼回事將網絡協議棧的實現塞進了內核態,從此它就一直在內核態了。既然網絡協議棧的處理在內核態進行,那麼網絡數據包必然是在內核態被處理的。

無論如何,數據包要先進入內核態,這就涉及到了進入內核態的方式:

  • 外部可以從兩個方向進入內核-從用戶態系統調用進入或者從硬件中斷進入。

也就是說,系統在任意時刻,必然處在兩個上下文中的一個:

  • 進程上下文;

  • 中斷上下文(在非中斷線程化的系統,也就是任意進程上下文)。

收包邏輯的協議棧處理顯然是自網卡而上的,它顯然是在中斷上下文中,而數據包往用戶進程的數據接收處理,顯然是在應用程序的進程上下文中, 數據包通過socket在兩個上下文中被轉接。

在socket層的數據包轉接處,必然存在著一個隊列緩存,這是一個典型的生產者-消費者模型,中斷上下文的終點作為生產者將數據包入隊,而進程上下文作為消費者從隊列消費數據包:

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

非常清爽的一個圖,這個圖是兩個上下文接力處理協議棧收包邏輯的必然結果,讓我們加入一些實際必須要考慮的問題後,我們會發現這幅圖並不是那麼清爽,然後再回過頭看如何來優化。

既然兩個上下文都要在任意可能的時刻操作同一個socket進行數據包的轉交,那麼必須有一個同步機制保護socket元數據以及數據包skb本身。

由於Linux內核中斷,軟中斷可能處在任意進程上下文,唯一的同步方案几乎就是spinlock了,於是,真正的圖示應該是下面的樣子:

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

現在可以說,類似上面的這種這種保護是非常必要的,特別是對於TCP而言。

我們知道,TCP是基於事務的有狀態傳輸協議,而且攜帶複雜的流控和擁塞控制機制,這些機制所依託的就是socket當前的一些狀態數據,比如inflight、lost、retrans等等,這些狀態數據在發包和接收ACK/SACK期間會不斷變化,所以說:

  • 在一個上下文完成一次事務傳輸之前,必須鎖定socket狀態數據。

比方說發包流程。數據包的發送可以出現在兩個上下文中:

  • 進程上下文:系統調用觸發的發包;

  • 中斷上下文:ACK/SACK觸發的發包。

任何一個上下文的發包過程必須被TCP協議本身比如擁塞控制,流量控制這些所終止,而不能被中途切換到另一個上下文中,所以必須鎖定。

問題是,上圖中的鎖定是不是太狠了些,中斷上下文自旋時間完全取決於進程上下文的行文,這不利於軟中斷的快速返回,極大地降低了系統的響應度。

於是,需要把鎖的粒度進行細分。Linux內核並沒有在橫向上將鎖的粒度做劃分,而是在縱向上,採用兩個層次的鎖機制:

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

我們看到的Linux內核在處理收包邏輯時的backlog,其實抽象出來就是上面的二級鎖,它是不是很像Windows的IRQL機制呢?伴隨著APC、DPC,你可以把暫時由於高level的IRQL阻滯而無法執行的邏輯放入DPC:

  • 由於進程上下文對socket的low鎖佔有,中斷上下文將skb排入次level的backlog隊列,當進程上下文釋放low鎖的時候,順序執行次level被排入的任務,即處理backlog中的skb。

事實上這是一種非常常見且通用的設計,除了Windows的IRQL,Linux中斷的上半部/下半部也是這種基於思想設計的。

前面說了,TCP的一次事務可能非常複雜耗時,並且必須一次完成,這意味著期間必須持有socket low鎖。以發包邏輯 tcp_write_xmit 函數為例,其內部循環發包,直到受到窗口限制而終止,每一次tcp_transmit_skb返回耗時3微秒~5微秒,平均4微秒,以每次發送4個包為例,在這期間,若使用spinlock,那麼中斷上下文的收包路徑將自旋16微秒,16微秒對於spinlock而言有點久了,於是採用兩級的lock機制,非常有效!

backlog隊列機制有效降低了中斷上下文的spin時延,提高了系統的響應度,非常不錯。但問題是,UDP有必要這樣嗎?

首先,UDP是無狀態的,收包和發包都無需事務,協議棧對UDP的處理,從來都是單個報文粒度的,因此只需要保護唯一的socket接收隊列即可,即 sk_receive_queue 。

enqueue(skb, sk)
{
spin_lock(sk->sk_receive_queue->lock);
skb_queue_tail(sk->sk_receive_queue, skb);
spin_unlock(sk->sk_receive_queue->lock);
}
sk_buff dequeue(sk)
{
spin_lock(sk->sk_receive_queue->lock);
skb = skb_dequeue(sk->sk_receive_queue);
spin_unlock(sk->sk_receive_queue->lock);
return skb;
}

需要保護的接收隊列操作區間都是指令級別的時延,採用一把單一的 sk_receive_queue->lock 足矣。

確實,在Linux 2.6.25版本內核之前,就是這麼幹的。而自從2.2版本內核,TCP就已經採用二級鎖backlog隊列了。

然而,在2.6.25版本內核中,Linux協議棧的UDP收包路徑,轉而採用了兩層鎖的backlog隊列機制,和TCP一樣的邏輯:

low_lock_lock(sk)
{
spin_lock(sk->higher_level_spin_lock); // 熱點!
sk->low_lock_owned_by_process = 1;
spin_unlock(sk->higher_level_spin_lock);
}
low_lock_unlock(sk)
{
spin_lock(sk->higher_level_spin_lock);
sk->low_lock_owned_by_process = 0;
spin_unlock(sk->higher_level_spin_lock);
}

udp_rcv(skb) // 中斷上下文
{
sk = lookup(...);
spin_lock(sk->higher_level_spin_lock); // 熱點!
if (sk->low_lock_owned_by_process) {
enqueue_to_backlog(skb, sk);
} else {
enqueue(skb, sk);// 見上面的偽代碼
update_statis(sk);
wakeup_process(sk);
}
spin_unlock(sk->higher_level_spin_lock);
}

udp_recv(sk, buff) // 進程上下文
{
skb = dequeue(sk); // 見上面的偽代碼
if (skb) {
copy_skb_to_buff(skb, buff);
low_lock_lock(sk);
update_statis(sk);
low_lock_unlock(sk);
dequeue_backlog_to_receive_queue(sk);
}
}

顯然這非常沒有必要。如果你有多個線程同時操作一個UDP socket,將會直面這個熱點,但事實上,你很難遭遇這樣的場景,如果非要說一個,那麼DNS服務器可能首當其中。

之所以在2.6.25版本內核引入了二級鎖backlog隊列,大致是考慮到UDP需要統計內存全局記賬,以防UDP吃盡系統內存,可以review一下 sk_rmem_schedule 函數的邏輯。而在2.6.25版本內核之前,UDP的內存使用是不記賬的,由於UDP本身沒有任何類似流控,擁塞控制之類的約束機制,很容易被惡意程序將系統內存吃盡。

因此,除了sk_receive_queue需要保護,內存記賬邏輯也是需要保護的,比如累加當前skb對內存的佔用到全局數據結構。但即便如此,把這些統計數據的更新都塞入到spinlock的保護區域,也還是要比兩級lock要好。

在我看來,之所以引入二級鎖backlog機制來保護內存記賬邏輯,這是在 借鑑 TCP的代碼,或者說 抄代碼 更直接些。這個攜帶backlog隊列機制的UDP收包代碼存在了好多年,一直在4.9內核才終結。

事實上,僅僅下面的邏輯就可以了:

enqueue(skb, sk)
{
spin_lock(sk->sk_receive_queue->lock);
skb_queue_tail(sk->sk_receive_queue, skb);
update_statis(sk);
spin_unlock(sk->sk_receive_queue->lock);
}
sk_buff dequeue(sk)
{
spin_lock(sk->sk_receive_queue->lock);
skb = skb_dequeue(sk->sk_receive_queue);
update_statis(sk);
spin_unlock(sk->sk_receive_queue->lock);
return skb;
}
udp_rcv(skb) // 中斷上下文
{
sk = lookup(...);
spin_lock(sk->higher_level_spin_lock);
enqueue(skb, sk);// 見上面的偽代碼
spin_unlock(sk->higher_level_spin_lock);
}

udp_recv(sk, buff) // 進程上下文
{
skb = dequeue(sk); // 見上面的偽代碼
if (skb) {
copy_skb_to_buff(skb, buff);
}
}

簡單直接!Linux內核的UDP處理邏輯在4.10版本也確實去掉了兩級的lock,恢復到了2.6.25內核版本之前的邏輯。

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

上面的優化帶來了可觀的性能收益,但是卻並不值得炫耀。因為上面的優化更像是解決了一個bug,這個bug是在2.6.25版本內核因為借鑑TCP的backlog實現而引入的,而事實上,UDP並不需要這種花哨的backlog邏輯。所以說,上面的效果並非優化而帶來的效果,而是解了一個bug帶來的效果。

但是為什麼遲至4.10版本才發現並解決這個問題的呢?

我想這件事可能跟QUIC有關。用得少的邏輯自然就不容易發現問題,這就好比David Miller在2.6版本內核引入IPv6實現的那幾個bug,就是因為IPv6用的人少,所以一直在很晚的4.23+版本內核才被發現被解決。對於UDP,一直到2.6.24版本其實現都挺好,符合邏輯,2.6.25引入的二級鎖bug同樣是因為UDP本身用的少而沒有被發現。

在QUIC之前,很少有那種有來有回的持續全雙工UDP長連接,基本都是request/response的oneshot類型的連接。然而QUIC卻是類似TCP的全雙工協議,在數據發送端持續發送大塊數據的同時,伴隨著的是接收大量的ACK報文,這顯然和TCP一樣,也是一種反饋控制的方式來驅動數據的發送。

QUIC是有確認機制的,但是處理確認卻不是在內核進行的,內核只是一個快速將確認包收到用戶態QUIC處理進程的一個通路,這個通路越快越好!也就是說,QUIC的ACK報文的接收效率會影響其數據的發送效率。

隨著QUIC的大規模部署,人們才開始逐漸關注其背後UDP的收包效率問題。擺脫了二級鎖的backlog隊列之後,僅僅是為UDP後續的優化掃清了障礙,這才是真正剛剛開始。擺在UDP的內核協議棧收包效率面前的,有一個現成的靶子,那就是DPDK。

挺煩DPDK的,說實話,被人天天說的東西都挺煩。不過你得先把內核協議棧的UDP性能優化到接近DPDK,再把這種鄙視當後話來講才更酷。

由於UDP的處理非常簡單,因此實現一個能和DPDK對接的UDP用戶態協議棧則並不是一件難事。而TCP則相反,它非常複雜,所以DPDK很少有完整處理TCP端到端邏輯的,大多數都只是做類似中間節點DPI這種事。目前都沒有幾個好用的基於DPDK的TCP實現,但是UDP實現卻很多。

DPDK的偽粉絲拿UDP說事的,比拿TCP說事,成本要低很多。好吧,那為什麼DPDK處理UDP收包效率那麼高?答案很簡單, DPDK是在進程上下文輪詢接收UDP數據包的! 也就是說,它擺脫了兩個問題:

  • 進程上下文和中斷上下文操作共享數據的鎖問題;

  • 進程上下文和中斷上下文切換導致的cache miss問題。

這兩點其實也就是 “為什麼內核協議棧性能幹不過用戶態協議棧” 的要點。當然,Linux內核協議棧無法擺脫這兩點問題,也就回答了本文的題目中的第一個問題, “Linux內核UDP收包為什麼效率低” ?

不同的上下文異步操作同一份數據,鎖是必不可少的。關於鎖的話題已經爛大街了。現在僅就cache來討論,中斷上下文和進程上下文之間的切換,也有一個明顯的case:

  • 中斷上下文中修改了socket的元統計數據,該修改會表現在cache中,然而當其wakeup該socket的處理進程後,切換到進程上下文的recv系統調用,其或讀或寫這個統計數據,伴隨著cache的flush以及cache的一致性同步。

如果這些操作統一在進程上下文中進行,cache的利用率將會高效很多。當然,回到UDP收包不合理的backlog隊列機制,其實backlog本身存在的目的之一,就是為了讓進程上下文去處理,以提高cache的利用率,減少不必要的flush。然而,初衷未必能達到效果,在傳輸層用backlog將skb推給進程上下文去處理,已經太晚了,何必不再網卡就給進程上下文呢?就像DPDK那樣。

其實Linux內核社區早就意識到了這兩點,早在3.11版本內核中引入的busy poll機制就是為了解決鎖和切換問題的。busy poll的思想非常簡單,那就是:

  • 不再需要軟中斷上下文往接收隊列裡“推”數據包,而改成自己在進程上下文裡主動從網卡上“拉”數據包。

落實到代碼上,那就是在進程上下文的recvmsg函數中直接調用napi的收包函數,從ring buffer裡拿數據,自己調用netif_receive_skb。

如果busy poll總能執行,它總是能拉取到自己下一個需要的數據包,那麼這基本就是DPDK的效率了,然而和DPDK一樣,這並不是一個統一的解決方案,輪詢固然對於收包有收益,但中斷是不能丟的,用CPU的自旋輪詢換取收包效率,這買賣代價太大,畢竟Linux內核並非專職收包的。

當然了,也許內核態實現協議棧本身就是一種錯誤,但這個話題有點跑偏,畢竟我們就是要優化內核協議棧的,而不是放棄它。

關於這個話題,推薦一篇好文:

千萬級併發實現的祕密:內核不是解決方案,而是問題所在!http://highscalability.com/blog/2013/5/13/the-secret-to-10-million-concurrent-connections-the-kernel-i.html

"如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

作者 | dog250

責編 | 郭芮

出品 | CSDN 博客

現在很多人都在詬病Linux內核協議棧收包效率低,不管他們是真的懂還是一點都不懂只是聽別人說的,反正就是在一味地懟Linux內核協議棧,他們的武器貌似只有DPDK。

但是,即便Linux內核協議棧收包效率真的很低,這是為什麼?有沒有辦法去嘗試著優化?而不是動不動就DPDK,一窩蜂跟上去的想法,大部分都是很low的想法。

再次重申,我不寫技術文檔,我也不分析源碼,本文只是一個思考的總結,但凡是思考,那必然是主觀的,不圖精確,甚至不圖正確。

我們從最開始說起。

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

Linux內核作為一個通用操作系統內核,脫胎於UNIX那一套現代操作系統理論。但一開始不知道怎麼回事將網絡協議棧的實現塞進了內核態,從此它就一直在內核態了。既然網絡協議棧的處理在內核態進行,那麼網絡數據包必然是在內核態被處理的。

無論如何,數據包要先進入內核態,這就涉及到了進入內核態的方式:

  • 外部可以從兩個方向進入內核-從用戶態系統調用進入或者從硬件中斷進入。

也就是說,系統在任意時刻,必然處在兩個上下文中的一個:

  • 進程上下文;

  • 中斷上下文(在非中斷線程化的系統,也就是任意進程上下文)。

收包邏輯的協議棧處理顯然是自網卡而上的,它顯然是在中斷上下文中,而數據包往用戶進程的數據接收處理,顯然是在應用程序的進程上下文中, 數據包通過socket在兩個上下文中被轉接。

在socket層的數據包轉接處,必然存在著一個隊列緩存,這是一個典型的生產者-消費者模型,中斷上下文的終點作為生產者將數據包入隊,而進程上下文作為消費者從隊列消費數據包:

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

非常清爽的一個圖,這個圖是兩個上下文接力處理協議棧收包邏輯的必然結果,讓我們加入一些實際必須要考慮的問題後,我們會發現這幅圖並不是那麼清爽,然後再回過頭看如何來優化。

既然兩個上下文都要在任意可能的時刻操作同一個socket進行數據包的轉交,那麼必須有一個同步機制保護socket元數據以及數據包skb本身。

由於Linux內核中斷,軟中斷可能處在任意進程上下文,唯一的同步方案几乎就是spinlock了,於是,真正的圖示應該是下面的樣子:

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

現在可以說,類似上面的這種這種保護是非常必要的,特別是對於TCP而言。

我們知道,TCP是基於事務的有狀態傳輸協議,而且攜帶複雜的流控和擁塞控制機制,這些機制所依託的就是socket當前的一些狀態數據,比如inflight、lost、retrans等等,這些狀態數據在發包和接收ACK/SACK期間會不斷變化,所以說:

  • 在一個上下文完成一次事務傳輸之前,必須鎖定socket狀態數據。

比方說發包流程。數據包的發送可以出現在兩個上下文中:

  • 進程上下文:系統調用觸發的發包;

  • 中斷上下文:ACK/SACK觸發的發包。

任何一個上下文的發包過程必須被TCP協議本身比如擁塞控制,流量控制這些所終止,而不能被中途切換到另一個上下文中,所以必須鎖定。

問題是,上圖中的鎖定是不是太狠了些,中斷上下文自旋時間完全取決於進程上下文的行文,這不利於軟中斷的快速返回,極大地降低了系統的響應度。

於是,需要把鎖的粒度進行細分。Linux內核並沒有在橫向上將鎖的粒度做劃分,而是在縱向上,採用兩個層次的鎖機制:

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

我們看到的Linux內核在處理收包邏輯時的backlog,其實抽象出來就是上面的二級鎖,它是不是很像Windows的IRQL機制呢?伴隨著APC、DPC,你可以把暫時由於高level的IRQL阻滯而無法執行的邏輯放入DPC:

  • 由於進程上下文對socket的low鎖佔有,中斷上下文將skb排入次level的backlog隊列,當進程上下文釋放low鎖的時候,順序執行次level被排入的任務,即處理backlog中的skb。

事實上這是一種非常常見且通用的設計,除了Windows的IRQL,Linux中斷的上半部/下半部也是這種基於思想設計的。

前面說了,TCP的一次事務可能非常複雜耗時,並且必須一次完成,這意味著期間必須持有socket low鎖。以發包邏輯 tcp_write_xmit 函數為例,其內部循環發包,直到受到窗口限制而終止,每一次tcp_transmit_skb返回耗時3微秒~5微秒,平均4微秒,以每次發送4個包為例,在這期間,若使用spinlock,那麼中斷上下文的收包路徑將自旋16微秒,16微秒對於spinlock而言有點久了,於是採用兩級的lock機制,非常有效!

backlog隊列機制有效降低了中斷上下文的spin時延,提高了系統的響應度,非常不錯。但問題是,UDP有必要這樣嗎?

首先,UDP是無狀態的,收包和發包都無需事務,協議棧對UDP的處理,從來都是單個報文粒度的,因此只需要保護唯一的socket接收隊列即可,即 sk_receive_queue 。

enqueue(skb, sk)
{
spin_lock(sk->sk_receive_queue->lock);
skb_queue_tail(sk->sk_receive_queue, skb);
spin_unlock(sk->sk_receive_queue->lock);
}
sk_buff dequeue(sk)
{
spin_lock(sk->sk_receive_queue->lock);
skb = skb_dequeue(sk->sk_receive_queue);
spin_unlock(sk->sk_receive_queue->lock);
return skb;
}

需要保護的接收隊列操作區間都是指令級別的時延,採用一把單一的 sk_receive_queue->lock 足矣。

確實,在Linux 2.6.25版本內核之前,就是這麼幹的。而自從2.2版本內核,TCP就已經採用二級鎖backlog隊列了。

然而,在2.6.25版本內核中,Linux協議棧的UDP收包路徑,轉而採用了兩層鎖的backlog隊列機制,和TCP一樣的邏輯:

low_lock_lock(sk)
{
spin_lock(sk->higher_level_spin_lock); // 熱點!
sk->low_lock_owned_by_process = 1;
spin_unlock(sk->higher_level_spin_lock);
}
low_lock_unlock(sk)
{
spin_lock(sk->higher_level_spin_lock);
sk->low_lock_owned_by_process = 0;
spin_unlock(sk->higher_level_spin_lock);
}

udp_rcv(skb) // 中斷上下文
{
sk = lookup(...);
spin_lock(sk->higher_level_spin_lock); // 熱點!
if (sk->low_lock_owned_by_process) {
enqueue_to_backlog(skb, sk);
} else {
enqueue(skb, sk);// 見上面的偽代碼
update_statis(sk);
wakeup_process(sk);
}
spin_unlock(sk->higher_level_spin_lock);
}

udp_recv(sk, buff) // 進程上下文
{
skb = dequeue(sk); // 見上面的偽代碼
if (skb) {
copy_skb_to_buff(skb, buff);
low_lock_lock(sk);
update_statis(sk);
low_lock_unlock(sk);
dequeue_backlog_to_receive_queue(sk);
}
}

顯然這非常沒有必要。如果你有多個線程同時操作一個UDP socket,將會直面這個熱點,但事實上,你很難遭遇這樣的場景,如果非要說一個,那麼DNS服務器可能首當其中。

之所以在2.6.25版本內核引入了二級鎖backlog隊列,大致是考慮到UDP需要統計內存全局記賬,以防UDP吃盡系統內存,可以review一下 sk_rmem_schedule 函數的邏輯。而在2.6.25版本內核之前,UDP的內存使用是不記賬的,由於UDP本身沒有任何類似流控,擁塞控制之類的約束機制,很容易被惡意程序將系統內存吃盡。

因此,除了sk_receive_queue需要保護,內存記賬邏輯也是需要保護的,比如累加當前skb對內存的佔用到全局數據結構。但即便如此,把這些統計數據的更新都塞入到spinlock的保護區域,也還是要比兩級lock要好。

在我看來,之所以引入二級鎖backlog機制來保護內存記賬邏輯,這是在 借鑑 TCP的代碼,或者說 抄代碼 更直接些。這個攜帶backlog隊列機制的UDP收包代碼存在了好多年,一直在4.9內核才終結。

事實上,僅僅下面的邏輯就可以了:

enqueue(skb, sk)
{
spin_lock(sk->sk_receive_queue->lock);
skb_queue_tail(sk->sk_receive_queue, skb);
update_statis(sk);
spin_unlock(sk->sk_receive_queue->lock);
}
sk_buff dequeue(sk)
{
spin_lock(sk->sk_receive_queue->lock);
skb = skb_dequeue(sk->sk_receive_queue);
update_statis(sk);
spin_unlock(sk->sk_receive_queue->lock);
return skb;
}
udp_rcv(skb) // 中斷上下文
{
sk = lookup(...);
spin_lock(sk->higher_level_spin_lock);
enqueue(skb, sk);// 見上面的偽代碼
spin_unlock(sk->higher_level_spin_lock);
}

udp_recv(sk, buff) // 進程上下文
{
skb = dequeue(sk); // 見上面的偽代碼
if (skb) {
copy_skb_to_buff(skb, buff);
}
}

簡單直接!Linux內核的UDP處理邏輯在4.10版本也確實去掉了兩級的lock,恢復到了2.6.25內核版本之前的邏輯。

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

上面的優化帶來了可觀的性能收益,但是卻並不值得炫耀。因為上面的優化更像是解決了一個bug,這個bug是在2.6.25版本內核因為借鑑TCP的backlog實現而引入的,而事實上,UDP並不需要這種花哨的backlog邏輯。所以說,上面的效果並非優化而帶來的效果,而是解了一個bug帶來的效果。

但是為什麼遲至4.10版本才發現並解決這個問題的呢?

我想這件事可能跟QUIC有關。用得少的邏輯自然就不容易發現問題,這就好比David Miller在2.6版本內核引入IPv6實現的那幾個bug,就是因為IPv6用的人少,所以一直在很晚的4.23+版本內核才被發現被解決。對於UDP,一直到2.6.24版本其實現都挺好,符合邏輯,2.6.25引入的二級鎖bug同樣是因為UDP本身用的少而沒有被發現。

在QUIC之前,很少有那種有來有回的持續全雙工UDP長連接,基本都是request/response的oneshot類型的連接。然而QUIC卻是類似TCP的全雙工協議,在數據發送端持續發送大塊數據的同時,伴隨著的是接收大量的ACK報文,這顯然和TCP一樣,也是一種反饋控制的方式來驅動數據的發送。

QUIC是有確認機制的,但是處理確認卻不是在內核進行的,內核只是一個快速將確認包收到用戶態QUIC處理進程的一個通路,這個通路越快越好!也就是說,QUIC的ACK報文的接收效率會影響其數據的發送效率。

隨著QUIC的大規模部署,人們才開始逐漸關注其背後UDP的收包效率問題。擺脫了二級鎖的backlog隊列之後,僅僅是為UDP後續的優化掃清了障礙,這才是真正剛剛開始。擺在UDP的內核協議棧收包效率面前的,有一個現成的靶子,那就是DPDK。

挺煩DPDK的,說實話,被人天天說的東西都挺煩。不過你得先把內核協議棧的UDP性能優化到接近DPDK,再把這種鄙視當後話來講才更酷。

由於UDP的處理非常簡單,因此實現一個能和DPDK對接的UDP用戶態協議棧則並不是一件難事。而TCP則相反,它非常複雜,所以DPDK很少有完整處理TCP端到端邏輯的,大多數都只是做類似中間節點DPI這種事。目前都沒有幾個好用的基於DPDK的TCP實現,但是UDP實現卻很多。

DPDK的偽粉絲拿UDP說事的,比拿TCP說事,成本要低很多。好吧,那為什麼DPDK處理UDP收包效率那麼高?答案很簡單, DPDK是在進程上下文輪詢接收UDP數據包的! 也就是說,它擺脫了兩個問題:

  • 進程上下文和中斷上下文操作共享數據的鎖問題;

  • 進程上下文和中斷上下文切換導致的cache miss問題。

這兩點其實也就是 “為什麼內核協議棧性能幹不過用戶態協議棧” 的要點。當然,Linux內核協議棧無法擺脫這兩點問題,也就回答了本文的題目中的第一個問題, “Linux內核UDP收包為什麼效率低” ?

不同的上下文異步操作同一份數據,鎖是必不可少的。關於鎖的話題已經爛大街了。現在僅就cache來討論,中斷上下文和進程上下文之間的切換,也有一個明顯的case:

  • 中斷上下文中修改了socket的元統計數據,該修改會表現在cache中,然而當其wakeup該socket的處理進程後,切換到進程上下文的recv系統調用,其或讀或寫這個統計數據,伴隨著cache的flush以及cache的一致性同步。

如果這些操作統一在進程上下文中進行,cache的利用率將會高效很多。當然,回到UDP收包不合理的backlog隊列機制,其實backlog本身存在的目的之一,就是為了讓進程上下文去處理,以提高cache的利用率,減少不必要的flush。然而,初衷未必能達到效果,在傳輸層用backlog將skb推給進程上下文去處理,已經太晚了,何必不再網卡就給進程上下文呢?就像DPDK那樣。

其實Linux內核社區早就意識到了這兩點,早在3.11版本內核中引入的busy poll機制就是為了解決鎖和切換問題的。busy poll的思想非常簡單,那就是:

  • 不再需要軟中斷上下文往接收隊列裡“推”數據包,而改成自己在進程上下文裡主動從網卡上“拉”數據包。

落實到代碼上,那就是在進程上下文的recvmsg函數中直接調用napi的收包函數,從ring buffer裡拿數據,自己調用netif_receive_skb。

如果busy poll總能執行,它總是能拉取到自己下一個需要的數據包,那麼這基本就是DPDK的效率了,然而和DPDK一樣,這並不是一個統一的解決方案,輪詢固然對於收包有收益,但中斷是不能丟的,用CPU的自旋輪詢換取收包效率,這買賣代價太大,畢竟Linux內核並非專職收包的。

當然了,也許內核態實現協議棧本身就是一種錯誤,但這個話題有點跑偏,畢竟我們就是要優化內核協議棧的,而不是放棄它。

關於這個話題,推薦一篇好文:

千萬級併發實現的祕密:內核不是解決方案,而是問題所在!http://highscalability.com/blog/2013/5/13/the-secret-to-10-million-concurrent-connections-the-kernel-i.html

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

現在,我們不能指望busy poll擔當所有的性能問題,仍然要依靠中斷。既然依靠中斷,鎖的問題就是優化的重點。

以雙核CPU為例,假設CPU0專職處理中斷,而收包進程則綁定在CPU1上,我們很快能意識到, CPU0和CPU1對於每一個skb的enqueue和dequeue均在爭搶socket的sk_receive_queue的spinlock 。

優化措施顯而易見, 將多個skb聚集起來,一次性入接收隊列 。顯然,這需要兩個隊列:

  • 維護聚集隊列:由中斷上下文將skb推入該隊列;

  • 維護接收隊列:進程上下文從該隊列拉取skb;

  • 接收隊列為空時,交換聚集隊列和接收隊列。

這樣,同樣在上述雙核CPU的情況下,只有在上面的第3點的操作中,才需要鎖保護。

考慮到機器的CPU並非雙核,可能是任意核,收包進程也未必綁定任何CPU,因此上述每一個隊列均需要一把鎖保護,無論如何, 和單隊列相比,雙隊列情況下鎖的競爭減少了一半!

collect_enqueue(skb, sk)
{
spin_lock(sk->sk_collect_queue->lock);
skb_queue_tail(sk->sk_collect_queue, skb);
update_statis(sk);
spin_unlock(sk->sk_collect_queue->lock);
}
sk_buff recv_dequeue(sk)
{
spin_lock(sk->sk_receive_queue->lock);
skb = skb_dequeue(sk->sk_receive_queue);
update_statis(sk);
spin_unlock(sk->sk_receive_queue->lock);
return skb;
}
udp_rcv(skb) // 中斷上下文
{
sk = lookup(...);
spin_lock(sk->higher_level_spin_lock);
collect_enqueue(skb, sk);// 僅僅往聚集隊列裡推入。
spin_unlock(sk->higher_level_spin_lock);
}

udp_recv(sk, buff) // 進程上下文
{
if (empty(sk->sk_receive_queue)) {
spin_lock(sk->queues_lock);
swap(sk->sk_receive_queue, sk->sk_collect_queue);
spin_unlock(sk->queues_lock)
}
skb = recv_dequeue(sk); // 僅僅從接收隊列里拉取
if (skb) {
copy_skb_to_buff(skb, buff);
}
}

如此一來,雙隊列解除了中斷上下文和進程上下文之間的鎖競爭。

來看一下對比圖示:

"如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

作者 | dog250

責編 | 郭芮

出品 | CSDN 博客

現在很多人都在詬病Linux內核協議棧收包效率低,不管他們是真的懂還是一點都不懂只是聽別人說的,反正就是在一味地懟Linux內核協議棧,他們的武器貌似只有DPDK。

但是,即便Linux內核協議棧收包效率真的很低,這是為什麼?有沒有辦法去嘗試著優化?而不是動不動就DPDK,一窩蜂跟上去的想法,大部分都是很low的想法。

再次重申,我不寫技術文檔,我也不分析源碼,本文只是一個思考的總結,但凡是思考,那必然是主觀的,不圖精確,甚至不圖正確。

我們從最開始說起。

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

Linux內核作為一個通用操作系統內核,脫胎於UNIX那一套現代操作系統理論。但一開始不知道怎麼回事將網絡協議棧的實現塞進了內核態,從此它就一直在內核態了。既然網絡協議棧的處理在內核態進行,那麼網絡數據包必然是在內核態被處理的。

無論如何,數據包要先進入內核態,這就涉及到了進入內核態的方式:

  • 外部可以從兩個方向進入內核-從用戶態系統調用進入或者從硬件中斷進入。

也就是說,系統在任意時刻,必然處在兩個上下文中的一個:

  • 進程上下文;

  • 中斷上下文(在非中斷線程化的系統,也就是任意進程上下文)。

收包邏輯的協議棧處理顯然是自網卡而上的,它顯然是在中斷上下文中,而數據包往用戶進程的數據接收處理,顯然是在應用程序的進程上下文中, 數據包通過socket在兩個上下文中被轉接。

在socket層的數據包轉接處,必然存在著一個隊列緩存,這是一個典型的生產者-消費者模型,中斷上下文的終點作為生產者將數據包入隊,而進程上下文作為消費者從隊列消費數據包:

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

非常清爽的一個圖,這個圖是兩個上下文接力處理協議棧收包邏輯的必然結果,讓我們加入一些實際必須要考慮的問題後,我們會發現這幅圖並不是那麼清爽,然後再回過頭看如何來優化。

既然兩個上下文都要在任意可能的時刻操作同一個socket進行數據包的轉交,那麼必須有一個同步機制保護socket元數據以及數據包skb本身。

由於Linux內核中斷,軟中斷可能處在任意進程上下文,唯一的同步方案几乎就是spinlock了,於是,真正的圖示應該是下面的樣子:

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

現在可以說,類似上面的這種這種保護是非常必要的,特別是對於TCP而言。

我們知道,TCP是基於事務的有狀態傳輸協議,而且攜帶複雜的流控和擁塞控制機制,這些機制所依託的就是socket當前的一些狀態數據,比如inflight、lost、retrans等等,這些狀態數據在發包和接收ACK/SACK期間會不斷變化,所以說:

  • 在一個上下文完成一次事務傳輸之前,必須鎖定socket狀態數據。

比方說發包流程。數據包的發送可以出現在兩個上下文中:

  • 進程上下文:系統調用觸發的發包;

  • 中斷上下文:ACK/SACK觸發的發包。

任何一個上下文的發包過程必須被TCP協議本身比如擁塞控制,流量控制這些所終止,而不能被中途切換到另一個上下文中,所以必須鎖定。

問題是,上圖中的鎖定是不是太狠了些,中斷上下文自旋時間完全取決於進程上下文的行文,這不利於軟中斷的快速返回,極大地降低了系統的響應度。

於是,需要把鎖的粒度進行細分。Linux內核並沒有在橫向上將鎖的粒度做劃分,而是在縱向上,採用兩個層次的鎖機制:

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

我們看到的Linux內核在處理收包邏輯時的backlog,其實抽象出來就是上面的二級鎖,它是不是很像Windows的IRQL機制呢?伴隨著APC、DPC,你可以把暫時由於高level的IRQL阻滯而無法執行的邏輯放入DPC:

  • 由於進程上下文對socket的low鎖佔有,中斷上下文將skb排入次level的backlog隊列,當進程上下文釋放low鎖的時候,順序執行次level被排入的任務,即處理backlog中的skb。

事實上這是一種非常常見且通用的設計,除了Windows的IRQL,Linux中斷的上半部/下半部也是這種基於思想設計的。

前面說了,TCP的一次事務可能非常複雜耗時,並且必須一次完成,這意味著期間必須持有socket low鎖。以發包邏輯 tcp_write_xmit 函數為例,其內部循環發包,直到受到窗口限制而終止,每一次tcp_transmit_skb返回耗時3微秒~5微秒,平均4微秒,以每次發送4個包為例,在這期間,若使用spinlock,那麼中斷上下文的收包路徑將自旋16微秒,16微秒對於spinlock而言有點久了,於是採用兩級的lock機制,非常有效!

backlog隊列機制有效降低了中斷上下文的spin時延,提高了系統的響應度,非常不錯。但問題是,UDP有必要這樣嗎?

首先,UDP是無狀態的,收包和發包都無需事務,協議棧對UDP的處理,從來都是單個報文粒度的,因此只需要保護唯一的socket接收隊列即可,即 sk_receive_queue 。

enqueue(skb, sk)
{
spin_lock(sk->sk_receive_queue->lock);
skb_queue_tail(sk->sk_receive_queue, skb);
spin_unlock(sk->sk_receive_queue->lock);
}
sk_buff dequeue(sk)
{
spin_lock(sk->sk_receive_queue->lock);
skb = skb_dequeue(sk->sk_receive_queue);
spin_unlock(sk->sk_receive_queue->lock);
return skb;
}

需要保護的接收隊列操作區間都是指令級別的時延,採用一把單一的 sk_receive_queue->lock 足矣。

確實,在Linux 2.6.25版本內核之前,就是這麼幹的。而自從2.2版本內核,TCP就已經採用二級鎖backlog隊列了。

然而,在2.6.25版本內核中,Linux協議棧的UDP收包路徑,轉而採用了兩層鎖的backlog隊列機制,和TCP一樣的邏輯:

low_lock_lock(sk)
{
spin_lock(sk->higher_level_spin_lock); // 熱點!
sk->low_lock_owned_by_process = 1;
spin_unlock(sk->higher_level_spin_lock);
}
low_lock_unlock(sk)
{
spin_lock(sk->higher_level_spin_lock);
sk->low_lock_owned_by_process = 0;
spin_unlock(sk->higher_level_spin_lock);
}

udp_rcv(skb) // 中斷上下文
{
sk = lookup(...);
spin_lock(sk->higher_level_spin_lock); // 熱點!
if (sk->low_lock_owned_by_process) {
enqueue_to_backlog(skb, sk);
} else {
enqueue(skb, sk);// 見上面的偽代碼
update_statis(sk);
wakeup_process(sk);
}
spin_unlock(sk->higher_level_spin_lock);
}

udp_recv(sk, buff) // 進程上下文
{
skb = dequeue(sk); // 見上面的偽代碼
if (skb) {
copy_skb_to_buff(skb, buff);
low_lock_lock(sk);
update_statis(sk);
low_lock_unlock(sk);
dequeue_backlog_to_receive_queue(sk);
}
}

顯然這非常沒有必要。如果你有多個線程同時操作一個UDP socket,將會直面這個熱點,但事實上,你很難遭遇這樣的場景,如果非要說一個,那麼DNS服務器可能首當其中。

之所以在2.6.25版本內核引入了二級鎖backlog隊列,大致是考慮到UDP需要統計內存全局記賬,以防UDP吃盡系統內存,可以review一下 sk_rmem_schedule 函數的邏輯。而在2.6.25版本內核之前,UDP的內存使用是不記賬的,由於UDP本身沒有任何類似流控,擁塞控制之類的約束機制,很容易被惡意程序將系統內存吃盡。

因此,除了sk_receive_queue需要保護,內存記賬邏輯也是需要保護的,比如累加當前skb對內存的佔用到全局數據結構。但即便如此,把這些統計數據的更新都塞入到spinlock的保護區域,也還是要比兩級lock要好。

在我看來,之所以引入二級鎖backlog機制來保護內存記賬邏輯,這是在 借鑑 TCP的代碼,或者說 抄代碼 更直接些。這個攜帶backlog隊列機制的UDP收包代碼存在了好多年,一直在4.9內核才終結。

事實上,僅僅下面的邏輯就可以了:

enqueue(skb, sk)
{
spin_lock(sk->sk_receive_queue->lock);
skb_queue_tail(sk->sk_receive_queue, skb);
update_statis(sk);
spin_unlock(sk->sk_receive_queue->lock);
}
sk_buff dequeue(sk)
{
spin_lock(sk->sk_receive_queue->lock);
skb = skb_dequeue(sk->sk_receive_queue);
update_statis(sk);
spin_unlock(sk->sk_receive_queue->lock);
return skb;
}
udp_rcv(skb) // 中斷上下文
{
sk = lookup(...);
spin_lock(sk->higher_level_spin_lock);
enqueue(skb, sk);// 見上面的偽代碼
spin_unlock(sk->higher_level_spin_lock);
}

udp_recv(sk, buff) // 進程上下文
{
skb = dequeue(sk); // 見上面的偽代碼
if (skb) {
copy_skb_to_buff(skb, buff);
}
}

簡單直接!Linux內核的UDP處理邏輯在4.10版本也確實去掉了兩級的lock,恢復到了2.6.25內核版本之前的邏輯。

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

上面的優化帶來了可觀的性能收益,但是卻並不值得炫耀。因為上面的優化更像是解決了一個bug,這個bug是在2.6.25版本內核因為借鑑TCP的backlog實現而引入的,而事實上,UDP並不需要這種花哨的backlog邏輯。所以說,上面的效果並非優化而帶來的效果,而是解了一個bug帶來的效果。

但是為什麼遲至4.10版本才發現並解決這個問題的呢?

我想這件事可能跟QUIC有關。用得少的邏輯自然就不容易發現問題,這就好比David Miller在2.6版本內核引入IPv6實現的那幾個bug,就是因為IPv6用的人少,所以一直在很晚的4.23+版本內核才被發現被解決。對於UDP,一直到2.6.24版本其實現都挺好,符合邏輯,2.6.25引入的二級鎖bug同樣是因為UDP本身用的少而沒有被發現。

在QUIC之前,很少有那種有來有回的持續全雙工UDP長連接,基本都是request/response的oneshot類型的連接。然而QUIC卻是類似TCP的全雙工協議,在數據發送端持續發送大塊數據的同時,伴隨著的是接收大量的ACK報文,這顯然和TCP一樣,也是一種反饋控制的方式來驅動數據的發送。

QUIC是有確認機制的,但是處理確認卻不是在內核進行的,內核只是一個快速將確認包收到用戶態QUIC處理進程的一個通路,這個通路越快越好!也就是說,QUIC的ACK報文的接收效率會影響其數據的發送效率。

隨著QUIC的大規模部署,人們才開始逐漸關注其背後UDP的收包效率問題。擺脫了二級鎖的backlog隊列之後,僅僅是為UDP後續的優化掃清了障礙,這才是真正剛剛開始。擺在UDP的內核協議棧收包效率面前的,有一個現成的靶子,那就是DPDK。

挺煩DPDK的,說實話,被人天天說的東西都挺煩。不過你得先把內核協議棧的UDP性能優化到接近DPDK,再把這種鄙視當後話來講才更酷。

由於UDP的處理非常簡單,因此實現一個能和DPDK對接的UDP用戶態協議棧則並不是一件難事。而TCP則相反,它非常複雜,所以DPDK很少有完整處理TCP端到端邏輯的,大多數都只是做類似中間節點DPI這種事。目前都沒有幾個好用的基於DPDK的TCP實現,但是UDP實現卻很多。

DPDK的偽粉絲拿UDP說事的,比拿TCP說事,成本要低很多。好吧,那為什麼DPDK處理UDP收包效率那麼高?答案很簡單, DPDK是在進程上下文輪詢接收UDP數據包的! 也就是說,它擺脫了兩個問題:

  • 進程上下文和中斷上下文操作共享數據的鎖問題;

  • 進程上下文和中斷上下文切換導致的cache miss問題。

這兩點其實也就是 “為什麼內核協議棧性能幹不過用戶態協議棧” 的要點。當然,Linux內核協議棧無法擺脫這兩點問題,也就回答了本文的題目中的第一個問題, “Linux內核UDP收包為什麼效率低” ?

不同的上下文異步操作同一份數據,鎖是必不可少的。關於鎖的話題已經爛大街了。現在僅就cache來討論,中斷上下文和進程上下文之間的切換,也有一個明顯的case:

  • 中斷上下文中修改了socket的元統計數據,該修改會表現在cache中,然而當其wakeup該socket的處理進程後,切換到進程上下文的recv系統調用,其或讀或寫這個統計數據,伴隨著cache的flush以及cache的一致性同步。

如果這些操作統一在進程上下文中進行,cache的利用率將會高效很多。當然,回到UDP收包不合理的backlog隊列機制,其實backlog本身存在的目的之一,就是為了讓進程上下文去處理,以提高cache的利用率,減少不必要的flush。然而,初衷未必能達到效果,在傳輸層用backlog將skb推給進程上下文去處理,已經太晚了,何必不再網卡就給進程上下文呢?就像DPDK那樣。

其實Linux內核社區早就意識到了這兩點,早在3.11版本內核中引入的busy poll機制就是為了解決鎖和切換問題的。busy poll的思想非常簡單,那就是:

  • 不再需要軟中斷上下文往接收隊列裡“推”數據包,而改成自己在進程上下文裡主動從網卡上“拉”數據包。

落實到代碼上,那就是在進程上下文的recvmsg函數中直接調用napi的收包函數,從ring buffer裡拿數據,自己調用netif_receive_skb。

如果busy poll總能執行,它總是能拉取到自己下一個需要的數據包,那麼這基本就是DPDK的效率了,然而和DPDK一樣,這並不是一個統一的解決方案,輪詢固然對於收包有收益,但中斷是不能丟的,用CPU的自旋輪詢換取收包效率,這買賣代價太大,畢竟Linux內核並非專職收包的。

當然了,也許內核態實現協議棧本身就是一種錯誤,但這個話題有點跑偏,畢竟我們就是要優化內核協議棧的,而不是放棄它。

關於這個話題,推薦一篇好文:

千萬級併發實現的祕密:內核不是解決方案,而是問題所在!http://highscalability.com/blog/2013/5/13/the-secret-to-10-million-concurrent-connections-the-kernel-i.html

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

現在,我們不能指望busy poll擔當所有的性能問題,仍然要依靠中斷。既然依靠中斷,鎖的問題就是優化的重點。

以雙核CPU為例,假設CPU0專職處理中斷,而收包進程則綁定在CPU1上,我們很快能意識到, CPU0和CPU1對於每一個skb的enqueue和dequeue均在爭搶socket的sk_receive_queue的spinlock 。

優化措施顯而易見, 將多個skb聚集起來,一次性入接收隊列 。顯然,這需要兩個隊列:

  • 維護聚集隊列:由中斷上下文將skb推入該隊列;

  • 維護接收隊列:進程上下文從該隊列拉取skb;

  • 接收隊列為空時,交換聚集隊列和接收隊列。

這樣,同樣在上述雙核CPU的情況下,只有在上面的第3點的操作中,才需要鎖保護。

考慮到機器的CPU並非雙核,可能是任意核,收包進程也未必綁定任何CPU,因此上述每一個隊列均需要一把鎖保護,無論如何, 和單隊列相比,雙隊列情況下鎖的競爭減少了一半!

collect_enqueue(skb, sk)
{
spin_lock(sk->sk_collect_queue->lock);
skb_queue_tail(sk->sk_collect_queue, skb);
update_statis(sk);
spin_unlock(sk->sk_collect_queue->lock);
}
sk_buff recv_dequeue(sk)
{
spin_lock(sk->sk_receive_queue->lock);
skb = skb_dequeue(sk->sk_receive_queue);
update_statis(sk);
spin_unlock(sk->sk_receive_queue->lock);
return skb;
}
udp_rcv(skb) // 中斷上下文
{
sk = lookup(...);
spin_lock(sk->higher_level_spin_lock);
collect_enqueue(skb, sk);// 僅僅往聚集隊列裡推入。
spin_unlock(sk->higher_level_spin_lock);
}

udp_recv(sk, buff) // 進程上下文
{
if (empty(sk->sk_receive_queue)) {
spin_lock(sk->queues_lock);
swap(sk->sk_receive_queue, sk->sk_collect_queue);
spin_unlock(sk->queues_lock)
}
skb = recv_dequeue(sk); // 僅僅從接收隊列里拉取
if (skb) {
copy_skb_to_buff(skb, buff);
}
}

如此一來,雙隊列解除了中斷上下文和進程上下文之間的鎖競爭。

來看一下對比圖示:

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

引入雙隊列後:

"如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

作者 | dog250

責編 | 郭芮

出品 | CSDN 博客

現在很多人都在詬病Linux內核協議棧收包效率低,不管他們是真的懂還是一點都不懂只是聽別人說的,反正就是在一味地懟Linux內核協議棧,他們的武器貌似只有DPDK。

但是,即便Linux內核協議棧收包效率真的很低,這是為什麼?有沒有辦法去嘗試著優化?而不是動不動就DPDK,一窩蜂跟上去的想法,大部分都是很low的想法。

再次重申,我不寫技術文檔,我也不分析源碼,本文只是一個思考的總結,但凡是思考,那必然是主觀的,不圖精確,甚至不圖正確。

我們從最開始說起。

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

Linux內核作為一個通用操作系統內核,脫胎於UNIX那一套現代操作系統理論。但一開始不知道怎麼回事將網絡協議棧的實現塞進了內核態,從此它就一直在內核態了。既然網絡協議棧的處理在內核態進行,那麼網絡數據包必然是在內核態被處理的。

無論如何,數據包要先進入內核態,這就涉及到了進入內核態的方式:

  • 外部可以從兩個方向進入內核-從用戶態系統調用進入或者從硬件中斷進入。

也就是說,系統在任意時刻,必然處在兩個上下文中的一個:

  • 進程上下文;

  • 中斷上下文(在非中斷線程化的系統,也就是任意進程上下文)。

收包邏輯的協議棧處理顯然是自網卡而上的,它顯然是在中斷上下文中,而數據包往用戶進程的數據接收處理,顯然是在應用程序的進程上下文中, 數據包通過socket在兩個上下文中被轉接。

在socket層的數據包轉接處,必然存在著一個隊列緩存,這是一個典型的生產者-消費者模型,中斷上下文的終點作為生產者將數據包入隊,而進程上下文作為消費者從隊列消費數據包:

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

非常清爽的一個圖,這個圖是兩個上下文接力處理協議棧收包邏輯的必然結果,讓我們加入一些實際必須要考慮的問題後,我們會發現這幅圖並不是那麼清爽,然後再回過頭看如何來優化。

既然兩個上下文都要在任意可能的時刻操作同一個socket進行數據包的轉交,那麼必須有一個同步機制保護socket元數據以及數據包skb本身。

由於Linux內核中斷,軟中斷可能處在任意進程上下文,唯一的同步方案几乎就是spinlock了,於是,真正的圖示應該是下面的樣子:

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

現在可以說,類似上面的這種這種保護是非常必要的,特別是對於TCP而言。

我們知道,TCP是基於事務的有狀態傳輸協議,而且攜帶複雜的流控和擁塞控制機制,這些機制所依託的就是socket當前的一些狀態數據,比如inflight、lost、retrans等等,這些狀態數據在發包和接收ACK/SACK期間會不斷變化,所以說:

  • 在一個上下文完成一次事務傳輸之前,必須鎖定socket狀態數據。

比方說發包流程。數據包的發送可以出現在兩個上下文中:

  • 進程上下文:系統調用觸發的發包;

  • 中斷上下文:ACK/SACK觸發的發包。

任何一個上下文的發包過程必須被TCP協議本身比如擁塞控制,流量控制這些所終止,而不能被中途切換到另一個上下文中,所以必須鎖定。

問題是,上圖中的鎖定是不是太狠了些,中斷上下文自旋時間完全取決於進程上下文的行文,這不利於軟中斷的快速返回,極大地降低了系統的響應度。

於是,需要把鎖的粒度進行細分。Linux內核並沒有在橫向上將鎖的粒度做劃分,而是在縱向上,採用兩個層次的鎖機制:

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

我們看到的Linux內核在處理收包邏輯時的backlog,其實抽象出來就是上面的二級鎖,它是不是很像Windows的IRQL機制呢?伴隨著APC、DPC,你可以把暫時由於高level的IRQL阻滯而無法執行的邏輯放入DPC:

  • 由於進程上下文對socket的low鎖佔有,中斷上下文將skb排入次level的backlog隊列,當進程上下文釋放low鎖的時候,順序執行次level被排入的任務,即處理backlog中的skb。

事實上這是一種非常常見且通用的設計,除了Windows的IRQL,Linux中斷的上半部/下半部也是這種基於思想設計的。

前面說了,TCP的一次事務可能非常複雜耗時,並且必須一次完成,這意味著期間必須持有socket low鎖。以發包邏輯 tcp_write_xmit 函數為例,其內部循環發包,直到受到窗口限制而終止,每一次tcp_transmit_skb返回耗時3微秒~5微秒,平均4微秒,以每次發送4個包為例,在這期間,若使用spinlock,那麼中斷上下文的收包路徑將自旋16微秒,16微秒對於spinlock而言有點久了,於是採用兩級的lock機制,非常有效!

backlog隊列機制有效降低了中斷上下文的spin時延,提高了系統的響應度,非常不錯。但問題是,UDP有必要這樣嗎?

首先,UDP是無狀態的,收包和發包都無需事務,協議棧對UDP的處理,從來都是單個報文粒度的,因此只需要保護唯一的socket接收隊列即可,即 sk_receive_queue 。

enqueue(skb, sk)
{
spin_lock(sk->sk_receive_queue->lock);
skb_queue_tail(sk->sk_receive_queue, skb);
spin_unlock(sk->sk_receive_queue->lock);
}
sk_buff dequeue(sk)
{
spin_lock(sk->sk_receive_queue->lock);
skb = skb_dequeue(sk->sk_receive_queue);
spin_unlock(sk->sk_receive_queue->lock);
return skb;
}

需要保護的接收隊列操作區間都是指令級別的時延,採用一把單一的 sk_receive_queue->lock 足矣。

確實,在Linux 2.6.25版本內核之前,就是這麼幹的。而自從2.2版本內核,TCP就已經採用二級鎖backlog隊列了。

然而,在2.6.25版本內核中,Linux協議棧的UDP收包路徑,轉而採用了兩層鎖的backlog隊列機制,和TCP一樣的邏輯:

low_lock_lock(sk)
{
spin_lock(sk->higher_level_spin_lock); // 熱點!
sk->low_lock_owned_by_process = 1;
spin_unlock(sk->higher_level_spin_lock);
}
low_lock_unlock(sk)
{
spin_lock(sk->higher_level_spin_lock);
sk->low_lock_owned_by_process = 0;
spin_unlock(sk->higher_level_spin_lock);
}

udp_rcv(skb) // 中斷上下文
{
sk = lookup(...);
spin_lock(sk->higher_level_spin_lock); // 熱點!
if (sk->low_lock_owned_by_process) {
enqueue_to_backlog(skb, sk);
} else {
enqueue(skb, sk);// 見上面的偽代碼
update_statis(sk);
wakeup_process(sk);
}
spin_unlock(sk->higher_level_spin_lock);
}

udp_recv(sk, buff) // 進程上下文
{
skb = dequeue(sk); // 見上面的偽代碼
if (skb) {
copy_skb_to_buff(skb, buff);
low_lock_lock(sk);
update_statis(sk);
low_lock_unlock(sk);
dequeue_backlog_to_receive_queue(sk);
}
}

顯然這非常沒有必要。如果你有多個線程同時操作一個UDP socket,將會直面這個熱點,但事實上,你很難遭遇這樣的場景,如果非要說一個,那麼DNS服務器可能首當其中。

之所以在2.6.25版本內核引入了二級鎖backlog隊列,大致是考慮到UDP需要統計內存全局記賬,以防UDP吃盡系統內存,可以review一下 sk_rmem_schedule 函數的邏輯。而在2.6.25版本內核之前,UDP的內存使用是不記賬的,由於UDP本身沒有任何類似流控,擁塞控制之類的約束機制,很容易被惡意程序將系統內存吃盡。

因此,除了sk_receive_queue需要保護,內存記賬邏輯也是需要保護的,比如累加當前skb對內存的佔用到全局數據結構。但即便如此,把這些統計數據的更新都塞入到spinlock的保護區域,也還是要比兩級lock要好。

在我看來,之所以引入二級鎖backlog機制來保護內存記賬邏輯,這是在 借鑑 TCP的代碼,或者說 抄代碼 更直接些。這個攜帶backlog隊列機制的UDP收包代碼存在了好多年,一直在4.9內核才終結。

事實上,僅僅下面的邏輯就可以了:

enqueue(skb, sk)
{
spin_lock(sk->sk_receive_queue->lock);
skb_queue_tail(sk->sk_receive_queue, skb);
update_statis(sk);
spin_unlock(sk->sk_receive_queue->lock);
}
sk_buff dequeue(sk)
{
spin_lock(sk->sk_receive_queue->lock);
skb = skb_dequeue(sk->sk_receive_queue);
update_statis(sk);
spin_unlock(sk->sk_receive_queue->lock);
return skb;
}
udp_rcv(skb) // 中斷上下文
{
sk = lookup(...);
spin_lock(sk->higher_level_spin_lock);
enqueue(skb, sk);// 見上面的偽代碼
spin_unlock(sk->higher_level_spin_lock);
}

udp_recv(sk, buff) // 進程上下文
{
skb = dequeue(sk); // 見上面的偽代碼
if (skb) {
copy_skb_to_buff(skb, buff);
}
}

簡單直接!Linux內核的UDP處理邏輯在4.10版本也確實去掉了兩級的lock,恢復到了2.6.25內核版本之前的邏輯。

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

上面的優化帶來了可觀的性能收益,但是卻並不值得炫耀。因為上面的優化更像是解決了一個bug,這個bug是在2.6.25版本內核因為借鑑TCP的backlog實現而引入的,而事實上,UDP並不需要這種花哨的backlog邏輯。所以說,上面的效果並非優化而帶來的效果,而是解了一個bug帶來的效果。

但是為什麼遲至4.10版本才發現並解決這個問題的呢?

我想這件事可能跟QUIC有關。用得少的邏輯自然就不容易發現問題,這就好比David Miller在2.6版本內核引入IPv6實現的那幾個bug,就是因為IPv6用的人少,所以一直在很晚的4.23+版本內核才被發現被解決。對於UDP,一直到2.6.24版本其實現都挺好,符合邏輯,2.6.25引入的二級鎖bug同樣是因為UDP本身用的少而沒有被發現。

在QUIC之前,很少有那種有來有回的持續全雙工UDP長連接,基本都是request/response的oneshot類型的連接。然而QUIC卻是類似TCP的全雙工協議,在數據發送端持續發送大塊數據的同時,伴隨著的是接收大量的ACK報文,這顯然和TCP一樣,也是一種反饋控制的方式來驅動數據的發送。

QUIC是有確認機制的,但是處理確認卻不是在內核進行的,內核只是一個快速將確認包收到用戶態QUIC處理進程的一個通路,這個通路越快越好!也就是說,QUIC的ACK報文的接收效率會影響其數據的發送效率。

隨著QUIC的大規模部署,人們才開始逐漸關注其背後UDP的收包效率問題。擺脫了二級鎖的backlog隊列之後,僅僅是為UDP後續的優化掃清了障礙,這才是真正剛剛開始。擺在UDP的內核協議棧收包效率面前的,有一個現成的靶子,那就是DPDK。

挺煩DPDK的,說實話,被人天天說的東西都挺煩。不過你得先把內核協議棧的UDP性能優化到接近DPDK,再把這種鄙視當後話來講才更酷。

由於UDP的處理非常簡單,因此實現一個能和DPDK對接的UDP用戶態協議棧則並不是一件難事。而TCP則相反,它非常複雜,所以DPDK很少有完整處理TCP端到端邏輯的,大多數都只是做類似中間節點DPI這種事。目前都沒有幾個好用的基於DPDK的TCP實現,但是UDP實現卻很多。

DPDK的偽粉絲拿UDP說事的,比拿TCP說事,成本要低很多。好吧,那為什麼DPDK處理UDP收包效率那麼高?答案很簡單, DPDK是在進程上下文輪詢接收UDP數據包的! 也就是說,它擺脫了兩個問題:

  • 進程上下文和中斷上下文操作共享數據的鎖問題;

  • 進程上下文和中斷上下文切換導致的cache miss問題。

這兩點其實也就是 “為什麼內核協議棧性能幹不過用戶態協議棧” 的要點。當然,Linux內核協議棧無法擺脫這兩點問題,也就回答了本文的題目中的第一個問題, “Linux內核UDP收包為什麼效率低” ?

不同的上下文異步操作同一份數據,鎖是必不可少的。關於鎖的話題已經爛大街了。現在僅就cache來討論,中斷上下文和進程上下文之間的切換,也有一個明顯的case:

  • 中斷上下文中修改了socket的元統計數據,該修改會表現在cache中,然而當其wakeup該socket的處理進程後,切換到進程上下文的recv系統調用,其或讀或寫這個統計數據,伴隨著cache的flush以及cache的一致性同步。

如果這些操作統一在進程上下文中進行,cache的利用率將會高效很多。當然,回到UDP收包不合理的backlog隊列機制,其實backlog本身存在的目的之一,就是為了讓進程上下文去處理,以提高cache的利用率,減少不必要的flush。然而,初衷未必能達到效果,在傳輸層用backlog將skb推給進程上下文去處理,已經太晚了,何必不再網卡就給進程上下文呢?就像DPDK那樣。

其實Linux內核社區早就意識到了這兩點,早在3.11版本內核中引入的busy poll機制就是為了解決鎖和切換問題的。busy poll的思想非常簡單,那就是:

  • 不再需要軟中斷上下文往接收隊列裡“推”數據包,而改成自己在進程上下文裡主動從網卡上“拉”數據包。

落實到代碼上,那就是在進程上下文的recvmsg函數中直接調用napi的收包函數,從ring buffer裡拿數據,自己調用netif_receive_skb。

如果busy poll總能執行,它總是能拉取到自己下一個需要的數據包,那麼這基本就是DPDK的效率了,然而和DPDK一樣,這並不是一個統一的解決方案,輪詢固然對於收包有收益,但中斷是不能丟的,用CPU的自旋輪詢換取收包效率,這買賣代價太大,畢竟Linux內核並非專職收包的。

當然了,也許內核態實現協議棧本身就是一種錯誤,但這個話題有點跑偏,畢竟我們就是要優化內核協議棧的,而不是放棄它。

關於這個話題,推薦一篇好文:

千萬級併發實現的祕密:內核不是解決方案,而是問題所在!http://highscalability.com/blog/2013/5/13/the-secret-to-10-million-concurrent-connections-the-kernel-i.html

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

現在,我們不能指望busy poll擔當所有的性能問題,仍然要依靠中斷。既然依靠中斷,鎖的問題就是優化的重點。

以雙核CPU為例,假設CPU0專職處理中斷,而收包進程則綁定在CPU1上,我們很快能意識到, CPU0和CPU1對於每一個skb的enqueue和dequeue均在爭搶socket的sk_receive_queue的spinlock 。

優化措施顯而易見, 將多個skb聚集起來,一次性入接收隊列 。顯然,這需要兩個隊列:

  • 維護聚集隊列:由中斷上下文將skb推入該隊列;

  • 維護接收隊列:進程上下文從該隊列拉取skb;

  • 接收隊列為空時,交換聚集隊列和接收隊列。

這樣,同樣在上述雙核CPU的情況下,只有在上面的第3點的操作中,才需要鎖保護。

考慮到機器的CPU並非雙核,可能是任意核,收包進程也未必綁定任何CPU,因此上述每一個隊列均需要一把鎖保護,無論如何, 和單隊列相比,雙隊列情況下鎖的競爭減少了一半!

collect_enqueue(skb, sk)
{
spin_lock(sk->sk_collect_queue->lock);
skb_queue_tail(sk->sk_collect_queue, skb);
update_statis(sk);
spin_unlock(sk->sk_collect_queue->lock);
}
sk_buff recv_dequeue(sk)
{
spin_lock(sk->sk_receive_queue->lock);
skb = skb_dequeue(sk->sk_receive_queue);
update_statis(sk);
spin_unlock(sk->sk_receive_queue->lock);
return skb;
}
udp_rcv(skb) // 中斷上下文
{
sk = lookup(...);
spin_lock(sk->higher_level_spin_lock);
collect_enqueue(skb, sk);// 僅僅往聚集隊列裡推入。
spin_unlock(sk->higher_level_spin_lock);
}

udp_recv(sk, buff) // 進程上下文
{
if (empty(sk->sk_receive_queue)) {
spin_lock(sk->queues_lock);
swap(sk->sk_receive_queue, sk->sk_collect_queue);
spin_unlock(sk->queues_lock)
}
skb = recv_dequeue(sk); // 僅僅從接收隊列里拉取
if (skb) {
copy_skb_to_buff(skb, buff);
}
}

如此一來,雙隊列解除了中斷上下文和進程上下文之間的鎖競爭。

來看一下對比圖示:

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

引入雙隊列後:

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

即便已經很不錯了,但是:

  • 中斷上下文中不同CPU可能會收到同一個socket的skb,CPU依然會在聚集隊列的鎖上蹦躂;

  • 不同的CPU上的進程也可能會處理同一個socket,本意是合作,卻需要接收隊列的鎖來將其操作串行化。

沒辦法,通用的操作系統內核只能做到這裡了,如果要解決以上的問題,就需要按照任何和角色明確綁CPU核心了,然而這也就不再是通用的內核了。最終,你會在內核裡聞到DPDK的腐臭味,超級糟心。

對了,我暫且將雙隊列區分為了聚集隊列和接收隊列 ,更好的名字可能是backlog隊列和接收隊列 。中斷上下文總是操作backlog隊列,而進程上下文在接收隊列為空時,交換backlog隊列為接收隊列。然而,backlog隊列這個名字在我看來非常臭名昭著,所以,暫且不用它了。

我想本文應該就要結束了,確實沒有源碼分析,事實上,我覺得我寫的這篇要比下面的這種有意思的多,然而可能在網上能找到的基本都是這種非常詳細的源碼分析:

...
bh_lock_sock(sk); // 鎖定住sk
if (!sock_owned_by_user(sk)) // 判斷sk是不是被用戶進程所擁有,如果沒有被擁有的話。
rc = __udp_queue_rcv_skb(sk, skb); // 直接調用__udp_queue_rcv_skb
else if (sk_add_backlog(sk, skb, sk->sk_rcvbuf)) { //否則調用sk_add_backlog將skb放入backlog
bh_unlock_sock(sk); // 如果失敗,解鎖sk,直接丟包
goto drop;
}
bh_unlock_sock(sk); // 解鎖sk

return rc; // 返回rc
...

哈哈…

我為什麼沒有談UDP的GRO、LRO機制?因為太不通用了。但是另一方面,如果應用程序加以稍微支持,UDP的GRO、LRO將會帶來非常可觀的收益,別忘了,內核只是UDP報文的一個通路即可,既然是通路,它便不包含處理邏輯,越快通過,越好。如果你在乎高吞吐,那麼就GRO唄,如下:

"如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

作者 | dog250

責編 | 郭芮

出品 | CSDN 博客

現在很多人都在詬病Linux內核協議棧收包效率低,不管他們是真的懂還是一點都不懂只是聽別人說的,反正就是在一味地懟Linux內核協議棧,他們的武器貌似只有DPDK。

但是,即便Linux內核協議棧收包效率真的很低,這是為什麼?有沒有辦法去嘗試著優化?而不是動不動就DPDK,一窩蜂跟上去的想法,大部分都是很low的想法。

再次重申,我不寫技術文檔,我也不分析源碼,本文只是一個思考的總結,但凡是思考,那必然是主觀的,不圖精確,甚至不圖正確。

我們從最開始說起。

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

Linux內核作為一個通用操作系統內核,脫胎於UNIX那一套現代操作系統理論。但一開始不知道怎麼回事將網絡協議棧的實現塞進了內核態,從此它就一直在內核態了。既然網絡協議棧的處理在內核態進行,那麼網絡數據包必然是在內核態被處理的。

無論如何,數據包要先進入內核態,這就涉及到了進入內核態的方式:

  • 外部可以從兩個方向進入內核-從用戶態系統調用進入或者從硬件中斷進入。

也就是說,系統在任意時刻,必然處在兩個上下文中的一個:

  • 進程上下文;

  • 中斷上下文(在非中斷線程化的系統,也就是任意進程上下文)。

收包邏輯的協議棧處理顯然是自網卡而上的,它顯然是在中斷上下文中,而數據包往用戶進程的數據接收處理,顯然是在應用程序的進程上下文中, 數據包通過socket在兩個上下文中被轉接。

在socket層的數據包轉接處,必然存在著一個隊列緩存,這是一個典型的生產者-消費者模型,中斷上下文的終點作為生產者將數據包入隊,而進程上下文作為消費者從隊列消費數據包:

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

非常清爽的一個圖,這個圖是兩個上下文接力處理協議棧收包邏輯的必然結果,讓我們加入一些實際必須要考慮的問題後,我們會發現這幅圖並不是那麼清爽,然後再回過頭看如何來優化。

既然兩個上下文都要在任意可能的時刻操作同一個socket進行數據包的轉交,那麼必須有一個同步機制保護socket元數據以及數據包skb本身。

由於Linux內核中斷,軟中斷可能處在任意進程上下文,唯一的同步方案几乎就是spinlock了,於是,真正的圖示應該是下面的樣子:

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

現在可以說,類似上面的這種這種保護是非常必要的,特別是對於TCP而言。

我們知道,TCP是基於事務的有狀態傳輸協議,而且攜帶複雜的流控和擁塞控制機制,這些機制所依託的就是socket當前的一些狀態數據,比如inflight、lost、retrans等等,這些狀態數據在發包和接收ACK/SACK期間會不斷變化,所以說:

  • 在一個上下文完成一次事務傳輸之前,必須鎖定socket狀態數據。

比方說發包流程。數據包的發送可以出現在兩個上下文中:

  • 進程上下文:系統調用觸發的發包;

  • 中斷上下文:ACK/SACK觸發的發包。

任何一個上下文的發包過程必須被TCP協議本身比如擁塞控制,流量控制這些所終止,而不能被中途切換到另一個上下文中,所以必須鎖定。

問題是,上圖中的鎖定是不是太狠了些,中斷上下文自旋時間完全取決於進程上下文的行文,這不利於軟中斷的快速返回,極大地降低了系統的響應度。

於是,需要把鎖的粒度進行細分。Linux內核並沒有在橫向上將鎖的粒度做劃分,而是在縱向上,採用兩個層次的鎖機制:

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

我們看到的Linux內核在處理收包邏輯時的backlog,其實抽象出來就是上面的二級鎖,它是不是很像Windows的IRQL機制呢?伴隨著APC、DPC,你可以把暫時由於高level的IRQL阻滯而無法執行的邏輯放入DPC:

  • 由於進程上下文對socket的low鎖佔有,中斷上下文將skb排入次level的backlog隊列,當進程上下文釋放low鎖的時候,順序執行次level被排入的任務,即處理backlog中的skb。

事實上這是一種非常常見且通用的設計,除了Windows的IRQL,Linux中斷的上半部/下半部也是這種基於思想設計的。

前面說了,TCP的一次事務可能非常複雜耗時,並且必須一次完成,這意味著期間必須持有socket low鎖。以發包邏輯 tcp_write_xmit 函數為例,其內部循環發包,直到受到窗口限制而終止,每一次tcp_transmit_skb返回耗時3微秒~5微秒,平均4微秒,以每次發送4個包為例,在這期間,若使用spinlock,那麼中斷上下文的收包路徑將自旋16微秒,16微秒對於spinlock而言有點久了,於是採用兩級的lock機制,非常有效!

backlog隊列機制有效降低了中斷上下文的spin時延,提高了系統的響應度,非常不錯。但問題是,UDP有必要這樣嗎?

首先,UDP是無狀態的,收包和發包都無需事務,協議棧對UDP的處理,從來都是單個報文粒度的,因此只需要保護唯一的socket接收隊列即可,即 sk_receive_queue 。

enqueue(skb, sk)
{
spin_lock(sk->sk_receive_queue->lock);
skb_queue_tail(sk->sk_receive_queue, skb);
spin_unlock(sk->sk_receive_queue->lock);
}
sk_buff dequeue(sk)
{
spin_lock(sk->sk_receive_queue->lock);
skb = skb_dequeue(sk->sk_receive_queue);
spin_unlock(sk->sk_receive_queue->lock);
return skb;
}

需要保護的接收隊列操作區間都是指令級別的時延,採用一把單一的 sk_receive_queue->lock 足矣。

確實,在Linux 2.6.25版本內核之前,就是這麼幹的。而自從2.2版本內核,TCP就已經採用二級鎖backlog隊列了。

然而,在2.6.25版本內核中,Linux協議棧的UDP收包路徑,轉而採用了兩層鎖的backlog隊列機制,和TCP一樣的邏輯:

low_lock_lock(sk)
{
spin_lock(sk->higher_level_spin_lock); // 熱點!
sk->low_lock_owned_by_process = 1;
spin_unlock(sk->higher_level_spin_lock);
}
low_lock_unlock(sk)
{
spin_lock(sk->higher_level_spin_lock);
sk->low_lock_owned_by_process = 0;
spin_unlock(sk->higher_level_spin_lock);
}

udp_rcv(skb) // 中斷上下文
{
sk = lookup(...);
spin_lock(sk->higher_level_spin_lock); // 熱點!
if (sk->low_lock_owned_by_process) {
enqueue_to_backlog(skb, sk);
} else {
enqueue(skb, sk);// 見上面的偽代碼
update_statis(sk);
wakeup_process(sk);
}
spin_unlock(sk->higher_level_spin_lock);
}

udp_recv(sk, buff) // 進程上下文
{
skb = dequeue(sk); // 見上面的偽代碼
if (skb) {
copy_skb_to_buff(skb, buff);
low_lock_lock(sk);
update_statis(sk);
low_lock_unlock(sk);
dequeue_backlog_to_receive_queue(sk);
}
}

顯然這非常沒有必要。如果你有多個線程同時操作一個UDP socket,將會直面這個熱點,但事實上,你很難遭遇這樣的場景,如果非要說一個,那麼DNS服務器可能首當其中。

之所以在2.6.25版本內核引入了二級鎖backlog隊列,大致是考慮到UDP需要統計內存全局記賬,以防UDP吃盡系統內存,可以review一下 sk_rmem_schedule 函數的邏輯。而在2.6.25版本內核之前,UDP的內存使用是不記賬的,由於UDP本身沒有任何類似流控,擁塞控制之類的約束機制,很容易被惡意程序將系統內存吃盡。

因此,除了sk_receive_queue需要保護,內存記賬邏輯也是需要保護的,比如累加當前skb對內存的佔用到全局數據結構。但即便如此,把這些統計數據的更新都塞入到spinlock的保護區域,也還是要比兩級lock要好。

在我看來,之所以引入二級鎖backlog機制來保護內存記賬邏輯,這是在 借鑑 TCP的代碼,或者說 抄代碼 更直接些。這個攜帶backlog隊列機制的UDP收包代碼存在了好多年,一直在4.9內核才終結。

事實上,僅僅下面的邏輯就可以了:

enqueue(skb, sk)
{
spin_lock(sk->sk_receive_queue->lock);
skb_queue_tail(sk->sk_receive_queue, skb);
update_statis(sk);
spin_unlock(sk->sk_receive_queue->lock);
}
sk_buff dequeue(sk)
{
spin_lock(sk->sk_receive_queue->lock);
skb = skb_dequeue(sk->sk_receive_queue);
update_statis(sk);
spin_unlock(sk->sk_receive_queue->lock);
return skb;
}
udp_rcv(skb) // 中斷上下文
{
sk = lookup(...);
spin_lock(sk->higher_level_spin_lock);
enqueue(skb, sk);// 見上面的偽代碼
spin_unlock(sk->higher_level_spin_lock);
}

udp_recv(sk, buff) // 進程上下文
{
skb = dequeue(sk); // 見上面的偽代碼
if (skb) {
copy_skb_to_buff(skb, buff);
}
}

簡單直接!Linux內核的UDP處理邏輯在4.10版本也確實去掉了兩級的lock,恢復到了2.6.25內核版本之前的邏輯。

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

上面的優化帶來了可觀的性能收益,但是卻並不值得炫耀。因為上面的優化更像是解決了一個bug,這個bug是在2.6.25版本內核因為借鑑TCP的backlog實現而引入的,而事實上,UDP並不需要這種花哨的backlog邏輯。所以說,上面的效果並非優化而帶來的效果,而是解了一個bug帶來的效果。

但是為什麼遲至4.10版本才發現並解決這個問題的呢?

我想這件事可能跟QUIC有關。用得少的邏輯自然就不容易發現問題,這就好比David Miller在2.6版本內核引入IPv6實現的那幾個bug,就是因為IPv6用的人少,所以一直在很晚的4.23+版本內核才被發現被解決。對於UDP,一直到2.6.24版本其實現都挺好,符合邏輯,2.6.25引入的二級鎖bug同樣是因為UDP本身用的少而沒有被發現。

在QUIC之前,很少有那種有來有回的持續全雙工UDP長連接,基本都是request/response的oneshot類型的連接。然而QUIC卻是類似TCP的全雙工協議,在數據發送端持續發送大塊數據的同時,伴隨著的是接收大量的ACK報文,這顯然和TCP一樣,也是一種反饋控制的方式來驅動數據的發送。

QUIC是有確認機制的,但是處理確認卻不是在內核進行的,內核只是一個快速將確認包收到用戶態QUIC處理進程的一個通路,這個通路越快越好!也就是說,QUIC的ACK報文的接收效率會影響其數據的發送效率。

隨著QUIC的大規模部署,人們才開始逐漸關注其背後UDP的收包效率問題。擺脫了二級鎖的backlog隊列之後,僅僅是為UDP後續的優化掃清了障礙,這才是真正剛剛開始。擺在UDP的內核協議棧收包效率面前的,有一個現成的靶子,那就是DPDK。

挺煩DPDK的,說實話,被人天天說的東西都挺煩。不過你得先把內核協議棧的UDP性能優化到接近DPDK,再把這種鄙視當後話來講才更酷。

由於UDP的處理非常簡單,因此實現一個能和DPDK對接的UDP用戶態協議棧則並不是一件難事。而TCP則相反,它非常複雜,所以DPDK很少有完整處理TCP端到端邏輯的,大多數都只是做類似中間節點DPI這種事。目前都沒有幾個好用的基於DPDK的TCP實現,但是UDP實現卻很多。

DPDK的偽粉絲拿UDP說事的,比拿TCP說事,成本要低很多。好吧,那為什麼DPDK處理UDP收包效率那麼高?答案很簡單, DPDK是在進程上下文輪詢接收UDP數據包的! 也就是說,它擺脫了兩個問題:

  • 進程上下文和中斷上下文操作共享數據的鎖問題;

  • 進程上下文和中斷上下文切換導致的cache miss問題。

這兩點其實也就是 “為什麼內核協議棧性能幹不過用戶態協議棧” 的要點。當然,Linux內核協議棧無法擺脫這兩點問題,也就回答了本文的題目中的第一個問題, “Linux內核UDP收包為什麼效率低” ?

不同的上下文異步操作同一份數據,鎖是必不可少的。關於鎖的話題已經爛大街了。現在僅就cache來討論,中斷上下文和進程上下文之間的切換,也有一個明顯的case:

  • 中斷上下文中修改了socket的元統計數據,該修改會表現在cache中,然而當其wakeup該socket的處理進程後,切換到進程上下文的recv系統調用,其或讀或寫這個統計數據,伴隨著cache的flush以及cache的一致性同步。

如果這些操作統一在進程上下文中進行,cache的利用率將會高效很多。當然,回到UDP收包不合理的backlog隊列機制,其實backlog本身存在的目的之一,就是為了讓進程上下文去處理,以提高cache的利用率,減少不必要的flush。然而,初衷未必能達到效果,在傳輸層用backlog將skb推給進程上下文去處理,已經太晚了,何必不再網卡就給進程上下文呢?就像DPDK那樣。

其實Linux內核社區早就意識到了這兩點,早在3.11版本內核中引入的busy poll機制就是為了解決鎖和切換問題的。busy poll的思想非常簡單,那就是:

  • 不再需要軟中斷上下文往接收隊列裡“推”數據包,而改成自己在進程上下文裡主動從網卡上“拉”數據包。

落實到代碼上,那就是在進程上下文的recvmsg函數中直接調用napi的收包函數,從ring buffer裡拿數據,自己調用netif_receive_skb。

如果busy poll總能執行,它總是能拉取到自己下一個需要的數據包,那麼這基本就是DPDK的效率了,然而和DPDK一樣,這並不是一個統一的解決方案,輪詢固然對於收包有收益,但中斷是不能丟的,用CPU的自旋輪詢換取收包效率,這買賣代價太大,畢竟Linux內核並非專職收包的。

當然了,也許內核態實現協議棧本身就是一種錯誤,但這個話題有點跑偏,畢竟我們就是要優化內核協議棧的,而不是放棄它。

關於這個話題,推薦一篇好文:

千萬級併發實現的祕密:內核不是解決方案,而是問題所在!http://highscalability.com/blog/2013/5/13/the-secret-to-10-million-concurrent-connections-the-kernel-i.html

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

現在,我們不能指望busy poll擔當所有的性能問題,仍然要依靠中斷。既然依靠中斷,鎖的問題就是優化的重點。

以雙核CPU為例,假設CPU0專職處理中斷,而收包進程則綁定在CPU1上,我們很快能意識到, CPU0和CPU1對於每一個skb的enqueue和dequeue均在爭搶socket的sk_receive_queue的spinlock 。

優化措施顯而易見, 將多個skb聚集起來,一次性入接收隊列 。顯然,這需要兩個隊列:

  • 維護聚集隊列:由中斷上下文將skb推入該隊列;

  • 維護接收隊列:進程上下文從該隊列拉取skb;

  • 接收隊列為空時,交換聚集隊列和接收隊列。

這樣,同樣在上述雙核CPU的情況下,只有在上面的第3點的操作中,才需要鎖保護。

考慮到機器的CPU並非雙核,可能是任意核,收包進程也未必綁定任何CPU,因此上述每一個隊列均需要一把鎖保護,無論如何, 和單隊列相比,雙隊列情況下鎖的競爭減少了一半!

collect_enqueue(skb, sk)
{
spin_lock(sk->sk_collect_queue->lock);
skb_queue_tail(sk->sk_collect_queue, skb);
update_statis(sk);
spin_unlock(sk->sk_collect_queue->lock);
}
sk_buff recv_dequeue(sk)
{
spin_lock(sk->sk_receive_queue->lock);
skb = skb_dequeue(sk->sk_receive_queue);
update_statis(sk);
spin_unlock(sk->sk_receive_queue->lock);
return skb;
}
udp_rcv(skb) // 中斷上下文
{
sk = lookup(...);
spin_lock(sk->higher_level_spin_lock);
collect_enqueue(skb, sk);// 僅僅往聚集隊列裡推入。
spin_unlock(sk->higher_level_spin_lock);
}

udp_recv(sk, buff) // 進程上下文
{
if (empty(sk->sk_receive_queue)) {
spin_lock(sk->queues_lock);
swap(sk->sk_receive_queue, sk->sk_collect_queue);
spin_unlock(sk->queues_lock)
}
skb = recv_dequeue(sk); // 僅僅從接收隊列里拉取
if (skb) {
copy_skb_to_buff(skb, buff);
}
}

如此一來,雙隊列解除了中斷上下文和進程上下文之間的鎖競爭。

來看一下對比圖示:

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

引入雙隊列後:

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

即便已經很不錯了,但是:

  • 中斷上下文中不同CPU可能會收到同一個socket的skb,CPU依然會在聚集隊列的鎖上蹦躂;

  • 不同的CPU上的進程也可能會處理同一個socket,本意是合作,卻需要接收隊列的鎖來將其操作串行化。

沒辦法,通用的操作系統內核只能做到這裡了,如果要解決以上的問題,就需要按照任何和角色明確綁CPU核心了,然而這也就不再是通用的內核了。最終,你會在內核裡聞到DPDK的腐臭味,超級糟心。

對了,我暫且將雙隊列區分為了聚集隊列和接收隊列 ,更好的名字可能是backlog隊列和接收隊列 。中斷上下文總是操作backlog隊列,而進程上下文在接收隊列為空時,交換backlog隊列為接收隊列。然而,backlog隊列這個名字在我看來非常臭名昭著,所以,暫且不用它了。

我想本文應該就要結束了,確實沒有源碼分析,事實上,我覺得我寫的這篇要比下面的這種有意思的多,然而可能在網上能找到的基本都是這種非常詳細的源碼分析:

...
bh_lock_sock(sk); // 鎖定住sk
if (!sock_owned_by_user(sk)) // 判斷sk是不是被用戶進程所擁有,如果沒有被擁有的話。
rc = __udp_queue_rcv_skb(sk, skb); // 直接調用__udp_queue_rcv_skb
else if (sk_add_backlog(sk, skb, sk->sk_rcvbuf)) { //否則調用sk_add_backlog將skb放入backlog
bh_unlock_sock(sk); // 如果失敗,解鎖sk,直接丟包
goto drop;
}
bh_unlock_sock(sk); // 解鎖sk

return rc; // 返回rc
...

哈哈…

我為什麼沒有談UDP的GRO、LRO機制?因為太不通用了。但是另一方面,如果應用程序加以稍微支持,UDP的GRO、LRO將會帶來非常可觀的收益,別忘了,內核只是UDP報文的一個通路即可,既然是通路,它便不包含處理邏輯,越快通過,越好。如果你在乎高吞吐,那麼就GRO唄,如下:

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

UDP的通用L4 GRO相當於一個非常簡單的5層協議,應用程序按照len字段稍加解析拆分即可,這將極大減少系統調用的次數,減少上下文切換帶來的cache miss損耗。

"如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

作者 | dog250

責編 | 郭芮

出品 | CSDN 博客

現在很多人都在詬病Linux內核協議棧收包效率低,不管他們是真的懂還是一點都不懂只是聽別人說的,反正就是在一味地懟Linux內核協議棧,他們的武器貌似只有DPDK。

但是,即便Linux內核協議棧收包效率真的很低,這是為什麼?有沒有辦法去嘗試著優化?而不是動不動就DPDK,一窩蜂跟上去的想法,大部分都是很low的想法。

再次重申,我不寫技術文檔,我也不分析源碼,本文只是一個思考的總結,但凡是思考,那必然是主觀的,不圖精確,甚至不圖正確。

我們從最開始說起。

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

Linux內核作為一個通用操作系統內核,脫胎於UNIX那一套現代操作系統理論。但一開始不知道怎麼回事將網絡協議棧的實現塞進了內核態,從此它就一直在內核態了。既然網絡協議棧的處理在內核態進行,那麼網絡數據包必然是在內核態被處理的。

無論如何,數據包要先進入內核態,這就涉及到了進入內核態的方式:

  • 外部可以從兩個方向進入內核-從用戶態系統調用進入或者從硬件中斷進入。

也就是說,系統在任意時刻,必然處在兩個上下文中的一個:

  • 進程上下文;

  • 中斷上下文(在非中斷線程化的系統,也就是任意進程上下文)。

收包邏輯的協議棧處理顯然是自網卡而上的,它顯然是在中斷上下文中,而數據包往用戶進程的數據接收處理,顯然是在應用程序的進程上下文中, 數據包通過socket在兩個上下文中被轉接。

在socket層的數據包轉接處,必然存在著一個隊列緩存,這是一個典型的生產者-消費者模型,中斷上下文的終點作為生產者將數據包入隊,而進程上下文作為消費者從隊列消費數據包:

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

非常清爽的一個圖,這個圖是兩個上下文接力處理協議棧收包邏輯的必然結果,讓我們加入一些實際必須要考慮的問題後,我們會發現這幅圖並不是那麼清爽,然後再回過頭看如何來優化。

既然兩個上下文都要在任意可能的時刻操作同一個socket進行數據包的轉交,那麼必須有一個同步機制保護socket元數據以及數據包skb本身。

由於Linux內核中斷,軟中斷可能處在任意進程上下文,唯一的同步方案几乎就是spinlock了,於是,真正的圖示應該是下面的樣子:

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

現在可以說,類似上面的這種這種保護是非常必要的,特別是對於TCP而言。

我們知道,TCP是基於事務的有狀態傳輸協議,而且攜帶複雜的流控和擁塞控制機制,這些機制所依託的就是socket當前的一些狀態數據,比如inflight、lost、retrans等等,這些狀態數據在發包和接收ACK/SACK期間會不斷變化,所以說:

  • 在一個上下文完成一次事務傳輸之前,必須鎖定socket狀態數據。

比方說發包流程。數據包的發送可以出現在兩個上下文中:

  • 進程上下文:系統調用觸發的發包;

  • 中斷上下文:ACK/SACK觸發的發包。

任何一個上下文的發包過程必須被TCP協議本身比如擁塞控制,流量控制這些所終止,而不能被中途切換到另一個上下文中,所以必須鎖定。

問題是,上圖中的鎖定是不是太狠了些,中斷上下文自旋時間完全取決於進程上下文的行文,這不利於軟中斷的快速返回,極大地降低了系統的響應度。

於是,需要把鎖的粒度進行細分。Linux內核並沒有在橫向上將鎖的粒度做劃分,而是在縱向上,採用兩個層次的鎖機制:

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

我們看到的Linux內核在處理收包邏輯時的backlog,其實抽象出來就是上面的二級鎖,它是不是很像Windows的IRQL機制呢?伴隨著APC、DPC,你可以把暫時由於高level的IRQL阻滯而無法執行的邏輯放入DPC:

  • 由於進程上下文對socket的low鎖佔有,中斷上下文將skb排入次level的backlog隊列,當進程上下文釋放low鎖的時候,順序執行次level被排入的任務,即處理backlog中的skb。

事實上這是一種非常常見且通用的設計,除了Windows的IRQL,Linux中斷的上半部/下半部也是這種基於思想設計的。

前面說了,TCP的一次事務可能非常複雜耗時,並且必須一次完成,這意味著期間必須持有socket low鎖。以發包邏輯 tcp_write_xmit 函數為例,其內部循環發包,直到受到窗口限制而終止,每一次tcp_transmit_skb返回耗時3微秒~5微秒,平均4微秒,以每次發送4個包為例,在這期間,若使用spinlock,那麼中斷上下文的收包路徑將自旋16微秒,16微秒對於spinlock而言有點久了,於是採用兩級的lock機制,非常有效!

backlog隊列機制有效降低了中斷上下文的spin時延,提高了系統的響應度,非常不錯。但問題是,UDP有必要這樣嗎?

首先,UDP是無狀態的,收包和發包都無需事務,協議棧對UDP的處理,從來都是單個報文粒度的,因此只需要保護唯一的socket接收隊列即可,即 sk_receive_queue 。

enqueue(skb, sk)
{
spin_lock(sk->sk_receive_queue->lock);
skb_queue_tail(sk->sk_receive_queue, skb);
spin_unlock(sk->sk_receive_queue->lock);
}
sk_buff dequeue(sk)
{
spin_lock(sk->sk_receive_queue->lock);
skb = skb_dequeue(sk->sk_receive_queue);
spin_unlock(sk->sk_receive_queue->lock);
return skb;
}

需要保護的接收隊列操作區間都是指令級別的時延,採用一把單一的 sk_receive_queue->lock 足矣。

確實,在Linux 2.6.25版本內核之前,就是這麼幹的。而自從2.2版本內核,TCP就已經採用二級鎖backlog隊列了。

然而,在2.6.25版本內核中,Linux協議棧的UDP收包路徑,轉而採用了兩層鎖的backlog隊列機制,和TCP一樣的邏輯:

low_lock_lock(sk)
{
spin_lock(sk->higher_level_spin_lock); // 熱點!
sk->low_lock_owned_by_process = 1;
spin_unlock(sk->higher_level_spin_lock);
}
low_lock_unlock(sk)
{
spin_lock(sk->higher_level_spin_lock);
sk->low_lock_owned_by_process = 0;
spin_unlock(sk->higher_level_spin_lock);
}

udp_rcv(skb) // 中斷上下文
{
sk = lookup(...);
spin_lock(sk->higher_level_spin_lock); // 熱點!
if (sk->low_lock_owned_by_process) {
enqueue_to_backlog(skb, sk);
} else {
enqueue(skb, sk);// 見上面的偽代碼
update_statis(sk);
wakeup_process(sk);
}
spin_unlock(sk->higher_level_spin_lock);
}

udp_recv(sk, buff) // 進程上下文
{
skb = dequeue(sk); // 見上面的偽代碼
if (skb) {
copy_skb_to_buff(skb, buff);
low_lock_lock(sk);
update_statis(sk);
low_lock_unlock(sk);
dequeue_backlog_to_receive_queue(sk);
}
}

顯然這非常沒有必要。如果你有多個線程同時操作一個UDP socket,將會直面這個熱點,但事實上,你很難遭遇這樣的場景,如果非要說一個,那麼DNS服務器可能首當其中。

之所以在2.6.25版本內核引入了二級鎖backlog隊列,大致是考慮到UDP需要統計內存全局記賬,以防UDP吃盡系統內存,可以review一下 sk_rmem_schedule 函數的邏輯。而在2.6.25版本內核之前,UDP的內存使用是不記賬的,由於UDP本身沒有任何類似流控,擁塞控制之類的約束機制,很容易被惡意程序將系統內存吃盡。

因此,除了sk_receive_queue需要保護,內存記賬邏輯也是需要保護的,比如累加當前skb對內存的佔用到全局數據結構。但即便如此,把這些統計數據的更新都塞入到spinlock的保護區域,也還是要比兩級lock要好。

在我看來,之所以引入二級鎖backlog機制來保護內存記賬邏輯,這是在 借鑑 TCP的代碼,或者說 抄代碼 更直接些。這個攜帶backlog隊列機制的UDP收包代碼存在了好多年,一直在4.9內核才終結。

事實上,僅僅下面的邏輯就可以了:

enqueue(skb, sk)
{
spin_lock(sk->sk_receive_queue->lock);
skb_queue_tail(sk->sk_receive_queue, skb);
update_statis(sk);
spin_unlock(sk->sk_receive_queue->lock);
}
sk_buff dequeue(sk)
{
spin_lock(sk->sk_receive_queue->lock);
skb = skb_dequeue(sk->sk_receive_queue);
update_statis(sk);
spin_unlock(sk->sk_receive_queue->lock);
return skb;
}
udp_rcv(skb) // 中斷上下文
{
sk = lookup(...);
spin_lock(sk->higher_level_spin_lock);
enqueue(skb, sk);// 見上面的偽代碼
spin_unlock(sk->higher_level_spin_lock);
}

udp_recv(sk, buff) // 進程上下文
{
skb = dequeue(sk); // 見上面的偽代碼
if (skb) {
copy_skb_to_buff(skb, buff);
}
}

簡單直接!Linux內核的UDP處理邏輯在4.10版本也確實去掉了兩級的lock,恢復到了2.6.25內核版本之前的邏輯。

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

上面的優化帶來了可觀的性能收益,但是卻並不值得炫耀。因為上面的優化更像是解決了一個bug,這個bug是在2.6.25版本內核因為借鑑TCP的backlog實現而引入的,而事實上,UDP並不需要這種花哨的backlog邏輯。所以說,上面的效果並非優化而帶來的效果,而是解了一個bug帶來的效果。

但是為什麼遲至4.10版本才發現並解決這個問題的呢?

我想這件事可能跟QUIC有關。用得少的邏輯自然就不容易發現問題,這就好比David Miller在2.6版本內核引入IPv6實現的那幾個bug,就是因為IPv6用的人少,所以一直在很晚的4.23+版本內核才被發現被解決。對於UDP,一直到2.6.24版本其實現都挺好,符合邏輯,2.6.25引入的二級鎖bug同樣是因為UDP本身用的少而沒有被發現。

在QUIC之前,很少有那種有來有回的持續全雙工UDP長連接,基本都是request/response的oneshot類型的連接。然而QUIC卻是類似TCP的全雙工協議,在數據發送端持續發送大塊數據的同時,伴隨著的是接收大量的ACK報文,這顯然和TCP一樣,也是一種反饋控制的方式來驅動數據的發送。

QUIC是有確認機制的,但是處理確認卻不是在內核進行的,內核只是一個快速將確認包收到用戶態QUIC處理進程的一個通路,這個通路越快越好!也就是說,QUIC的ACK報文的接收效率會影響其數據的發送效率。

隨著QUIC的大規模部署,人們才開始逐漸關注其背後UDP的收包效率問題。擺脫了二級鎖的backlog隊列之後,僅僅是為UDP後續的優化掃清了障礙,這才是真正剛剛開始。擺在UDP的內核協議棧收包效率面前的,有一個現成的靶子,那就是DPDK。

挺煩DPDK的,說實話,被人天天說的東西都挺煩。不過你得先把內核協議棧的UDP性能優化到接近DPDK,再把這種鄙視當後話來講才更酷。

由於UDP的處理非常簡單,因此實現一個能和DPDK對接的UDP用戶態協議棧則並不是一件難事。而TCP則相反,它非常複雜,所以DPDK很少有完整處理TCP端到端邏輯的,大多數都只是做類似中間節點DPI這種事。目前都沒有幾個好用的基於DPDK的TCP實現,但是UDP實現卻很多。

DPDK的偽粉絲拿UDP說事的,比拿TCP說事,成本要低很多。好吧,那為什麼DPDK處理UDP收包效率那麼高?答案很簡單, DPDK是在進程上下文輪詢接收UDP數據包的! 也就是說,它擺脫了兩個問題:

  • 進程上下文和中斷上下文操作共享數據的鎖問題;

  • 進程上下文和中斷上下文切換導致的cache miss問題。

這兩點其實也就是 “為什麼內核協議棧性能幹不過用戶態協議棧” 的要點。當然,Linux內核協議棧無法擺脫這兩點問題,也就回答了本文的題目中的第一個問題, “Linux內核UDP收包為什麼效率低” ?

不同的上下文異步操作同一份數據,鎖是必不可少的。關於鎖的話題已經爛大街了。現在僅就cache來討論,中斷上下文和進程上下文之間的切換,也有一個明顯的case:

  • 中斷上下文中修改了socket的元統計數據,該修改會表現在cache中,然而當其wakeup該socket的處理進程後,切換到進程上下文的recv系統調用,其或讀或寫這個統計數據,伴隨著cache的flush以及cache的一致性同步。

如果這些操作統一在進程上下文中進行,cache的利用率將會高效很多。當然,回到UDP收包不合理的backlog隊列機制,其實backlog本身存在的目的之一,就是為了讓進程上下文去處理,以提高cache的利用率,減少不必要的flush。然而,初衷未必能達到效果,在傳輸層用backlog將skb推給進程上下文去處理,已經太晚了,何必不再網卡就給進程上下文呢?就像DPDK那樣。

其實Linux內核社區早就意識到了這兩點,早在3.11版本內核中引入的busy poll機制就是為了解決鎖和切換問題的。busy poll的思想非常簡單,那就是:

  • 不再需要軟中斷上下文往接收隊列裡“推”數據包,而改成自己在進程上下文裡主動從網卡上“拉”數據包。

落實到代碼上,那就是在進程上下文的recvmsg函數中直接調用napi的收包函數,從ring buffer裡拿數據,自己調用netif_receive_skb。

如果busy poll總能執行,它總是能拉取到自己下一個需要的數據包,那麼這基本就是DPDK的效率了,然而和DPDK一樣,這並不是一個統一的解決方案,輪詢固然對於收包有收益,但中斷是不能丟的,用CPU的自旋輪詢換取收包效率,這買賣代價太大,畢竟Linux內核並非專職收包的。

當然了,也許內核態實現協議棧本身就是一種錯誤,但這個話題有點跑偏,畢竟我們就是要優化內核協議棧的,而不是放棄它。

關於這個話題,推薦一篇好文:

千萬級併發實現的祕密:內核不是解決方案,而是問題所在!http://highscalability.com/blog/2013/5/13/the-secret-to-10-million-concurrent-connections-the-kernel-i.html

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

現在,我們不能指望busy poll擔當所有的性能問題,仍然要依靠中斷。既然依靠中斷,鎖的問題就是優化的重點。

以雙核CPU為例,假設CPU0專職處理中斷,而收包進程則綁定在CPU1上,我們很快能意識到, CPU0和CPU1對於每一個skb的enqueue和dequeue均在爭搶socket的sk_receive_queue的spinlock 。

優化措施顯而易見, 將多個skb聚集起來,一次性入接收隊列 。顯然,這需要兩個隊列:

  • 維護聚集隊列:由中斷上下文將skb推入該隊列;

  • 維護接收隊列:進程上下文從該隊列拉取skb;

  • 接收隊列為空時,交換聚集隊列和接收隊列。

這樣,同樣在上述雙核CPU的情況下,只有在上面的第3點的操作中,才需要鎖保護。

考慮到機器的CPU並非雙核,可能是任意核,收包進程也未必綁定任何CPU,因此上述每一個隊列均需要一把鎖保護,無論如何, 和單隊列相比,雙隊列情況下鎖的競爭減少了一半!

collect_enqueue(skb, sk)
{
spin_lock(sk->sk_collect_queue->lock);
skb_queue_tail(sk->sk_collect_queue, skb);
update_statis(sk);
spin_unlock(sk->sk_collect_queue->lock);
}
sk_buff recv_dequeue(sk)
{
spin_lock(sk->sk_receive_queue->lock);
skb = skb_dequeue(sk->sk_receive_queue);
update_statis(sk);
spin_unlock(sk->sk_receive_queue->lock);
return skb;
}
udp_rcv(skb) // 中斷上下文
{
sk = lookup(...);
spin_lock(sk->higher_level_spin_lock);
collect_enqueue(skb, sk);// 僅僅往聚集隊列裡推入。
spin_unlock(sk->higher_level_spin_lock);
}

udp_recv(sk, buff) // 進程上下文
{
if (empty(sk->sk_receive_queue)) {
spin_lock(sk->queues_lock);
swap(sk->sk_receive_queue, sk->sk_collect_queue);
spin_unlock(sk->queues_lock)
}
skb = recv_dequeue(sk); // 僅僅從接收隊列里拉取
if (skb) {
copy_skb_to_buff(skb, buff);
}
}

如此一來,雙隊列解除了中斷上下文和進程上下文之間的鎖競爭。

來看一下對比圖示:

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

引入雙隊列後:

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

即便已經很不錯了,但是:

  • 中斷上下文中不同CPU可能會收到同一個socket的skb,CPU依然會在聚集隊列的鎖上蹦躂;

  • 不同的CPU上的進程也可能會處理同一個socket,本意是合作,卻需要接收隊列的鎖來將其操作串行化。

沒辦法,通用的操作系統內核只能做到這裡了,如果要解決以上的問題,就需要按照任何和角色明確綁CPU核心了,然而這也就不再是通用的內核了。最終,你會在內核裡聞到DPDK的腐臭味,超級糟心。

對了,我暫且將雙隊列區分為了聚集隊列和接收隊列 ,更好的名字可能是backlog隊列和接收隊列 。中斷上下文總是操作backlog隊列,而進程上下文在接收隊列為空時,交換backlog隊列為接收隊列。然而,backlog隊列這個名字在我看來非常臭名昭著,所以,暫且不用它了。

我想本文應該就要結束了,確實沒有源碼分析,事實上,我覺得我寫的這篇要比下面的這種有意思的多,然而可能在網上能找到的基本都是這種非常詳細的源碼分析:

...
bh_lock_sock(sk); // 鎖定住sk
if (!sock_owned_by_user(sk)) // 判斷sk是不是被用戶進程所擁有,如果沒有被擁有的話。
rc = __udp_queue_rcv_skb(sk, skb); // 直接調用__udp_queue_rcv_skb
else if (sk_add_backlog(sk, skb, sk->sk_rcvbuf)) { //否則調用sk_add_backlog將skb放入backlog
bh_unlock_sock(sk); // 如果失敗,解鎖sk,直接丟包
goto drop;
}
bh_unlock_sock(sk); // 解鎖sk

return rc; // 返回rc
...

哈哈…

我為什麼沒有談UDP的GRO、LRO機制?因為太不通用了。但是另一方面,如果應用程序加以稍微支持,UDP的GRO、LRO將會帶來非常可觀的收益,別忘了,內核只是UDP報文的一個通路即可,既然是通路,它便不包含處理邏輯,越快通過,越好。如果你在乎高吞吐,那麼就GRO唄,如下:

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

UDP的通用L4 GRO相當於一個非常簡單的5層協議,應用程序按照len字段稍加解析拆分即可,這將極大減少系統調用的次數,減少上下文切換帶來的cache miss損耗。

如何快速優化 Linux 內核 UDP 收包效率?| CSDN 博文精選

我為什麼不寫源碼分析?

因為我感覺很多寫源碼分析的都是吹水待價而沽的,當前出一本源碼分析的書成本太低了,所以大家都去寫這種源碼分析。我來說下寫源碼分析需要做什麼:什麼都不需要做,只要懂編程語言語法,能看懂代碼的語法即可,然後給代碼寫註釋,源碼分析就完成了,你甚至都不需要懂代碼的邏輯。

只要是命名良好的源代碼,只需要把代碼翻譯成中文即可。而國內幾乎所有的技術書籍都可以冠以 “深入理解”、 “深度解析” 之名,實則就是類似上面的 源碼翻譯......

那麼,我為什麼不給Linux內核社區提交patch呢?和寫源碼分析待價而沽使我不得開心顏一樣,我並不認為寫patch提交給社區能讓我更快樂。我只是思考,僅此而已,無需被承認,所以不必想方設法為外人知。

專業領域內,以IT互聯網行業為例,真正的牛人幾乎不寫書,不寫文章,更不會寫什麼源碼分析,真正的牛人留下的是代碼而不是別的......我不是牛人,所以我有時間寫文章,但我也不是欺世盜名待價而沽之人,所以我不寫毫無含金量的源碼分析,我只是一個在路上思考的人,所以我寫的東西都是思考的總結,不精確,甚至不正確,但這就是了。

作者:dog250,本文精選自CSDN博客,原文https://blog.csdn.net/dog250/article/details/98061338。

【End】

"

相關推薦

推薦中...