聊聊Netty那些事兒之從核心角度看IO模型

bin的技術小屋發表於2022-07-03

從今天開始我們來聊聊Netty的那些事兒,我們都知道Netty是一個高效能非同步事件驅動的網路框架。

它的設計異常優雅簡潔,擴充套件性高,穩定性強。擁有非常詳細完整的使用者文件。

同時內建了很多非常有用的模組基本上做到了開箱即用,使用者只需要編寫短短几行程式碼,就可以快速構建出一個具有高吞吐低延時更少的資源消耗高效能(非必要的記憶體拷貝最小化)等特徵的高併發網路應用程式。

本文我們來探討下支援Netty具有高吞吐低延時特徵的基石----netty的網路IO模型

由Netty的網路IO模型開始,我們來正式揭開本系列Netty原始碼解析的序幕:

網路包接收流程

image

  • 網路資料幀通過網路傳輸到達網路卡時,網路卡會將網路資料幀通過DMA的方式放到環形緩衝區RingBuffer中。

RingBuffer是網路卡在啟動的時候分配和初始化環形緩衝佇列。當RingBuffer滿的時候,新來的資料包就會被丟棄。我們可以通過ifconfig命令檢視網路卡收發資料包的情況。其中overruns資料項表示當RingBuffer滿時,被丟棄的資料包。如果發現出現丟包情況,可以通過ethtool命令來增大RingBuffer長度。

  • DMA操作完成時,網路卡會向CPU發起一個硬中斷,告訴CPU有網路資料到達。CPU呼叫網路卡驅動註冊的硬中斷響應程式。網路卡硬中斷響應程式會為網路資料幀建立核心資料結構sk_buffer,並將網路資料幀拷貝sk_buffer中。然後發起軟中斷請求,通知核心有新的網路資料幀到達。

sk_buff 緩衝區,是一個維護網路幀結構的雙向連結串列,連結串列中的每一個元素都是一個網路幀。雖然 TCP/IP 協議棧分了好幾層,但上下不同層之間的傳遞,實際上只需要操作這個資料結構中的指標,而無需進行資料複製

  • 核心執行緒ksoftirqd發現有軟中斷請求到來,隨後呼叫網路卡驅動註冊的poll函式poll函式sk_buffer中的網路資料包送到核心協議棧中註冊的ip_rcv函式中。

每個CPU會繫結一個ksoftirqd核心執行緒專門用來處理軟中斷響應。2個 CPU 時,就會有 ksoftirqd/0ksoftirqd/1 這兩個核心執行緒。

這裡有個事情需要注意下: 網路卡接收到資料後,當DMA拷貝完成時,向CPU發出硬中斷,這時哪個CPU上響應了這個硬中斷,那麼在網路卡硬中斷響應程式中發出的軟中斷請求也會在這個CPU繫結的ksoftirqd執行緒中響應。所以如果發現Linux軟中斷,CPU消耗都集中在一個核上的話,那麼就需要調整硬中斷的CPU親和性,來將硬中斷打散不通的CPU核上去。

  • ip_rcv函式中也就是上圖中的網路層取出資料包的IP頭,判斷該資料包下一跳的走向,如果資料包是傳送給本機的,則取出傳輸層的協議型別(TCP或者UDP),並去掉資料包的IP頭,將資料包交給上圖中得傳輸層處理。

傳輸層的處理函式:TCP協議對應核心協議棧中註冊的tcp_rcv函式UDP協議對應核心協議棧中註冊的udp_rcv函式

  • 當我們採用的是TCP協議時,資料包到達傳輸層時,會在核心協議棧中的tcp_rcv函式處理,在tcp_rcv函式中去掉TCP頭,根據四元組(源IP,源埠,目的IP,目的埠)查詢對應的Socket,如果找到對應的Socket則將網路資料包中的傳輸資料拷貝到Socket中的接收緩衝區中。如果沒有找到,則傳送一個目標不可達icmp包。

  • 核心在接收網路資料包時所做的工作我們就介紹完了,現在我們把視角放到應用層,當我們程式通過系統呼叫read讀取Socket接收緩衝區中的資料時,如果接收緩衝區中沒有資料,那麼應用程式就會在系統呼叫上阻塞,直到Socket接收緩衝區有資料,然後CPU核心空間(Socket接收緩衝區)的資料拷貝使用者空間,最後系統呼叫read返回,應用程式讀取資料。

效能開銷

從核心處理網路資料包接收的整個過程來看,核心幫我們做了非常之多的工作,最終我們的應用程式才能讀取到網路資料。

隨著而來的也帶來了很多的效能開銷,結合前面介紹的網路資料包接收過程我們來看下網路資料包接收的過程中都有哪些效能開銷:

  • 應用程式通過系統呼叫使用者態轉為核心態的開銷以及系統呼叫返回時從核心態轉為使用者態的開銷。
  • 網路資料從核心空間通過CPU拷貝使用者空間的開銷。
  • 核心執行緒ksoftirqd響應軟中斷的開銷。
  • CPU響應硬中斷的開銷。
  • DMA拷貝網路資料包到記憶體中的開銷。

網路包傳送流程

image

  • 當我們在應用程式中呼叫send系統呼叫傳送資料時,由於是系統呼叫所以執行緒會發生一次使用者態到核心態的轉換,在核心中首先根據fd將真正的Socket找出,這個Socket物件中記錄著各種協議棧的函式地址,然後構造struct msghdr物件,將使用者需要傳送的資料全部封裝在這個struct msghdr結構體中。

  • 呼叫核心協議棧函式inet_sendmsg ,傳送流程進入核心協議棧處理。在進入到核心協議棧之後,核心會找到Socket上的具體協議的傳送函式。

比如:我們使用的是TCP協議,對應的TCP協議傳送函式是tcp_sendmsg ,如果是UDP協議的話,對應的傳送函式為udp_sendmsg

  • TCP協議的傳送函式tcp_sendmsg中,建立核心資料結構sk_buffer,將
    struct msghdr結構體中的傳送資料拷貝sk_buffer中。呼叫tcp_write_queue_tail函式獲取Socket傳送佇列中的隊尾元素,將新建立的sk_buffer新增到Socket傳送佇列的尾部。

Socket的傳送佇列是由sk_buffer組成的一個雙向連結串列

傳送流程走到這裡,使用者要傳送的資料總算是從使用者空間拷貝到了核心中,這時雖然傳送資料已經拷貝到了核心Socket中的傳送佇列中,但並不代表核心會開始傳送,因為TCP協議流量控制擁塞控制,使用者要傳送的資料包並不一定會立馬被髮送出去,需要符合TCP協議的傳送條件。如果沒有達到傳送條件,那麼本次send系統呼叫就會直接返回。

  • 如果符合傳送條件,則開始呼叫tcp_write_xmit核心函式。在這個函式中,會迴圈獲取Socket傳送佇列中待傳送的sk_buffer,然後進行擁塞控制以及滑動視窗的管理

  • 將從Socket傳送佇列中獲取到的sk_buffer重新拷貝一份,設定sk_buffer副本中的TCP HEADER

sk_buffer 內部其實包含了網路協議中所有的 header。在設定 TCP HEADER的時候,只是把指標指向 sk_buffer的合適位置。後面再設定 IP HEADER的時候,在把指標移動一下就行,避免頻繁的記憶體申請和拷貝,效率很高。

image

為什麼不直接使用Socket傳送佇列中的sk_buffer而是需要拷貝一份呢?
因為TCP協議是支援丟包重傳的,在沒有收到對端的ACK之前,這個sk_buffer是不能刪除的。核心每次呼叫網路卡傳送資料的時候,實際上傳遞的是sk_buffer拷貝副本,當網路卡把資料傳送出去後,sk_buffer拷貝副本會被釋放。當收到對端的ACK之後,Socket傳送佇列中的sk_buffer才會被真正刪除。

  • 當設定完TCP頭後,核心協議棧傳輸層的事情就做完了,下面通過呼叫ip_queue_xmit 核心函式,正式來到核心協議棧網路層的處理。

    • 檢查Socket中是否有快取路由表,如果沒有的話,則查詢路由項,並快取到Socket中。接著在把路由表設定到sk_buffer中。

    通過route命令可以檢視本機路由配置。

    • sk_buffer中的指標移動到IP頭位置上,設定IP頭

    • 執行netfilters過濾。過濾通過之後,如果資料大於 MTU 的話,則執行分片。

    如果你使用 iptables 配置了一些規則,那麼這裡將檢測是否命中規則。 如果你設定了非常複雜的 netfilter 規則,在這個函式裡將會導致你的執行緒 CPU 開銷極大增加

  • 核心協議棧網路層的事情處理完後,現在傳送流程進入了到了鄰居子系統鄰居子系統位於核心協議棧中的網路層網路介面層之間,用於傳送ARP請求獲取MAC地址,然後將sk_buffer中的指標移動到MAC頭位置,填充MAC頭

  • 經過鄰居子系統的處理,現在sk_buffer中已經封裝了一個完整的資料幀,隨後核心將sk_buffer交給網路裝置子系統進行處理。網路裝置子系統主要做以下幾項事情:

    • 選擇傳送佇列(RingBuffer)。因為網路卡擁有多個傳送佇列,所以在傳送前需要選擇一個傳送佇列。
    • sk_buffer新增到傳送佇列中。
    • 迴圈從傳送佇列(RingBuffer)中取出sk_buffer,呼叫核心函式sch_direct_xmit 傳送資料,其中會呼叫網路卡驅動程式來傳送資料。

以上過程全部是使用者執行緒的核心態在執行,佔用的CPU時間是系統態時間(sy),當分配給使用者執行緒的CPU quota用完的時候,會觸發NET_TX_SOFTIRQ型別的軟中斷,核心執行緒ksoftirqd 會響應這個軟中斷,並執行NET_TX_SOFTIRQ型別的軟中斷註冊的回撥函式net_tx_action ,在回撥函式中會執行到驅動程式函式 dev_hard_start_xmit來傳送資料。

