1. 程式人生 > >Linux內核中網絡數據包的接收-第一部分 概念和框架

Linux內核中網絡數據包的接收-第一部分 概念和框架

csdn 請求 版本號 post sched nec alloc nts 多核cpu

與網絡數據包的發送不同,網絡收包是異步的的。由於你不確定誰會在什麽時候突然發一個網絡包給你。因此這個網絡收包邏輯事實上包括兩件事:
1.數據包到來後的通知
2.收到通知並從數據包中獲取數據
這兩件事發生在協議棧的兩端。即網卡/協議棧邊界以及協議棧/應用邊界:
網卡/協議棧邊界:網卡通知數據包到來,中斷協議棧收包;
協議棧棧/應用邊界:協議棧將數據包填充socket隊列,通知應用程序有數據可讀,應用程序負責接收數據。


本文就來介紹一下關於這兩個邊界的這兩件事是怎麽一個細節,關乎網卡中斷,NAPI。網卡poll,select/poll/epoll等細節。並假設你已經大約懂了這些。

網卡/協議棧邊界的事件

網卡在數據包到來的時候會觸發中斷,然後協議棧就知道了數據包到來事件。接下來怎麽收包全然取決於協議棧本身,這就是網卡中斷處理程序的任務,當然,也能夠不採用中斷的方式,而是採用一個單獨的線程不斷輪詢網卡是否有數據包的到來。可是這樣的方式過於消耗CPU。做過多的無用功,因此基本被棄用。像這樣的異步事件。基本都是採用中斷通知的方案。綜合整個收包邏輯,大致能夠分為下面兩種方式
a.每個數據包到來即中斷CPU。由CPU調度中斷處理程序進行收包處理,收包邏輯又分為上半部和下半部,核心的協議棧處理邏輯在下半部完畢。
b.數據包到來,中斷CPU,CPU調度中斷處理程序而且關閉中斷響應,調度下半部不斷輪詢網卡。收包完畢或者達到一個閥值後,又一次開啟中斷。
當中的方式a在數據包持續高速到達的時候會造成非常大的性能損害,因此這樣的情況下一般採用方式b,這也是Linux NAPI採用的方式。

關於網卡/協議棧邊界所發生的事件。不想再說很多其他了,由於這會涉及到非常多硬件的細節,比方你在NAPI方式下關了中斷後,網卡內部是怎麽緩存數據包的,另外考慮到多核處理器的情形。是不是能夠將一個網卡收到的數據包中斷到不同的CPU核心呢?那麽這就涉及到了多隊列網卡的問題,而這些都不是一個普通的內核程序猿所能駕馭的,你要了解很多其他的與廠商相關的東西,比方Intel的各種規範,各種讓人看到暈的手冊...


協議棧/socket邊界事件

因此。為了更easy理解,我決定在還有一個邊界。即協議棧棧/應用邊界來描寫敘述相同的事情,而這些基本都是內核程序猿甚至應用程序猿所感興趣的領域了,為了使後面的討論更easy進行。我將這個協議棧棧/應用邊界重命名為協議棧/socket邊界,socket隔離了協議棧和應用程序,它就是一個接口,對於協議棧,它能夠代表應用程序。對於應用程序,它能夠代表協議棧。當數據包到來的時候。會發生例如以下的事情:
1).協議棧將數據包放入socket的接收緩沖區隊列,並通知持有該socket的應用程序;
2).CPU調度持有該socket的應用程序,將數據包從接收緩沖區隊列中取出,收包完畢。
整體的示意圖例如以下
技術分享


socket要素

如上圖所看到的,每個socket的收包邏輯都包括下面兩個要素

接收隊列

協議棧處理完畢的數據包要排入到的隊列,應用程序被喚醒後要從該隊列中讀取數據。


睡眠隊列

與該socket相關的應用程序假設沒有數據可讀。能夠在這個隊列上睡眠。一旦協議棧將數據包排入socket的接收隊列,將喚醒該睡眠隊列上的進程或者線程。


一把socket鎖

在有運行流操作socket的元數據的時候,必須鎖定socket,註意。接收隊列和睡眠隊列並不須要該鎖來保護。該鎖所保護的是相似socket緩沖區大小改動。TCP按序接收之類的事情。

這個模型非常easy且直接,和網卡中斷CPU通知網絡上有數據包到來須要處理一樣,協議棧通過這樣的方式通知應用程序有數據可讀,在繼續討論細節以及select/poll/epoll之前,先說兩個無關的事,後面就不再說了,僅僅是由於它們相關,所以僅僅是提一下而已。不會占用大量篇幅。