注意:當觸發NET_TX_SOFTIRQ軟中斷來傳送資料時,後邊消耗的 CPU 就都顯示在 si 這裡了,不會消耗使用者程式的系統態時間(sy)了。

從這裡可以看到網路包的傳送過程和接受過程是不同的,在介紹網路包的接受過程時,我們提到是通過觸發NET_RX_SOFTIRQ 型別的軟中斷在核心執行緒ksoftirqd 中執行核心網路協議棧接受資料。而在網路資料包的傳送過程中是使用者執行緒的核心態在執行核心網路協議棧,只有當執行緒的CPU quota用盡時,才觸發NET_TX_SOFTIRQ軟中斷來傳送資料。

在整個網路包的傳送和接受過程中,NET_TX_SOFTIRQ型別的軟中斷只會在傳送網路包時並且當使用者執行緒的CPU quota用盡時,才會觸發。剩下的接受過程中觸發的軟中斷型別以及傳送完資料觸發的軟中斷型別均為NET_RX_SOFTIRQ
所以這就是你在伺服器上檢視 /proc/softirqs,一般 NET_RX 都要比 NET_TX 大很多的的原因。

  • 現在傳送流程終於到了網路卡真實傳送資料的階段,前邊我們講到無論是使用者執行緒的核心態還是觸發NET_TX_SOFTIRQ型別的軟中斷在傳送資料的時候最終會呼叫到網路卡的驅動程式函式dev_hard_start_xmit 來傳送資料。在網路卡驅動程式函式dev_hard_start_xmit中會將sk_buffer對映到網路卡可訪問的記憶體 DMA 區域,最終網路卡驅動程式通過DMA的方式將資料幀通過物理網路卡傳送出去。

  • 當資料傳送完畢後,還有最後一項重要的工作,就是清理工作。資料傳送完畢後,網路卡裝置會向CPU傳送一個硬中斷,CPU呼叫網路卡驅動程式註冊的硬中斷響應程式,在硬中斷響應中觸發NET_RX_SOFTIRQ型別的軟中斷,在軟中斷的回撥函式igb_poll 中清理釋放 sk_buffer,清理網路卡傳送佇列(RingBuffer),解除 DMA 對映。

無論硬中斷是因為有資料要接收,還是說傳送完成通知,從硬中斷觸發的軟中斷都是 NET_RX_SOFTIRQ

這裡釋放清理的只是sk_buffer的副本,真正的sk_buffer現在還是存放在Socket的傳送佇列中。前面在傳輸層處理的時候我們提到過,因為傳輸層需要保證可靠性,所以 sk_buffer其實還沒有刪除。它得等收到對方的 ACK 之後才會真正刪除。

效能開銷

前邊我們提到了在網路包接收過程中涉及到的效能開銷,現在介紹完了網路包的傳送過程,我們來看下在資料包傳送過程中的效能開銷:

  • 和接收資料一樣,應用程式在呼叫系統呼叫send的時候會從使用者態轉為核心態以及傳送完資料後,系統呼叫返回時從核心態轉為使用者態的開銷。

  • 使用者執行緒核心態CPU quota用盡時觸發NET_TX_SOFTIRQ型別軟中斷,核心響應軟中斷的開銷。

  • 網路卡傳送完資料,向CPU傳送硬中斷,CPU響應硬中斷的開銷。以及在硬中斷中傳送NET_RX_SOFTIRQ軟中斷執行具體的記憶體清理動作。核心響應軟中斷的開銷。

  • 記憶體拷貝的開銷。我們來回顧下在資料包傳送的過程中都發生了哪些記憶體拷貝:

    • 在核心協議棧的傳輸層中,TCP協議對應的傳送函式tcp_sendmsg 會申請sk_buffer,將使用者要傳送的資料拷貝sk_buffer中。
    • 在傳送流程從傳輸層到網路層的時候,會拷貝一個sk_buffer副本出來,將這個sk_buffer副本向下傳遞。原始sk_buffer保留在Socket傳送佇列中,等待網路對端ACK,對端ACK後刪除Socket傳送佇列中的sk_buffer。對端沒有傳送ACK,則重新從Socket傳送佇列中傳送,實現TCP協議的可靠傳輸。
    • 在網路層,如果發現要傳送的資料大於MTU,則會進行分片操作,申請額外的sk_buffer,並將原來的sk_buffer拷貝到多個小的sk_buffer中。

再談(阻塞,非阻塞)與(同步,非同步)

在我們聊完網路資料的接收和傳送過程後,我們來談下IO中特別容易混淆的概念:阻塞與同步非阻塞與非同步

網上各種博文還有各種書籍中有大量的關於這兩個概念的解釋,但是筆者覺得還是不夠形象化,只是對概念的生硬解釋,如果硬套概念的話,其實感覺阻塞與同步非阻塞與非同步還是沒啥區別,時間長了,還是比較模糊容易混淆。

所以筆者在這裡嘗試換一種更加形象化,更加容易理解記憶的方式來清晰地解釋下什麼是阻塞與非阻塞,什麼是同步與非同步

經過前邊對網路資料包接收流程的介紹,在這裡我們可以將整個流程總結為兩個階段:

image

  • 資料準備階段: 在這個階段,網路資料包到達網路卡,通過DMA
    的方式將資料包拷貝到記憶體中,然後經過硬中斷,軟中斷,接著通過核心執行緒ksoftirqd 經過核心協議棧的處理,最終將資料傳送到核心Socket的接收緩衝區中。

  • 資料拷貝階段: 當資料到達核心Socket的接收緩衝區中時,此時資料存在於核心空間中,需要將資料拷貝使用者空間中,才能夠被應用程式讀取。

阻塞與非阻塞

阻塞與非阻塞的區別主要發生在第一階段:資料準備階段

當應用程式發起系統呼叫read時,執行緒從使用者態轉為核心態,讀取核心Socket的接收緩衝區中的網路資料。

阻塞

如果這時核心Socket的接收緩衝區沒有資料,那麼執行緒就會一直等待,直到Socket接收緩衝區有資料為止。隨後將資料從核心空間拷貝到使用者空間,系統呼叫read返回。

image

從圖中我們可以看出:阻塞的特點是在第一階段和第二階段都會等待

非阻塞

阻塞非阻塞主要的區分是在第一階段:資料準備階段

  • 在第一階段,當Socket的接收緩衝區中沒有資料的時候,阻塞模式下應用執行緒會一直等待。非阻塞模式下應用執行緒不會等待,系統呼叫直接返回錯誤標誌EWOULDBLOCK

  • Socket的接收緩衝區中有資料的時候,阻塞非阻塞的表現是一樣的,都會進入第二階段等待資料從核心空間拷貝到使用者空間,然後系統呼叫返回

image

從上圖中,我們可以看出:非阻塞的特點是第一階段不會等待,但是在第二階段還是會等待

同步與非同步

同步非同步主要的區別發生在第二階段:資料拷貝階段

前邊我們提到在資料拷貝階段主要是將資料從核心空間拷貝到使用者空間。然後應用程式才可以讀取資料。

當核心Socket的接收緩衝區有資料到達時,進入第二階段。

同步

同步模式在資料準備好後,是由使用者執行緒核心態來執行第二階段。所以應用程式會在第二階段發生阻塞,直到資料從核心空間拷貝到使用者空間,系統呼叫才會返回。

Linux下的 epoll和Mac 下的 kqueue 都屬於同步 IO

image

非同步

非同步模式下是由核心來執行第二階段的資料拷貝操作,當核心執行完第二階段,會通知使用者執行緒IO操作已經完成,並將資料回撥給使用者執行緒。所以在非同步模式資料準備階段資料拷貝階段均是由核心來完成,不會對應用程式造成任何阻塞。

基於以上特徵,我們可以看到非同步模式需要核心的支援,比較依賴作業系統底層的支援。

在目前流行的作業系統中,只有Windows 中的 IOCP 才真正屬於非同步 IO,實現的也非常成熟。但Windows很少用來作為伺服器使用。

而常用來作為伺服器使用的Linux,非同步IO機制實現的不夠成熟,與NIO相比效能提升的也不夠明顯。

但Linux kernel 在5.1版本由Facebook的大神Jens Axboe引入了新的非同步IO庫io_uring 改善了原來Linux native AIO的一些效能問題。效能相比Epoll以及之前原生的AIO提高了不少,值得關注。

image

IO模型

在進行網路IO操作時,用什麼樣的IO模型來讀寫資料將在很大程度上決定了網路框架的IO效能。所以IO模型的選擇是構建一個高效能網路框架的基礎。

在《UNIX 網路程式設計》一書中介紹了五種IO模型:阻塞IO,非阻塞IO,IO多路複用,訊號驅動IO,非同步IO,每一種IO模型的出現都是對前一種的升級優化。

下面我們就來分別介紹下這五種IO模型各自都解決了什麼問題,適用於哪些場景,各自的優缺點是什麼?

阻塞IO(BIO)

image

經過前一小節對阻塞這個概念的介紹,相信大家可以很容易理解阻塞IO的概念和過程。

既然這小節我們談的是IO,那麼下邊我們來看下在阻塞IO模型下,網路資料的讀寫過程。

阻塞讀

當使用者執行緒發起read系統呼叫,使用者執行緒從使用者態切換到核心態,在核心中去檢視Socket接收緩衝區是否有資料到來。

  • Socket接收緩衝區中有資料,則使用者執行緒在核心態將核心空間中的資料拷貝到使用者空間,系統IO呼叫返回。

  • Socket接收緩衝區中無資料,則使用者執行緒讓出CPU,進入阻塞狀態。當資料到達Socket接收緩衝區後,核心喚醒阻塞狀態中的使用者執行緒進入就緒狀態,隨後經過CPU的排程獲取到CPU quota進入執行狀態,將核心空間的資料拷貝到使用者空間,隨後系統呼叫返回。