1.驚群與排他喚醒

相似TCP accpet邏輯這樣,對於大型webserver而言。基本上都是有多個進程或者線程同一時候在一個Listen socket上進行accept,假設協議棧將一個clientsocket排入了accept隊列。是將這些線程全部喚醒還是僅僅喚醒一個呢?假設是全部喚醒,非常顯然。僅僅有一個線程會搶到這個socket,其他的線程搶奪失敗後繼續睡眠,能夠說是被白白喚醒了,這就是經典的TCP驚群。因此產生了一種排他式的喚醒。也就是說僅僅喚醒睡眠隊列上的第一個線程,然後退出wakeup邏輯,不再喚醒後面的線程。這就避免了驚群。
這個話題在網上的討論早就已經汗牛充棟,可是細致想一下就會發現排他喚醒依舊有問題。它會大大降低效率。


為什麽這麽說呢?由於協議棧的喚醒操作和應用程序的實際Accept操作之間是全然異步的。除非在協議棧喚醒應用程序的時候,應用程序恰好堵塞在Accept上,不論什麽人都不能保證當時應用程序在幹什麽。舉一個簡單的樣例。在多核系統上,協議棧方面同一時候來了多個請求,而且也恰恰有多個線程等待在睡眠隊列上,假設能讓這多個協議棧運行流同一時候喚醒這多個線程該有多好,可是由於一個socket僅僅有一個Accept隊列,因此對於該隊列的排他喚醒機制基本上將這個暢想給打回去了。唯一一個accpet隊列的排入/取出的帶鎖操作讓整個流程串行化了,全然喪失了多核並行的優勢。因此REUSEPORT以及基於此的FastTCP就出現了。

(今天周末。細致研究了Linux kernel 4.4版本號帶來的更新,真的讓人眼前一亮啊,後面我會單獨寫一篇文章來描寫敘述)

2.REUSEPORT與多隊列

起初在了解到google的reuseport之前。我個人也做過一個相似的patch,當時的想法正是來自於與多隊列網卡的類比,既然一塊網卡能夠中斷多個CPU。一個socket上的數據可讀事件為什麽不能中斷多個應用程序呢?然而socket API早就已經固定死了,這對我的想法造成了打擊。由於一個socket就是一個文件描寫敘述符。表示一個五元組(非connect UDP的socket以及Listen tcp除外。)。協議棧的事件恰恰僅僅跟一個五元組相關...因此為了讓想法可行。僅僅能在socket API之外做文章,那就是同意多個socket綁定相同的IP地址/源port對,然後依照源IP地址/port對的HASH值來區分流量的路由,這個想法本人也實現了。事實上跟多隊列網卡是一個思想。全然一致的。多隊列網卡不也是依照不同五元組(或者N元組?咱不較真兒)的HASH值來中斷不同的CPU核心的嗎?細致想想當時的這個移植太TMD帥了。然而看到google的reuseport patch就認為自己做了無用功,又一次造了輪子...於是就想解決Accept單隊列的問題,既然已經多核時代了,為什麽不在每個CPU核心上保持一個accept隊列呢?應用程序的調度讓schedule子系統去考慮吧...這次沒有犯傻,於是看到了新浪的FastTCP方案。
當然,假設REUSEPORT的基於源IP/源port對的hash計算,直接避免了將同一個流“中斷”到不同的socket的接收隊列上。



好了,插曲已經說完,接下來該細節了。



接收隊列的管理

接收隊列的管理事實上非常easy,就是一個skb鏈表,協議棧將skb插入到鏈表的時候先lock住隊列本身,然後插入skb。然後喚醒socket睡眠隊列上的線程。接著線程加鎖獲取socket接收隊列上skb的數據,就是這麽簡單。


起碼在2.6.8的內核上就是這麽搞的。

後來的版本號都是這個基礎版本號的優化版本號。先後經歷了兩次的優化。

接收路徑優化1:引入backlog隊列