阻塞寫

當使用者執行緒發起send系統呼叫時,使用者執行緒從使用者態切換到核心態,將傳送資料從使用者空間拷貝到核心空間中的Socket傳送緩衝區中。

  • Socket傳送緩衝區能夠容納下傳送資料時,使用者執行緒會將全部的傳送資料寫入Socket緩衝區,然後執行在《網路包傳送流程》這小節介紹的後續流程,然後返回。

  • Socket傳送緩衝區空間不夠,無法容納下全部傳送資料時,使用者執行緒讓出CPU,進入阻塞狀態,直到Socket傳送緩衝區能夠容納下全部傳送資料時,核心喚醒使用者執行緒,執行後續傳送流程。

阻塞IO模型下的寫操作做事風格比較硬剛,非得要把全部的傳送資料寫入傳送緩衝區才肯善罷甘休。

阻塞IO模型

image

由於阻塞IO的讀寫特點,所以導致在阻塞IO模型下,每個請求都需要被一個獨立的執行緒處理。一個執行緒在同一時刻只能與一個連線繫結。來一個請求,服務端就需要建立一個執行緒用來處理請求。

當客戶端請求的併發量突然增大時,服務端在一瞬間就會建立出大量的執行緒,而建立執行緒是需要系統資源開銷的,這樣一來就會一瞬間佔用大量的系統資源。

如果客戶端建立好連線後,但是一直不發資料,通常大部分情況下,網路連線也並不總是有資料可讀,那麼在空閒的這段時間內,服務端執行緒就會一直處於阻塞狀態,無法幹其他的事情。CPU也無法得到充分的發揮,同時還會導致大量執行緒切換的開銷

適用場景

基於以上阻塞IO模型的特點,該模型只適用於連線數少併發度低的業務場景。

比如公司內部的一些管理系統,通常請求數在100個左右,使用阻塞IO模型還是非常適合的。而且效能還不輸NIO。

該模型在C10K之前,是普遍被採用的一種IO模型。

非阻塞IO(NIO)

阻塞IO模型最大的問題就是一個執行緒只能處理一個連線,如果這個連線上沒有資料的話,那麼這個執行緒就只能阻塞在系統IO呼叫上,不能幹其他的事情。這對系統資源來說,是一種極大的浪費。同時大量的執行緒上下文切換,也是一個巨大的系統開銷。

所以為了解決這個問題,我們就需要用盡可能少的執行緒去處理更多的連線。網路IO模型的演變也是根據這個需求來一步一步演進的。

基於這個需求,第一種解決方案非阻塞IO就出現了。我們在上一小節中介紹了非阻塞的概念,現在我們來看下網路讀寫操作在非阻塞IO下的特點:

image

非阻塞讀

當使用者執行緒發起非阻塞read系統呼叫時,使用者執行緒從使用者態轉為核心態,在核心中去檢視Socket接收緩衝區是否有資料到來。

  • Socket接收緩衝區中無資料,系統呼叫立馬返回,並帶有一個 EWOULDBLOCKEAGAIN 錯誤,這個階段使用者執行緒不會阻塞,也不會讓出CPU,而是會繼續輪訓直到Socket接收緩衝區中有資料為止。

  • Socket接收緩衝區中有資料,使用者執行緒在核心態會將核心空間中的資料拷貝到使用者空間注意這個資料拷貝階段,應用程式是阻塞的,當資料拷貝完成,系統呼叫返回。

非阻塞寫

前邊我們在介紹阻塞寫的時候提到阻塞寫的風格特別的硬朗,頭比較鐵非要把全部傳送資料一次性都寫到Socket的傳送緩衝區中才返回,如果傳送緩衝區中沒有足夠的空間容納,那麼就一直阻塞死等,特別的剛。

相比較而言非阻塞寫的特點就比較佛系,當傳送緩衝區中沒有足夠的空間容納全部傳送資料時,非阻塞寫的特點是能寫多少寫多少,寫不下了,就立即返回。並將寫入到傳送緩衝區的位元組數返回給應用程式,方便使用者執行緒不斷的輪訓嘗試將剩下的資料寫入傳送緩衝區中。

非阻塞IO模型

image

基於以上非阻塞IO的特點,我們就不必像阻塞IO那樣為每個請求分配一個執行緒去處理連線上的讀寫了。

我們可以利用一個執行緒或者很少的執行緒,去不斷地輪詢每個Socket的接收緩衝區是否有資料到達,如果沒有資料,不必阻塞執行緒,而是接著去輪詢下一個Socket接收緩衝區,直到輪詢到資料後,處理連線上的讀寫,或者交給業務執行緒池去處理,輪詢執行緒則繼續輪詢其他的Socket接收緩衝區。

這樣一個非阻塞IO模型就實現了我們在本小節開始提出的需求:我們需要用盡可能少的執行緒去處理更多的連線

適用場景

雖然非阻塞IO模型阻塞IO模型相比,減少了很大一部分的資源消耗和系統開銷。

但是它仍然有很大的效能問題,因為在非阻塞IO模型下,需要使用者執行緒去不斷地發起系統呼叫去輪訓Socket接收緩衝區,這就需要使用者執行緒不斷地從使用者態切換到核心態核心態切換到使用者態。隨著併發量的增大,這個上下文切換的開銷也是巨大的。

所以單純的非阻塞IO模型還是無法適用於高併發的場景。只能適用於C10K以下的場景。

IO多路複用

非阻塞IO這一小節的開頭,我們提到網路IO模型的演變都是圍繞著---如何用盡可能少的執行緒去處理更多的連線這個核心需求開始展開的。

本小節我們來談談IO多路複用模型,那麼什麼是多路?,什麼又是複用呢?

我們還是以這個核心需求來對這兩個概念展開闡述:

  • 多路:我們的核心需求是要用盡可能少的執行緒來處理儘可能多的連線,這裡的多路指的就是我們需要處理的眾多連線。

  • 複用:核心需求要求我們使用儘可能少的執行緒儘可能少的系統開銷去處理儘可能多的連線(多路),那麼這裡的複用指的就是用有限的資源,比如用一個執行緒或者固定數量的執行緒去處理眾多連線上的讀寫事件。換句話說,在阻塞IO模型中一個連線就需要分配一個獨立的執行緒去專門處理這個連線上的讀寫,到了IO多路複用模型中,多個連線可以複用這一個獨立的執行緒去處理這多個連線上的讀寫。

好了,IO多路複用模型的概念解釋清楚了,那麼問題的關鍵是我們如何去實現這個複用,也就是如何讓一個獨立的執行緒去處理眾多連線上的讀寫事件呢?

這個問題其實在非阻塞IO模型中已經給出了它的答案,在非阻塞IO模型中,利用非阻塞的系統IO呼叫去不斷的輪詢眾多連線的Socket接收緩衝區看是否有資料到來,如果有則處理,如果沒有則繼續輪詢下一個Socket。這樣就達到了用一個執行緒去處理眾多連線上的讀寫事件了。

但是非阻塞IO模型最大的問題就是需要不斷的發起系統呼叫去輪詢各個Socket中的接收緩衝區是否有資料到來,頻繁系統呼叫隨之帶來了大量的上下文切換開銷。隨著併發量的提升,這樣也會導致非常嚴重的效能問題。

那麼如何避免頻繁的系統呼叫同時又可以實現我們的核心需求呢?

這就需要作業系統的核心來支援這樣的操作,我們可以把頻繁的輪詢操作交給作業系統核心來替我們完成,這樣就避免了在使用者空間頻繁的去使用系統呼叫來輪詢所帶來的效能開銷。

正如我們所想,作業系統核心也確實為我們提供了這樣的功能實現,下面我們來一起看下作業系統對IO多路複用模型的實現。

select

select是作業系統核心提供給我們使用的一個系統呼叫,它解決了在非阻塞IO模型中需要不斷的發起系統IO呼叫去輪詢各個連線上的Socket接收緩衝區所帶來的使用者空間核心空間不斷切換的系統開銷

select系統呼叫將輪詢的操作交給了核心來幫助我們完成,從而避免了在使用者空間不斷的發起輪詢所帶來的的系統效能開銷。

image

  • 首先使用者執行緒在發起select系統呼叫的時候會阻塞select系統呼叫上。此時,使用者執行緒從使用者態切換到了核心態完成了一次上下文切換

  • 使用者執行緒將需要監聽的Socket對應的檔案描述符fd陣列通過select系統呼叫傳遞給核心。此時,使用者執行緒將使用者空間中的檔案描述符fd陣列拷貝核心空間

這裡的檔案描述符陣列其實是一個BitMapBitMap下標為檔案描述符fd,下標對應的值為:1表示該fd上有讀寫事件,0表示該fd上沒有讀寫事件。

image

檔案描述符fd其實就是一個整數值,在Linux中一切皆檔案,Socket也是一個檔案。描述程式所有資訊的資料結構task_struct 中有一個屬性struct files_struct *files,它最終指向了一個陣列,陣列裡存放了程式開啟的所有檔案列表,檔案資訊封裝在struct file結構體中,這個陣列存放的型別就是
struct file結構體,陣列的下標則是我們常說的檔案描述符fd

  • 當使用者執行緒呼叫完select後開始進入阻塞狀態核心開始輪詢遍歷fd陣列,檢視fd對應的Socket接收緩衝區中是否有資料到來。如果有資料到來,則將fd對應BitMap的值設定為1。如果沒有資料到來,則保持值為0

注意這裡核心會修改原始的fd陣列!!

  • 核心遍歷一遍fd陣列後,如果發現有些fd上有IO資料到來,則將修改後的fd陣列返回給使用者執行緒。此時,會將fd陣列從核心空間拷貝到使用者空間

  • 當核心將修改後的fd陣列返回給使用者執行緒後,使用者執行緒解除阻塞,由使用者執行緒開始遍歷fd陣列然後找出fd陣列中值為1Socket檔案描述符。最後對這些Socket發起系統呼叫讀取資料。

select不會告訴使用者執行緒具體哪些fd上有IO資料到來,只是在IO活躍fd上打上標記,將打好標記的完整fd陣列返回給使用者執行緒,所以使用者執行緒還需要遍歷fd陣列找出具體哪些fd上有IO資料到來。

  • 由於核心在遍歷的過程中已經修改了fd陣列,所以在使用者執行緒遍歷完fd陣列後獲取到IO就緒Socket後,就需要重置fd陣列,並重新呼叫select傳入重置後的fd陣列,讓核心發起新的一輪遍歷輪詢。

API介紹

當我們熟悉了select的原理後,就很容易理解核心給我們提供的select API了。

 int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout)

select API中我們可以看到,select系統呼叫是在規定的超時時間內,監聽(輪詢)使用者感興趣的檔案描述符集合上的可讀,可寫,異常三類事件。

  • maxfdp1 : select傳遞給核心監聽的檔案描述符集合中數值最大的檔案描述符+1,目的是用於限定核心遍歷範圍。比如:select監聽的檔案描述符集合為{0,1,2,3,4},那麼maxfdp1的值為5

  • fd_set *readset:可讀事件感興趣的檔案描述符集合。

  • fd_set *writeset:可寫事件感興趣的檔案描述符集合。

  • fd_set *exceptset:可寫事件感興趣的檔案描述符集合。

這裡的fd_set就是我們前邊提到的檔案描述符陣列,是一個BitMap結構。

  • const struct timeval *timeout:select系統呼叫超時時間,在這段時間內,核心如果沒有發現有IO就緒的檔案描述符,就直接返回。

上小節提到,在核心遍歷完fd陣列後,發現有IO就緒fd,則會將該fd對應的BitMap中的值設定為1,並將修改後的fd陣列,返回給使用者執行緒。

在使用者執行緒中需要重新遍歷fd陣列,找出IO就緒fd出來,然後發起真正的讀寫呼叫。

下面介紹下在使用者執行緒中重新遍歷fd陣列的過程中,我們需要用到的API

  • void FD_ZERO(fd_set *fdset):清空指定的檔案描述符集合,即讓fd_set中不在包含任何檔案描述符。

  • void FD_SET(int fd, fd_set *fdset):將一個給定的檔案描述符加入集合之中。

每次呼叫select之前都要通過FD_ZEROFD_SET重新設定檔案描述符,因為檔案描述符集合會在核心被修改

  • int FD_ISSET(int fd, fd_set *fdset):檢查集合中指定的檔案描述符是否可以讀寫。使用者執行緒遍歷檔案描述符集合,呼叫該方法檢查相應的檔案描述符是否IO就緒

  • void FD_CLR(int fd, fd_set *fdset):將一個給定的檔案描述符從集合中刪除

效能開銷

雖然select解決了非阻塞IO模型中頻繁發起系統呼叫的問題,但是在整個select工作過程中,我們還是看出了select有些不足的地方。

  • 在發起select系統呼叫以及返回時,使用者執行緒各發生了一次使用者態核心態以及核心態使用者態的上下文切換開銷。發生2次上下文切換

  • 在發起select系統呼叫以及返回時,使用者執行緒在核心態需要將檔案描述符集合從使用者空間拷貝到核心空間。以及在核心修改完檔案描述符集合後,又要將它從核心空間拷貝到使用者空間。發生2次檔案描述符集合的拷貝

  • 雖然由原來在使用者空間發起輪詢優化成了核心空間發起輪詢但select不會告訴使用者執行緒到底是哪些Socket上發生了IO就緒事件,只是對IO就緒Socket作了標記,使用者執行緒依然要遍歷檔案描述符集合去查詢具體IO就緒Socket。時間複雜度依然為O(n)

大部分情況下,網路連線並不總是活躍的,如果select監聽了大量的客戶端連線,只有少數的連線活躍,然而使用輪詢的這種方式會隨著連線數的增大,效率會越來越低。

  • 核心會對原始的檔案描述符集合進行修改。導致每次在使用者空間重新發起select呼叫時,都需要對檔案描述符集合進行重置

  • BitMap結構的檔案描述符集合,長度為固定的1024,所以只能監聽0~1023的檔案描述符。

  • select系統呼叫 不是執行緒安全的。

以上select的不足所產生的效能開銷都會隨著併發量的增大而線性增長

很明顯select也不能解決C10K問題,只適用於1000個左右的併發連線場景。

poll

poll相當於是改進版的select,但是工作原理基本和select沒有本質的區別。

int poll(struct pollfd *fds, unsigned int nfds, int timeout)
struct pollfd {
    int   fd;         /* 檔案描述符 */
    short events;     /* 需要監聽的事件 */
    short revents;    /* 實際發生的事件 由核心修改設定 */
};

select中使用的檔案描述符集合是採用的固定長度為1024的BitMap結構的fd_set,而poll換成了一個pollfd 結構沒有固定長度的陣列,這樣就沒有了最大描述符數量的限制(當然還會受到系統檔案描述符限制)

poll只是改進了select只能監聽1024個檔案描述符的數量限制,但是並沒有在效能方面做出改進。和select上本質並沒有多大差別。

  • 同樣需要在核心空間使用者空間中對檔案描述符集合進行輪詢,查詢出IO就緒Socket的時間複雜度依然為O(n)

  • 同樣需要將包含大量檔案描述符的集合整體在使用者空間核心空間之間來回複製無論這些檔案描述符是否就緒。他們的開銷都會隨著檔案描述符數量的增加而線性增大。

  • select,poll在每次新增,刪除需要監聽的socket時,都需要將整個新的socket集合全量傳至核心

poll同樣不適用高併發的場景。依然無法解決C10K問題。

epoll

通過上邊對select,poll核心原理的介紹,我們看到select,poll的效能瓶頸主要體現在下面三個地方:

  • 因為核心不會儲存我們要監聽的socket集合,所以在每次呼叫select,poll的時候都需要傳入,傳出全量的socket檔案描述符集合。這導致了大量的檔案描述符在使用者空間核心空間頻繁的來回複製。

  • 由於核心不會通知具體IO就緒socket,只是在這些IO就緒的socket上打好標記,所以當select系統呼叫返回時,在使用者空間還是需要完整遍歷一遍socket檔案描述符集合來獲取具體IO就緒socket

  • 核心空間中也是通過遍歷的方式來得到IO就緒socket

下面我們來看下epoll是如何解決這些問題的。在介紹epoll的核心原理之前,我們需要介紹下理解epoll工作過程所需要的一些核心基礎知識。

Socket的建立

服務端執行緒呼叫accept系統呼叫後開始阻塞,當有客戶端連線上來並完成TCP三次握手後,核心會建立一個對應的Socket作為服務端與客戶端通訊的核心介面。

在Linux核心的角度看來,一切皆是檔案,Socket也不例外,當核心建立出Socket之後,會將這個Socket放到當前程式所開啟的檔案列表中管理起來。

下面我們來看下程式管理這些開啟的檔案列表相關的核心資料結構是什麼樣的?在瞭解完這些資料結構後,我們會更加清晰的理解Socket在核心中所發揮的作用。並且對後面我們理解epoll的建立過程有很大的幫助。

程式中管理檔案列表結構

image

struct tast_struct是核心中用來表示程式的一個資料結構,它包含了程式的所有資訊。本小節我們只列出和檔案管理相關的屬性。

其中程式內開啟的所有檔案是通過一個陣列fd_array來進行組織管理,陣列的下標即為我們常提到的檔案描述符,陣列中存放的是對應的檔案資料結構struct file。每開啟一個檔案,核心都會建立一個struct file與之對應,並在fd_array中找到一個空閒位置分配給它,陣列中對應的下標,就是我們在使用者空間用到的檔案描述符

對於任何一個程式,預設情況下,檔案描述符 0 表示 stdin 標準輸入,檔案描述符 1 表示 stdout 標準輸出,檔案描述符2表示 stderr 標準錯誤輸出

程式中開啟的檔案列表fd_array定義在核心資料結構struct files_struct中,在struct fdtable結構中有一個指標struct fd **fd指向fd_array

由於本小節討論的是核心網路系統部分的資料結構,所以這裡拿Socket檔案型別來舉例說明:

用於封裝檔案元資訊的核心資料結構struct file中的private_data指標指向具體的Socket結構。

struct file中的file_operations屬性定義了檔案的操作函式,不同的檔案型別,對應的file_operations是不同的,針對Socket檔案型別,這裡的file_operations指向socket_file_ops

我們在使用者空間Socket發起的讀寫等系統呼叫,進入核心首先會呼叫的是Socket對應的struct file中指向的socket_file_ops
比如:對Socket發起write寫操作,在核心中首先被呼叫的就是socket_file_ops中定義的sock_write_iterSocket發起read讀操作核心中對應的則是sock_read_iter


static const struct file_operations socket_file_ops = {
  .owner =  THIS_MODULE,
  .llseek =  no_llseek,
  .read_iter =  sock_read_iter,
  .write_iter =  sock_write_iter,
  .poll =    sock_poll,
  .unlocked_ioctl = sock_ioctl,
  .mmap =    sock_mmap,
  .release =  sock_close,
  .fasync =  sock_fasync,
  .sendpage =  sock_sendpage,
  .splice_write = generic_splice_sendpage,
  .splice_read =  sock_splice_read,
};

Socket核心結構

image