考慮到復雜的細節,比方依據收到的數據改動socket緩沖區大小時。應用程序在調用recv例程時須要對整個socket進行鎖定,在復雜的多核CPU環境中,有多個應用程序可能會操作同一個socket,有多個協議棧運行流也可能會往同一個socket接收緩沖區排入skb[詳情請參考《多核心Linux內核路徑優化的不二法門之-多核心平臺TCP優化》],因此鎖的粒度範圍自然就變成了socket本身。在應用程序持有socket的時候。協議棧由於可能會在軟中斷上下文運行,是不可睡眠等待的,為了使得協議棧運行流不至於因此而自旋堵塞,引入了一個backlog隊列。協議棧在應用程序持有socket的時候,僅僅須要將skb排入backlog隊列就能夠返回了,那麽這個backlog隊列終於由誰來處理呢?
誰找的事誰來處理。當初就是由於應用程序lock住了socket而使得協議棧不得不將skb排入backlog,那麽在應用程序release socket的時候,就會將backlog隊列裏面的skb放入接收隊列中去,模擬協議棧將skb入隊並喚醒操作。
引入backlog隊列後,單一的接收隊列變成了一個兩階段的接力隊列,相似流水線作業那樣。這樣不管如何協議棧都不用堵塞等待,協議棧假設不能立即將skb排入接收隊列。那麽這件事就由socket鎖定者自己來完畢,等到它放棄鎖定的時候這件事就可以進行。操作例程例如以下:
協議棧排隊skb---
獲取socket自旋鎖
應用程序占有socket的時候:將skb排入backlog隊列
應用程序未占有socket的時候:將skb排入接收隊列。喚醒接收隊列
釋放socket自旋鎖
應用程序接收數據---
獲取socket自旋鎖
堵塞占用socket
釋放socket自旋鎖
讀取數據:由於已經獨占了socket,能夠放心地將接收隊列skb的內容復制到用戶態
獲取socket自旋鎖
將backlog隊列的skb排入接收隊列(這事實上本該由協議棧完畢的,可是由於應用程序占有socket而被延後到了此刻)。喚醒睡眠隊列
釋放socket自旋鎖
能夠看到,所謂的socket鎖,並非一把簡單的自旋鎖。而是在不同的路徑有不同的鎖定方式,總之,僅僅要能保證socket的元數據受到保護,方案都是合理的,於是我們看到這是一個兩層鎖的模型。

兩層鎖定的lock框架

啰嗦了這麽多,事實上我們能夠把上面最後的那個序列總結成一個更為抽象通用的模式,在某些場景下能夠套用。

如今就描寫敘述一下這個模式。
參與者類別:NON-Sleep-不可睡眠類,Sleep-可睡眠類
參與者數量:NON-Sleep多個,Sleep類多個
競爭者:NON-Sleep類之間,Sleep類之間。NON-Sleep類和Sleep類之間
數據結構:
X-被鎖定實體
X.LOCK-自旋鎖,用於鎖定不可睡眠路徑以及保護標記鎖
X.FLAG-標記鎖,用來鎖定可睡眠路徑
X.sleeplist-等待獲得標記鎖的task隊列

NON-Sleep類的鎖定/解鎖邏輯:

spin_lock(X.LOCK);

if(X.FLAG == 1) {
    //add something todo to backlog
    delay_func(...);
} else {
    //do it directly
    direct_func(...);
}

spin_unlock(X.LOCK);

Sleep類的鎖定/解鎖邏輯:

spin_lock(X.LOCK);
do {
    if (X.FLAG == 0) {
        break;
    }
    for (;;) {
        ready_to_wait(X.sleeplist);
        spin_unlock(X.lock);
        wait();
        spin_lock(X.lock);
        if (X.FLAG == 0) {
            break;     
        }
    }
} while(0);
X.FLAG = 1;
spin_unlock(X.LOCK);

do_something(...);

spin_lock(X.LOCK)
if (have_delayed_work) {
    do {
        fetch_delayed_work(...);
        direct_func(...);
    } while(have_delayed_work);
}  
X.FLAG = 0;
wakeup(X.sleeplist);
spin_unlock(X.LOCK);


對於socket收包邏輯,事實上就是將skb插入接收隊列並喚醒socket的睡眠隊列填充到上述的direct_func中就可以。同一時候delay_func的任務就是將skb插入到backlog隊列。
該抽象出來的模型基本就是一個兩層鎖邏輯,自旋鎖在可睡眠路徑僅僅用來保護標記位,可睡眠路徑使用標記位來鎖定而不是使用自旋鎖本身,標記位的改動被自旋鎖保護。這個非常快的改動操作取代了慢速的業務邏輯處理路徑(比方socket收包...)的全然鎖定。這樣就大大降低了競態帶來的CPU時間的自旋開銷。

最近我在實際的一個場景中就採用了這個模型,非常不錯,效果也真的還好,因此特意抽象出了上述的代碼。
引入這個兩層鎖解放了不可睡眠路徑的操作,使其在可睡眠路徑的task占有一個socket期間仍然能夠將數據包排入到backlog隊列而不是等待可睡眠路徑task解鎖,然而有的時候可睡眠路徑上的邏輯也不是那麽慢,假設它不慢,甚至非常快,鎖定時間非常短,那麽是不是就能夠直接跟不可睡眠路徑去爭搶自旋鎖了呢?這正是引入可睡眠路徑fast lock的契機。



接收路徑優化2:引入fast lock

進程/線程上下文中的socket處理邏輯在滿足下列情況的前提下能夠直接與內核協議棧競爭該socket的自旋鎖:
a.處理臨界區非常小
b.當前沒有其他進程/線程上下文中的socket處理邏輯正在處理這個socket。


滿足以上條件的。說明這是一個單純的環境,競爭者地位對等。那麽非常顯然的一個問題就是誰來處理backlog隊列的問題,這個問題事實上不是問題,由於這樣的情況下backlog就用不到了,操作backlog必須持有自旋鎖,socket在fast lock期間也是持有自旋鎖的。兩個路徑全然相互排斥!

因此上述條件a就極其重要,假設在臨界區內出現了大的延遲,會造成協議棧路徑過度自旋。新的fast lock框架例如以下:

Sleep類的fast鎖定/解鎖邏輯:

fast = 0;
spin_lock(X.LOCK)
do {
    if (X.FLAG == 0) {
        fast = 0;
        break;
    }
    for (;;) {
        ready_to_wait(X.sleeplist);
        spin_unlock(X.LOCK);
        wait();
        spin_lock(X.LOCK);
        if (X.FLAG == 0) {
            break;     
        }
    }
    X.FLAG = 1;
    spin_unlock(X.LOCK);
} while(0);

do_something_very_small(...);

do {
    if (fast == 1) {
        break;
    }
    spin_lock(X.LOCK);
    if (have_delayed_work) {
        do {
            fetch_delayed_work(...);
            direct_func(...);
        } while(have_delayed_work);
    }  
    X.FLAG = 0;
    wakeup(X.sleeplist);
} while(0);
spin_unlock(X.LOCK);


之所以上述代碼那麽復雜而不是僅僅的spin_lock/spin_unlock。是由於假設X.FLAG為1。說明該socket已經在處理了,比方堵塞等待。



以上就是在協議棧/socket邊界上的異步流程的隊列和鎖的整體架構。總結一下。包括5個要素:
a=socket的接收隊列
b=socket的睡眠隊列
c=socket的backlog隊列
d=socket的自旋鎖
e=socket的占有標記
這5者之間運行下面的流程:
技術分享

有了這個框架。協議棧和socket之間就能夠安全異步地進行網絡數據的交接了。假設你細致看,而且對Linux 2.6內核的wakeup機制有足夠的了解,且有一定的解耦合的思想,我想應該能夠知道select/poll/epoll是如何一種工作機制了。

關於這個我想在本文的第二部分描寫敘述,我認為,僅僅要對基礎概念有足夠的理解且能夠融會貫通。非常多東西都是能夠僅僅靠想而推導出來的。
下面,我們能夠在以上這個框架內讓skb參與進來了。



接力傳遞的skb

在Linux的協議棧實現中,skb表示一個數據包。一個skb能夠屬於一個socket或者協議棧,但不能同一時候屬於兩者,一個skb屬於協議棧指的是它不和不論什麽一個socket相關聯,它僅對協議棧本身負責,假設一個skb屬於一個socket,就意味著它已經和一個socket進行了綁定,全部的關於它的操作,都要由該socket負責。


Linux為skb提供了一個destructor析構回調函數,每當skb被賦予新的屬主的時候會調用前一個屬主的析構函數。並被指定一個新的析構函數。我們比較關註的是skb從協議棧到socket的這最後一棒,在將skb排入到socket接收隊列之前。會調用下面的函數:

static inline void skb_set_owner_r(struct sk_buff *skb, struct sock *sk)
{
    skb_orphan(skb);
    skb->sk = sk;
    skb->destructor = sock_rfree;
    atomic_add(skb->truesize, &sk->sk_rmem_alloc);
    sk_mem_charge(sk, skb->truesize);
}

當中skb_orphan主要是回調了前一個屬主賦予該skb的析構函數。然後為其指定一個新的析構回調函數sock_rfree。在skb_set_owner_r調用完畢後,該skb就正式進入socket的接收隊列了:
skb_set_owner_r(skb, sk);

/* Cache the SKB length before we tack it onto the receive
 * queue.  Once it is added it no longer belongs to us and
 * may be freed by other threads of control pulling packets
 * from the queue.
 */