在我們進行網路程式的編寫時會首先建立一個Socket,然後基於這個Socket進行bindlisten,我們先將這個Socket稱作為監聽Socket

  1. 當我們呼叫accept後,核心會基於監聽Socket建立出來一個新的Socket專門用於與客戶端之間的網路通訊。並將監聽Socket中的Socket操作函式集合inet_stream_ops ops賦值到新的Socketops屬性中。
const struct proto_ops inet_stream_ops = {
  .bind = inet_bind,
  .connect = inet_stream_connect,
  .accept = inet_accept,
  .poll = tcp_poll,
  .listen = inet_listen,
  .sendmsg = inet_sendmsg,
  .recvmsg = inet_recvmsg,
  ......
}

這裡需要注意的是,監聽的 socket 和真正用來網路通訊的 Socket,是兩個 Socket,一個叫作監聽 Socket,一個叫作已連線的Socket

  1. 接著核心會為已連線的Socket建立struct file並初始化,並把Socket檔案操作函式集合(socket_file_ops )賦值給struct file中的f_ops指標。然後將struct socket中的file指標指向這個新分配申請的struct file結構體。

核心會維護兩個佇列:

  • 一個是已經完成TCP三次握手,連線狀態處於established的連線佇列。核心中為icsk_accept_queue
  • 一個是還沒有完成TCP三次握手,連線狀態處於syn_rcvd的半連線佇列。
  1. 然後呼叫socket->ops->accept,從Socket核心結構圖中我們可以看到其實呼叫的是inet_accept,該函式會在icsk_accept_queue中查詢是否有已經建立好的連線,如果有的話,直接從icsk_accept_queue中獲取已經建立好的struct sock。並將這個struct sock物件賦值給struct socket中的sock指標。

struct sockstruct socket中是一個非常核心的核心物件,正是在這裡定義了我們在介紹網路包的接收傳送流程中提到的接收佇列傳送佇列等待佇列資料就緒回撥函式指標核心協議棧操作函式集合

  • 根據建立Socket時發起的系統呼叫sock_create中的protocol引數(對於TCP協議這裡的引數值為SOCK_STREAM )查詢到對於 tcp 定義的操作方法實現集合 inet_stream_ops tcp_prot。並把它們分別設定到socket->opssock->sk_prot 上。

這裡可以回看下本小節開頭的《Socket核心結構圖》捋一下他們之間的關係。

socket相關的操作介面定義在inet_stream_ops函式集合中,負責對上給使用者提供介面。而socket與核心協議棧之間的操作介面定義在struct sock中的sk_prot 指標上,這裡指向tcp_prot協議操作函式集合。

struct proto tcp_prot = {
  .name      = "TCP",
  .owner      = THIS_MODULE,
  .close      = tcp_close,
  .connect    = tcp_v4_connect,
  .disconnect    = tcp_disconnect,
  .accept      = inet_csk_accept,
  .keepalive    = tcp_set_keepalive,
  .recvmsg    = tcp_recvmsg,
  .sendmsg    = tcp_sendmsg,
  .backlog_rcv    = tcp_v4_do_rcv,
   ......
}

之前提到的對Socket發起的系統IO呼叫,在核心中首先會呼叫Socket的檔案結構struct file中的file_operations檔案操作集合,然後呼叫struct socket中的ops指向的inet_stream_opssocket操作函式,最終呼叫到struct socksk_prot指標指向的tcp_prot 核心協議棧操作函式介面集合。

image

  • struct sock 物件中的sk_data_ready 函式指標設定為 sock_def_readable,在Socket資料就緒的時候核心會回撥該函式。

  • struct sock中的等待佇列中存放的是系統IO呼叫發生阻塞的程式fd,以及相應的回撥函式記住這個地方,後邊介紹epoll的時候我們還會提到!

  1. struct filestruct socketstruct sock這些核心的核心物件建立好之後,最後就是把socket物件對應的struct file放到程式開啟的檔案列表fd_array中。隨後系統呼叫accept返回socket的檔案描述符fd給使用者程式。

阻塞IO中使用者程式阻塞以及喚醒原理

在前邊小節我們介紹阻塞IO的時候提到,當使用者程式發起系統IO呼叫時,這裡我們拿read舉例,使用者程式會在核心態檢視對應Socket接收緩衝區是否有資料到來。

  • Socket接收緩衝區有資料,則拷貝資料到使用者空間,系統呼叫返回。
  • Socket接收緩衝區沒有資料,則使用者程式讓出CPU進入阻塞狀態,當資料到達接收緩衝區時,使用者程式會被喚醒,從阻塞狀態進入就緒狀態,等待CPU排程。

本小節我們就來看下使用者程式是如何阻塞Socket上,又是如何在Socket上被喚醒的。理解這個過程很重要,對我們理解epoll的事件通知過程很有幫助

  • 首先我們在使用者程式中對Socket進行read系統呼叫時,使用者程式會從使用者態轉為核心態
  • 在程式的struct task_struct結構找到fd_array,並根據Socket的檔案描述符fd找到對應的struct file,呼叫struct file中的檔案操作函式結合file_operationsread系統呼叫對應的是sock_read_iter
  • sock_read_iter函式中找到struct file指向的struct socket,並呼叫socket->ops->recvmsg,這裡我們知道呼叫的是inet_stream_ops集合中定義的inet_recvmsg
  • inet_recvmsg中會找到struct sock,並呼叫sock->skprot->recvmsg,這裡呼叫的是tcp_prot集合中定義的tcp_recvmsg函式。

整個呼叫過程可以參考上邊的《系統IO呼叫結構圖》

熟悉了核心函式呼叫棧後,我們來看下系統IO呼叫在tcp_recvmsg核心函式中是如何將使用者程式給阻塞掉的

image

int tcp_recvmsg(struct kiocb *iocb, struct sock *sk, struct msghdr *msg,
  size_t len, int nonblock, int flags, int *addr_len)
{
    .................省略非核心程式碼...............
   //訪問sock物件中定義的接收佇列
  skb_queue_walk(&sk->sk_receive_queue, skb) {

    .................省略非核心程式碼...............

  //沒有收到足夠資料,呼叫sk_wait_data 阻塞當前程式
  sk_wait_data(sk, &timeo);
}
int sk_wait_data(struct sock *sk, long *timeo)
{
 //建立struct sock中等待佇列上的元素wait_queue_t
 //將程式描述符和回撥函式autoremove_wake_function關聯到wait_queue_t中
 DEFINE_WAIT(wait);

 // 呼叫 sk_sleep 獲取 sock 物件下的等待佇列的頭指標wait_queue_head_t
 // 呼叫prepare_to_wait將新建立的等待項wait_queue_t插入到等待佇列中,並將程式狀態設定為可打斷 INTERRUPTIBLE
 prepare_to_wait(sk_sleep(sk), &wait, TASK_INTERRUPTIBLE);
 set_bit(SOCK_ASYNC_WAITDATA, &sk->sk_socket->flags);

 // 通過呼叫schedule_timeout讓出CPU,然後進行睡眠,導致一次上下文切換
 rc = sk_wait_event(sk, timeo, !skb_queue_empty(&sk->sk_receive_queue));
 ...
  • 首先會在DEFINE_WAIT中建立struct sock中等待佇列上的等待型別wait_queue_t
#define DEFINE_WAIT(name) DEFINE_WAIT_FUNC(name, autoremove_wake_function)

#define DEFINE_WAIT_FUNC(name, function)    \
 wait_queue_t name = {      \
  .private = current,    \
  .func  = function,    \
  .task_list = LIST_HEAD_INIT((name).task_list), \
 }

等待型別wait_queue_t中的private用來關聯阻塞在當前socket上的使用者程式fdfunc用來關聯等待項上註冊的回撥函式。這裡註冊的是autoremove_wake_function

  • 呼叫sk_sleep(sk)獲取struct sock物件中的等待佇列頭指標wait_queue_head_t

  • 呼叫prepare_to_wait將新建立的等待項wait_queue_t插入到等待佇列中,並將程式設定為可打斷 INTERRUPTIBL

  • 呼叫sk_wait_event 讓出CPU,程式進入睡眠狀態。

使用者程式的阻塞過程我們就介紹完了,關鍵是要理解記住struct sock中定義的等待佇列上的等待型別wait_queue_t的結構。後面epoll的介紹中我們還會用到它。

下面我們接著介紹當資料就緒後,使用者程式是如何被喚醒的

在本文開始介紹《網路包接收過程》這一小節中我們提到:

  • 當網路資料包到達網路卡時,網路卡通過DMA的方式將資料放到RingBuffer中。
  • 然後向CPU發起硬中斷,在硬中斷響應程式中建立sk_buffer,並將網路資料拷貝至sk_buffer中。
  • 隨後發起軟中斷,核心執行緒ksoftirqd 響應軟中斷,呼叫poll函式sk_buffer送往核心協議棧做層層協議處理。
  • 在傳輸層tcp_rcv 函式中,去掉TCP頭,根據四元組(源IP,源埠,目的IP,目的埠)查詢對應的Socket
  • 最後將sk_buffer放到Socket中的接收佇列裡。

上邊這些過程是核心接收網路資料的完整過程,下邊我們來看下,當資料包接收完畢後,使用者程式是如何被喚醒的。

image

  • 當軟中斷將sk_buffer放到Socket的接收佇列上時,接著就會呼叫資料就緒函式回撥指標sk_data_ready,前邊我們提到,這個函式指標在初始化的時候指向了sock_def_readable函式。

  • sock_def_readable函式中會去獲取socket->sock->sk_wq等待佇列。在wake_up_common函式中從等待佇列sk_wq中找出一個等待項wait_queue_t,回撥註冊在該等待項上的func回撥函式(wait_queue_t->func),建立等待項wait_queue_t是我們提到,這裡註冊的回撥函式是autoremove_wake_function

即使是有多個程式都阻塞在同一個 socket 上,也只喚醒 1 個程式。其作用是為了避免驚群。

  • autoremove_wake_function 函式中,根據等待項wait_queue_t上的private關聯的阻塞程式fd呼叫try_to_wake_up喚醒阻塞在該Socket上的程式。

記住wait_queue_t中的func函式指標,在epoll中這裡會註冊epoll的回撥函式。

現在理解epoll所需要的基礎知識我們就介紹完了,嘮叨了這麼多,下面終於正式進入本小節的主題epoll了。

epoll_create建立epoll物件

epoll_create是核心提供給我們建立epoll物件的一個系統呼叫,當我們在使用者程式中呼叫epoll_create時,核心會為我們建立一個struct eventpoll物件,並且也有相應的struct file與之關聯,同樣需要把這個struct eventpoll物件所關聯的struct file放入程式開啟的檔案列表fd_array中管理。

熟悉了Socket的建立邏輯,epoll的建立邏輯也就不難理解了。

struct eventpoll物件關聯的struct file中的file_operations 指標指向的是eventpoll_fops操作函式集合。

static const struct file_operations eventpoll_fops = {
     .release = ep_eventpoll_release;
     .poll = ep_eventpoll_poll,
}

image

struct eventpoll {

    //等待佇列,阻塞在epoll上的程式會放在這裡
    wait_queue_head_t wq;

    //就緒佇列,IO就緒的socket連線會放在這裡
    struct list_head rdllist;

    //紅黑樹用來管理所有監聽的socket連線
    struct rb_root rbr;

    ......
}
  • wait_queue_head_t wq:epoll中的等待佇列,佇列裡存放的是阻塞epoll上的使用者程式。在IO就緒的時候epoll可以通過這個佇列找到這些阻塞的程式並喚醒它們,從而執行IO呼叫讀寫Socket上的資料。

這裡注意與Socket中的等待佇列區分!!!

  • struct list_head rdllist:epoll中的就緒佇列,佇列裡存放的是都是IO就緒Socket,被喚醒的使用者程式可以直接讀取這個佇列獲取IO活躍Socket。無需再次遍歷整個Socket集合。

這裡正是epollselect ,poll高效之處,select ,poll返回的是全部的socket連線,我們需要在使用者空間再次遍歷找出真正IO活躍Socket連線。
epoll只是返回IO活躍Socket連線。使用者程式可以直接進行IO操作。

  • struct rb_root rbr : 由於紅黑樹在查詢插入刪除等綜合效能方面是最優的,所以epoll內部使用一顆紅黑樹來管理海量的Socket連線。

select陣列管理連線,poll連結串列管理連線。

epoll_ctl向epoll物件中新增監聽的Socket

當我們呼叫epoll_create在核心中建立出epoll物件struct eventpoll後,我們就可以利用epoll_ctlepoll中新增我們需要管理的Socket連線了。

  1. 首先要在epoll核心中建立一個表示Socket連線的資料結構struct epitem ,而在epoll中為了綜合效能的考慮,採用一顆紅黑樹來管理這些海量socket連線。所以struct epitem 是一個紅黑樹節點。

image

struct epitem
{
      //指向所屬epoll物件
      struct eventpoll *ep; 
      //註冊的感興趣的事件,也就是使用者空間的epoll_event     
      struct epoll_event event; 
      //指向epoll物件中的就緒佇列
      struct list_head rdllink;  
      //指向epoll中對應的紅黑樹節點
      struct rb_node rbn;     
      //指向epitem所表示的socket->file結構以及對應的fd
      struct epoll_filefd ffd;                  
  }

這裡重點記住struct epitem結構中的rdllink以及epoll_filefd成員,後面我們會用到。

  1. 在核心中建立完表示Socket連線的資料結構struct epitem後,我們就需要在Socket中的等待佇列上建立等待項wait_queue_t並且註冊epoll的回撥函式ep_poll_callback

通過《阻塞IO中使用者程式阻塞以及喚醒原理》小節的鋪墊,我想大家已經猜到這一步的意義所在了吧!當時在等待項wait_queue_t中註冊的是autoremove_wake_function回撥函式。還記得嗎?

epoll的回撥函式ep_poll_callback正是epoll同步IO事件通知機制的核心所在,也是區別於select,poll採用核心輪詢方式的根本效能差異所在。

image

這裡又出現了一個新的資料結構struct eppoll_entry,那它的作用是幹什麼的呢?大家可以結合上圖先猜測下它的作用!

我們知道socket->sock->sk_wq等待佇列中的型別是wait_queue_t,我們需要在struct epitem所表示的socket的等待佇列上註冊epoll回撥函式ep_poll_callback

這樣當資料到達socket中的接收佇列時,核心會回撥sk_data_ready,在阻塞IO中使用者程式阻塞以及喚醒原理這一小節中,我們知道這個sk_data_ready函式指標會指向sk_def_readable函式,在sk_def_readable中會回撥註冊在等待佇列裡的等待項wait_queue_t -> func回撥函式ep_poll_callbackep_poll_callback中需要找到epitem,將IO就緒epitem放入epoll中的就緒佇列中。

socket等待佇列中型別是wait_queue_t無法關聯到epitem。所以就出現了struct eppoll_entry結構體,它的作用就是關聯Socket等待佇列中的等待項wait_queue_tepitem

struct eppoll_entry { 
   //指向關聯的epitem
   struct epitem *base; 

  // 關聯監聽socket中等待佇列中的等待項 (private = null  func = ep_poll_callback)
   wait_queue_t wait;   

   // 監聽socket中等待佇列頭指標
   wait_queue_head_t *whead; 
    .........
  }; 

這樣在ep_poll_callback 回撥函式中就可以根據Socket等待佇列中的等待項wait,通過container_of巨集找到eppoll_entry,繼而找到epitem了。

container_of在Linux核心中是一個常用的巨集,用於從包含在某個結構中的指標獲得結構本身的指標,通俗地講就是通過結構體變數中某個成員的首地址進而獲得整個結構體變數的首地址。

這裡需要注意下這次等待項wait_queue_t中的private設定的是null,因為這裡Socket是交給epoll來管理的,阻塞在Socket上的程式是也由epoll來喚醒。在等待項wait_queue_t註冊的funcep_poll_callback而不是autoremove_wake_function阻塞程式並不需要autoremove_wake_function來喚醒,所以這裡設定privatenull

  1. 當在Socket的等待佇列中建立好等待項wait_queue_t並且註冊了epoll的回撥函式ep_poll_callback ,然後又通過eppoll_entry關聯了epitem後。
    剩下要做的就是將epitem插入到epoll中的紅黑樹struct rb_root rbr中。

這裡可以看到epoll另一個優化的地方,epoll將所有的socket連線通過核心中的紅黑樹來集中管理。每次新增或者刪除socket連線都是增量新增刪除,而不是像select,poll那樣每次呼叫都是全量socket連線集合傳入核心。避免了頻繁大量記憶體拷貝

epoll_wait同步阻塞獲取IO就緒的Socket

  1. 使用者程式呼叫epoll_wait後,核心首先會查詢epoll中的就緒佇列eventpoll->rdllist是否有IO就緒epitemepitem裡封裝了socket的資訊。如果就緒佇列中有就緒的epitem,就將就緒的socket資訊封裝到epoll_event 返回。

  2. 如果eventpoll->rdllist就緒佇列中沒有IO就緒epitem,則會建立等待項wait_queue_t,將使用者程式的fd關聯到wait_queue_t->private上,並在等待項wait_queue_t->func上註冊回撥函式default_wake_function 。最後將等待項新增到epoll中的等待佇列中。使用者程式讓出CPU,進入阻塞狀態

image

這裡和阻塞IO模型中的阻塞原理是一樣的,只不過在阻塞IO模型中註冊到等待項wait_queue_t->func上的是autoremove_wake_function,並將等待項新增到socket中的等待佇列中。這裡註冊的是default_wake_function,將等待項新增到epoll中的等待佇列上。

image

  1. 前邊做了那麼多的知識鋪墊,下面終於到了epoll的整個工作流程了:

image

  • 當網路資料包在軟中斷中經過核心協議棧的處理到達socket的接收緩衝區時,緊接著會呼叫socket的資料就緒回撥指標sk_data_ready,回撥函式為sock_def_readable。在socket的等待佇列中找出等待項,其中等待項中註冊的回撥函式為ep_poll_callback

  • 在回撥函式ep_poll_callback中,根據struct eppoll_entry中的struct wait_queue_t wait通過container_of巨集找到eppoll_entry物件並通過它的base指標找到封裝socket的資料結構struct epitem,並將它加入到epoll中的就緒佇列rdllist中。

  • 隨後檢視epoll中的等待佇列中是否有等待項,也就是說檢視是否有程式阻塞在epoll_wait上等待IO就緒socket。如果沒有等待項,則軟中斷處理完成。

  • 如果有等待項,則回到註冊在等待項中的回撥函式default_wake_function,在回撥函式中喚醒阻塞程式,並將就緒佇列rdllist中的epitemIO就緒socket資訊封裝到struct epoll_event中返回。

  • 使用者程式拿到epoll_event獲取IO就緒的socket,發起系統IO呼叫讀取資料。

再談水平觸發和邊緣觸發

網上有大量的關於這兩種模式的講解,大部分講的比較模糊,感覺只是強行從概念上進行描述,看完讓人難以理解。所以在這裡,筆者想結合上邊epoll的工作過程,再次對這兩種模式做下自己的解讀,力求清晰的解釋出這兩種工作模式的異同。

經過上邊對epoll工作過程的詳細解讀,我們知道,當我們監聽的socket上有資料到來時,軟中斷會執行epoll的回撥函式ep_poll_callback ,在回撥函式中會將epoll中描述socket資訊的資料結構epitem插入到epoll中的就緒佇列rdllist中。隨後使用者程式從epoll的等待佇列中被喚醒,epoll_waitIO就緒socket返回給使用者程式,隨即epoll_wait會清空rdllist

水平觸發邊緣觸發最關鍵的區別就在於當socket中的接收緩衝區還有資料可讀時。epoll_wait是否會清空rdllist

  • 水平觸發:在這種模式下,使用者執行緒呼叫epoll_wait獲取到IO就緒的socket後,對Socket進行系統IO呼叫讀取資料,假設socket中的資料只讀了一部分沒有全部讀完,這時再次呼叫epoll_waitepoll_wait會檢查這些Socket中的接收緩衝區是否還有資料可讀,如果還有資料可讀,就將socket重新放回rdllist。所以當socket上的IO沒有被處理完時,再次呼叫epoll_wait依然可以獲得這些socket,使用者程式可以接著處理socket上的IO事件。

  • 邊緣觸發: 在這種模式下,epoll_wait就會直接清空rdllist,不管socket上是否還有資料可讀。所以在邊緣觸發模式下,當你沒有來得及處理socket接收緩衝區的剩下可讀資料時,再次呼叫epoll_wait,因為這時rdlist已經被清空了,socket不會再次從epoll_wait中返回,所以使用者程式就不會再次獲得這個socket了,也就無法在對它進行IO處理了。除非,這個socket上有新的IO資料到達,根據epoll的工作過程,該socket會被再次放入rdllist中。

如果你在邊緣觸發模式下,處理了部分socket上的資料,那麼想要處理剩下部分的資料,就只能等到這個socket上再次有網路資料到達。

Netty中實現的EpollSocketChannel預設的就是邊緣觸發模式。JDKNIO預設是水平觸發模式。

epoll對select,poll的優化總結

  • epoll在核心中通過紅黑樹管理海量的連線,所以在呼叫epoll_wait獲取IO就緒的socket時,不需要傳入監聽的socket檔案描述符。從而避免了海量的檔案描述符集合在使用者空間核心空間中來回複製。

select,poll每次呼叫時都需要傳遞全量的檔案描述符集合,導致大量頻繁的拷貝操作。

  • epoll僅會通知IO就緒的socket。避免了在使用者空間遍歷的開銷。

select,poll只會在IO就緒的socket上打好標記,依然是全量返回,所以在使用者空間還需要使用者程式在一次遍歷全量集合找出具體IO就緒的socket。

  • epoll通過在socket的等待佇列上註冊回撥函式ep_poll_callback 通知使用者程式IO就緒的socket。避免了在核心中輪詢的開銷。

大部分情況下socket上並不總是IO活躍的,在面對海量連線的情況下,select,poll採用核心輪詢的方式獲取IO活躍的socket,無疑是效能低下的核心原因。

根據以上epoll的效能優勢,它是目前為止各大主流網路框架,以及反向代理中介軟體使用到的網路IO模型。

利用epoll多路複用IO模型可以輕鬆的解決C10K問題。

C100k的解決方案也還是基於C10K的方案,通過epoll 配合執行緒池,再加上 CPU、記憶體和網路介面的效能和容量提升。大部分情況下,C100K 很自然就可以達到。

甚至C1000K 的解決方法,本質上還是構建在 epoll多路複用 I/O 模型上。只不過,除了 I/O 模型之外,還需要從應用程式到 Linux 核心、再到 CPU、記憶體和網路等各個層次的深度優化,特別是需要藉助硬體,來解除安裝那些原來通過軟體處理的大量功能(去掉大量的中斷響應開銷以及核心協議棧處理的開銷)。

訊號驅動IO

訊號驅動IO.png

大家對這個裝備肯定不會陌生,當我們去一些美食城吃飯的時候,點完餐付了錢,老闆會給我們一個訊號器。然後我們帶著這個訊號器可以去找餐桌,或者幹些其他的事情。當訊號器亮了的時候,這時代表飯餐已經做好,我們可以去視窗取餐了。

這個典型的生活場景和我們要介紹的訊號驅動IO模型就很像。

訊號驅動IO模型下,使用者程式操作通過系統呼叫 sigaction 函式發起一個 IO 請求,在對應的socket註冊一個訊號回撥,此時不阻塞使用者程式,程式會繼續工作。當核心資料就緒時,核心就為該程式生成一個 SIGIO 訊號,通過訊號回撥通知程式進行相關 IO 操作。

這裡需要注意的是:訊號驅動式 IO 模型依然是同步IO,因為它雖然可以在等待資料的時候不被阻塞,也不會頻繁的輪詢,但是當資料就緒,核心訊號通知後,使用者程式依然要自己去讀取資料,在資料拷貝階段發生阻塞。

訊號驅動 IO模型 相比於前三種 IO 模型,實現了在等待資料就緒時,程式不被阻塞,主迴圈可以繼續工作,所以理論上效能更佳。

但是實際上,使用TCP協議通訊時,訊號驅動IO模型 幾乎不會被採用。原因如下:

  • 訊號IO 在大量 IO 操作時可能會因為訊號佇列溢位導致沒法通知
  • SIGIO 訊號是一種 Unix 訊號,訊號沒有附加資訊,如果一個訊號源有多種產生訊號的原因,訊號接收者就無法確定究竟發生了什麼。而 TCP socket 生產的訊號事件有七種之多,這樣應用程式收到 SIGIO,根本無從區分處理。

訊號驅動IO模型 可以用在 UDP 通訊上,因為UDP 只有一個資料請求事件,這也就意味著在正常情況下 UDP 程式只要捕獲 SIGIO 訊號,就呼叫 read 系統呼叫讀取到達的資料。如果出現異常,就返回一個異常錯誤。


這裡插句題外話,大家覺不覺得阻塞IO模型在生活中的例子就像是我們在食堂排隊打飯。你自己需要排隊去打飯同時打飯師傅在配菜的過程中你需要等待。

阻塞IO.png

IO多路複用模型就像是我們在飯店門口排隊等待叫號。叫號器就好比select,poll,epoll可以統一管理全部顧客的吃飯就緒事件,客戶好比是socket連線,誰可以去吃飯了,叫號器就通知誰。

IO多路複用.png

非同步IO(AIO)

以上介紹的四種IO模型均為同步IO,它們都會阻塞在第二階段資料拷貝階段

通過在前邊小節《同步與非同步》中的介紹,相信大家很容易就會理解非同步IO模型,在非同步IO模型下,IO操作在資料準備階段資料拷貝階段均是由核心來完成,不會對應用程式造成任何阻塞。應用程式只需要在指定的陣列中引用資料即可。

非同步 IO訊號驅動 IO 的主要區別在於:訊號驅動 IO 由核心通知何時可以開始一個 IO 操作,而非同步 IO 由核心通知 IO 操作何時已經完成

舉個生活中的例子:非同步IO模型就像我們去一個高檔飯店裡的包間吃飯,我們只需要坐在包間裡面,點完餐(類比非同步IO呼叫)之後,我們就什麼也不需要管,該喝酒喝酒,該聊天聊天,飯餐做好後服務員(類比核心)會自己給我們送到包間(類比使用者空間)來。整個過程沒有任何阻塞。

非同步IO.png

非同步IO的系統呼叫需要作業系統核心來支援,目前只有Window中的IOCP實現了非常成熟的非同步IO機制

Linux系統對非同步IO機制實現的不夠成熟,且與NIO的效能相比提升也不明顯。

但Linux kernel 在5.1版本由Facebook的大神Jens Axboe引入了新的非同步IO庫io_uring 改善了原來Linux native AIO的一些效能問題。效能相比Epoll以及之前原生的AIO提高了不少,值得關注。

再加上訊號驅動IO模型不適用TCP協議,所以目前大部分採用的還是IO多路複用模型

IO執行緒模型

在前邊內容的介紹中,我們詳述了網路資料包的接收和傳送過程,並通過介紹5種IO模型瞭解了核心是如何讀取網路資料並通知給使用者執行緒的。

前邊的內容都是以核心空間的視角來剖析網路資料的收發模型,本小節我們站在使用者空間的視角來看下如果對網路資料進行收發。

相對核心來講,使用者空間的IO執行緒模型相對就簡單一些。這些使用者空間IO執行緒模型都是在討論當多執行緒一起配合工作時誰負責接收連線,誰負責響應IO 讀寫、誰負責計算、誰負責傳送和接收,僅僅是使用者IO執行緒的不同分工模式罷了。

Reactor

Reactor是利用NIOIO執行緒進行不同的分工:

  • 使用前邊我們提到的IO多路複用模型比如select,poll,epoll,kqueue,進行IO事件的註冊和監聽。
  • 將監聽到就緒的IO事件分發dispatch 到各個具體的處理Handler中進行相應的IO事件處理

通過IO多路複用技術就可以不斷的監聽IO事件,不斷的分發dispatch,就像一個反應堆一樣,看起來像不斷的產生IO事件,因此我們稱這種模式為Reactor模型。

下面我們來看下Reactor模型的三種分類:

單Reactor單執行緒

單Reactor單執行緒

Reactor模型是依賴IO多路複用技術實現監聽IO事件,從而源源不斷的產生IO就緒事件,在Linux系統下我們使用epoll來進行IO多路複用,我們以Linux系統為例:

  • Reactor意味著只有一個epoll物件,用來監聽所有的事件,比如連線事件讀寫事件
  • 單執行緒意味著只有一個執行緒來執行epoll_wait獲取IO就緒Socket,然後對這些就緒的Socket執行讀寫,以及後邊的業務處理也依然是這個執行緒。

單Reactor單執行緒模型就好比我們開了一個很小很小的小飯館,作為老闆的我們需要一個人幹所有的事情,包括:迎接顧客(accept事件),為顧客介紹選單等待顧客點菜(IO請求),做菜(業務處理),上菜(IO響應),送客(斷開連線)。

單Reactor多執行緒

隨著客人的增多(併發請求),顯然飯館裡的事情只有我們一個人幹(單執行緒)肯定是忙不過來的,這時候我們就需要多招聘一些員工(多執行緒)來幫著一起幹上述的事情。

於是就有了單Reactor多執行緒模型:

單Reactor多執行緒

  • 這種模式下,也是隻有一個epoll物件來監聽所有的IO事件,一個執行緒來呼叫epoll_wait獲取IO就緒Socket
  • 但是當IO就緒事件產生時,這些IO事件對應處理的業務Handler,我們是通過執行緒池來執行。這樣相比單Reactor單執行緒模型提高了執行效率,充分發揮了多核CPU的優勢。

主從Reactor多執行緒

做任何事情都要區分事情的優先順序,我們應該優先高效的去做優先順序更高的事情,而不是一股腦不分優先順序的全部去做。

當我們的小飯館客人越來越多(併發量越來越大),我們就需要擴大飯店的規模,在這個過程中我們發現,迎接客人是飯店最重要的工作,我們要先把客人迎接進來,不能讓客人一看人多就走掉,只要客人進來了,哪怕菜做的慢一點也沒關係。

於是,主從Reactor多執行緒模型就產生了:

主從Reactor多執行緒

  • 我們由原來的單Reactor變為了多Reactor主Reactor用來優先專門做優先順序最高的事情,也就是迎接客人(處理連線事件),對應的處理Handler就是圖中的acceptor

  • 當建立好連線,建立好對應的socket後,在acceptor中將要監聽的read事件註冊到從Reactor中,由從Reactor來監聽socket上的讀寫事件。

  • 最終將讀寫的業務邏輯處理交給執行緒池處理。

注意:這裡向從Reactor註冊的只是read事件,並沒有註冊write事件,因為read事件是由epoll核心觸發的,而write事件則是由使用者業務執行緒觸發的(什麼時候傳送資料是由具體業務執行緒決定的),所以write事件理應是由使用者業務執行緒去註冊。

使用者執行緒註冊write事件的時機是隻有當使用者傳送的資料無法一次性全部寫入buffer時,才會去註冊write事件,等待buffer重新可寫時,繼續寫入剩下的傳送資料、如果使用者執行緒可以一股腦的將傳送資料全部寫入buffer,那麼也就無需註冊write事件從Reactor中。

主從Reactor多執行緒模型是現在大部分主流網路框架中採用的一種IO執行緒模型。我們本系列的主題Netty就是用的這種模型。

Proactor

Proactor是基於AIOIO執行緒進行分工的一種模型。前邊我們介紹了非同步IO模型,它是作業系統核心支援的一種全非同步程式設計模型,在資料準備階段資料拷貝階段全程無阻塞。

ProactorIO執行緒模型IO事件的監聽IO操作的執行IO結果的dispatch統統交給核心來做。

image

Proactor模型元件介紹:

  • completion handler 為使用者程式定義的非同步IO操作回撥函式,在非同步IO操作完成時會被核心回撥並通知IO結果。

  • Completion Event Queue 非同步IO操作完成後,會產生對應的IO完成事件,將IO完成事件放入該佇列中。

  • Asynchronous Operation Processor 負責非同步IO的執行。執行完成後產生IO完成事件放入Completion Event Queue 佇列中。

  • Proactor 是一個事件迴圈派發器,負責從Completion Event Queue中獲取IO完成事件,並回撥與IO完成事件關聯的completion handler

  • Initiator 初始化非同步操作(asynchronous operation)並通過Asynchronous Operation Processorcompletion handlerproactor註冊到核心。

Proactor模型執行過程:

  • 使用者執行緒發起aio_read,並告訴核心使用者空間中的讀緩衝區地址,以便核心完成IO操作將結果放入使用者空間的讀緩衝區,使用者執行緒直接可以讀取結果(無任何阻塞)。

  • Initiator 初始化aio_read非同步讀取操作(asynchronous operation),並將completion handler註冊到核心。

Proactor中我們關心的IO完成事件:核心已經幫我們讀好資料並放入我們指定的讀緩衝區,使用者執行緒可以直接讀取。
Reactor中我們關心的是IO就緒事件:資料已經到來,但是需要使用者執行緒自己去讀取。

  • 此時使用者執行緒就可以做其他事情了,無需等待IO結果。而核心與此同時開始非同步執行IO操作。當IO操作完成時會產生一個completion event事件,將這個IO完成事件放入completion event queue中。

  • Proactorcompletion event queue中取出completion event,並回撥與IO完成事件關聯的completion handler

  • completion handler中完成業務邏輯處理。

Reactor與Proactor對比

  • Reactor是基於NIO實現的一種IO執行緒模型Proactor是基於AIO
    實現的IO執行緒模型

  • Reactor關心的是IO就緒事件Proactor關心的是IO完成事件

  • Proactor中,使用者程式需要向核心傳遞使用者空間的讀緩衝區地址Reactor則不需要。這也就導致了在Proactor中每個併發操作都要求有獨立的快取區,在記憶體上有一定的開銷。

  • Proactor 的實現邏輯複雜,編碼成本較 Reactor 要高很多。

  • Proactor 在處理高耗時 IO 時的效能要高於 Reactor,但對於低耗時 IO 的執行效率提升並不明顯

Netty的IO模型

在我們介紹完網路資料包在核心中的收發過程以及五種IO模型和兩種IO執行緒模型後,現在我們來看下netty中的IO模型是什麼樣的。

在我們介紹Reactor IO執行緒模型的時候提到有三種Reactor模型單Reactor單執行緒單Reactor多執行緒主從Reactor多執行緒

這三種Reactor模型netty中都是支援的,但是我們常用的是主從Reactor多執行緒模型

而我們之前介紹的三種Reactor只是一種模型,是一種設計思想。實際上各種網路框架在實現中並不是嚴格按照模型來實現的,會有一些小的不同,但大體設計思想上是一樣的。

下面我們來看下netty中的主從Reactor多執行緒模型是什麼樣子的?

image

  • Reactornetty中是以group的形式出現的,netty中將Reactor分為兩組,一組是MainReactorGroup也就是我們在編碼中常常看到的EventLoopGroup bossGroup,另一組是SubReactorGroup也就是我們在編碼中常常看到的EventLoopGroup workerGroup

  • MainReactorGroup中通常只有一個Reactor,專門負責做最重要的事情,也就是監聽連線accept事件。當有連線事件產生時,在對應的處理handler acceptor中建立初始化相應的NioSocketChannel(代表一個Socket連線)。然後以負載均衡的方式在SubReactorGroup中選取一個Reactor,註冊上去,監聽Read事件

MainReactorGroup中只有一個Reactor的原因是,通常我們服務端程式只會繫結監聽一個埠,如果要繫結監聽多個埠,就會配置多個Reactor

  • SubReactorGroup中有多個Reactor,具體Reactor的個數可以由系統引數 -D io.netty.eventLoopThreads指定。預設的Reactor的個數為CPU核數 * 2SubReactorGroup中的Reactor主要負責監聽讀寫事件,每一個Reactor負責監聽一組socket連線。將全量的連線分攤在多個Reactor中。

  • 一個Reactor分配一個IO執行緒,這個IO執行緒負責從Reactor中獲取IO就緒事件,執行IO呼叫獲取IO資料,執行PipeLine

Socket連線在建立後就被固定的分配給一個Reactor,所以一個Socket連線也只會被一個固定的IO執行緒執行,每個Socket連線分配一個獨立的PipeLine例項,用來編排這個Socket連線上的IO處理邏輯。這種無鎖序列化的設計的目的是為了防止多執行緒併發執行同一個socket連線上的IO邏輯處理,防止出現執行緒安全問題。同時使系統吞吐量達到最大化

由於每個Reactor中只有一個IO執行緒,這個IO執行緒既要執行IO活躍Socket連線對應的PipeLine中的ChannelHandler,又要從Reactor中獲取IO就緒事件,執行IO呼叫。所以PipeLineChannelHandler中執行的邏輯不能耗時太長,儘量將耗時的業務邏輯處理放入單獨的業務執行緒池中處理,否則會影響其他連線的IO讀寫,從而近一步影響整個服務程式的IO吞吐

  • IO請求在業務執行緒中完成相應的業務邏輯處理後,在業務執行緒中利用持有的ChannelHandlerContext引用將響應資料在PipeLine中反向傳播,最終寫回給客戶端。

netty中的IO模型我們介紹完了,下面我們來簡單介紹下在netty中是如何支援前邊提到的三種Reactor模型的。

配置單Reactor單執行緒

EventLoopGroup eventGroup = new NioEventLoopGroup(1);
ServerBootstrap serverBootstrap = new ServerBootstrap(); 
serverBootstrap.group(eventGroup);

配置多Reactor多執行緒

EventLoopGroup eventGroup = new NioEventLoopGroup();
ServerBootstrap serverBootstrap = new ServerBootstrap(); 
serverBootstrap.group(eventGroup);

配置主從Reactor多執行緒

EventLoopGroup bossGroup = new NioEventLoopGroup(1); 
EventLoopGroup workerGroup = new NioEventLoopGroup();
ServerBootstrap serverBootstrap = new ServerBootstrap(); 
serverBootstrap.group(bossGroup, workerGroup);

總結

本文是一篇資訊量比較大的文章,用了25張圖,22336個字從核心如何處理網路資料包的收發過程開始展開,隨後又在核心角度介紹了經常容易混淆的阻塞與非阻塞同步與非同步的概念。以這個作為鋪墊,我們通過一個C10K的問題,引出了五種IO模型,隨後在IO多路複用中以技術演進的形式介紹了select,poll,epoll的原理和它們綜合的對比。最後我們介紹了兩種IO執行緒模型以及netty中的Reactor模型

感謝大家聽我嘮叨到這裡,哈哈,現在大家可以揉揉眼,伸個懶腰,好好休息一下了。

歡迎關注微信公眾號:bin的技術小屋

相關文章