skb_len = skb->len;

skb_queue_tail(&sk->sk_receive_queue, skb);

if (!sock_flag(sk, SOCK_DEAD))
    sk->sk_data_ready(sk, skb_len);


最後通過調用sk_data_ready來通知睡眠在socket睡眠隊列上的task數據已經被排入接收隊列,事實上就是一個wakeup操作,然後協議棧就返回。

非常顯然,接下來關於該skb的全部處理均在進程/線程上下文中進行了,等到skb的數據被取出後,這個skb不會返回給協議棧。而是由進程/線程自行釋放,因此在其destructor回調函數sock_rfree中,主要做的就是把緩沖區空間還給系統,主要做兩件事:
1.該socket已分配的內存減去該skb占領的空間
sk->sk_rmem_alloc = sk->sk_rmem_alloc - skb->truesize;
2.該socket預分配的空間加上該skb占領的空間
sk->sk_forward_alloc = sk->sk_forward_alloc + skb->truesize;

協議數據包內存用量的統計和限制

內核協議棧僅僅是內核的一個子系統。且其數據來自本機之外。數據來源並不受控,非常easy受到DDos攻擊,因此有必要限制一個協議的整體內存用量,比方全部的TCP連接僅僅能用10M的內存這類,Linux內核起初僅僅針對TCP做統計。後來也增加了針對UDP的統計限制。在配置上體現為幾個sysctl參數:
net.ipv4.tcp_mem = 18978 25306 37956
net.ipv4.tcp_rmem = 4096 87380 6291456
net.ipv4.tcp_wmem = 4096 16384 4194304
net.ipv4.udp_mem = 18978 25306 37956
....
以上的每一項三個值中,含義例如以下:
第一個值mem[0]:表示正常值,凡是內存用量低於這個值時,都正常;
第二個值mem[1]:警告值。凡是高於這個值。就要著手緊縮方案了;
第三個值mem[2]:不可逾越的界限,高於這個值,說明內存使用已經超限了。數據要丟棄了。
註意,這些配置值是針對單獨協議的。而sockopt中配置的recvbuff配置的是針對單獨一條連接的緩沖區限制大小。兩者是不同的。內核在處理這個協議限額的時候,為了避免頻繁檢測,採用了預分配機制,第一次即便僅僅是來了一個1byte的包。也會為其透支一個頁面的內存限額,這裏並沒有實際進行內存分配。由於實際的內存分配在skb生成以及IP分片重組的時候就已經確定了,這裏僅僅是將這些值累加起來,檢測一下是否超過限額而已。因此這裏的邏輯僅僅是一個加減乘除的過程,除了計算過程消耗的CPU之外,並沒有消耗其他的機器資源。

計算方法例如以下
proto.memory_allocated:每個協議一個。表示當前該協議在內核socket緩沖區裏面一共已經使用了多少內存存放skb;
sk.sk_forward_alloc:每個socket一個,表示當前預分配給該socket的內存剩余用量,能夠用來存放skb。
skb.truesize:該skb結構體本身的大小以及其數據大小的總和;
skb即將進入socket的接收隊列前夕的累加例程:
ok = 0;
if (skb.truesize < sk.sk_forward_alloc) {
    ok = 1;
    goto addload;
}
pages = how_many_pages(skb.truesize);

tmp = atomic_add(proto.memory_allocated, pages*page_size);
if (tmp < mem[0]) {
    ok = 1;
    正常;
}

if (tmp > mem[1]) {
    ok = 2;
    吃緊;
}

if (tmp > mem[2]) {
    超限;
}

if (ok == 2) {
    if (do_something(proto)) {
        ok = 1;
    }
}

addload:
if (ok == 1) {
    sk.sk_forward_alloc = sk.sk_forward_alloc - skb.truesize;
    proto.memory_allocated = tmp;
} else {
    drop skb;
}

skb被socket釋放時調用析構函數時期的sk.sk_forward_alloc延展:
sk.sk_forward_alloc = sk.sk_forward_alloc + skb.truesize;

協議緩沖區回收時期(會在釋放skb或者過期刪除skb時調用):
if (sk.sk_forward_alloc > page_size) {
    pages = sk.sk_forward_alloc調整到整頁面數;
    prot.memory_allocated = prot.memory_allocated - pages*page_size;
}

這個邏輯能夠在sk_rmem_schedule等sk_mem_XXX函數中看個到底。


本文的第一部分到此已經結束,第二部分將著重描寫敘述select,poll,epoll的邏輯。

Linux內核中網絡數據包的接收-第一部分 概念和框架