3萬字 | 34 圖 | Netty | 核心角度看IO模型

語言: CN / TW / HK

大家好,我是悟空呀。

今天我們從核心角度來聊下 Netty 的 IO 模型。這篇很長,建議收藏起來慢慢看~

我們都知道Netty是一個高效能非同步事件驅動的網路框架。 它的設計異常優雅簡潔,擴充套件性高,穩定性強。擁有非常詳細完整的使用者文件。

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

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

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

網路包接收流程

網路包收發過程.png
  • 網路資料幀
    DMA的方式
    環形緩衝區RingBuffer
    

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

  • DMA操作完成
    硬中斷
    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拷貝 網路資料包到 記憶體 中的開銷。

網路包傳送流程

網路包傳送過程.png
  • 當我們在應用程式中呼叫 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 的時候,在把指標移動一下就行,避免頻繁的記憶體申請和拷貝,效率很高。

sk_buffer.png

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

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

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

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

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

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

    • Socket
      Socket
      sk_buffer
      
  • 核心協議棧 網路層 的事情處理完後,現在傳送流程進入了到了 鄰居子系統鄰居子系統 位於核心協議棧中的 網路層網路介面層 之間,用於傳送 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
      拷貝
      

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

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

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

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

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

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

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

阻塞與非阻塞

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

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

阻塞

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

阻塞IO.png

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

非阻塞

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

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

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

非阻塞IO.png

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

同步與非同步

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

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

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

同步

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

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

同步IO.png

非同步

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

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

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

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

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

非同步IO.png

IO模型

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

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

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

阻塞IO(BIO)

阻塞IO.png

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

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

阻塞讀

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

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

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

阻塞寫

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

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

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

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

阻塞IO模型

阻塞IO模型.png

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

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

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

適用場景

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

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

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

非阻塞IO(NIO)

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

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

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

非阻塞IO.png

非阻塞讀

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

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

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

非阻塞寫

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

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

非阻塞IO模型

非阻塞IO模型.png

基於以上 非阻塞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 系統呼叫將 輪詢 的操作交給了 核心 來幫助我們完成,從而避免了在 使用者空間 不斷的發起輪詢所帶來的的系統性能開銷。

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

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

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

fd陣列BitMap.png

檔案描述符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
    重置
    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 的建立過程有很大的幫助。

程序中管理檔案列表結構

程序中管理檔案列表結構.png

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核心結構

Socket核心結構.png

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

  1. accept
    監聽Socket
    Socket
    監聽Socket
    Socket操作函式集合
    inet_stream_ops
    ops
    Socket
    ops
    
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_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
    inet_stream_ops
    tcp_prot
    socket->ops
    sock->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_ops socket操作函式,最終呼叫到 struct socksk_prot 指標指向的 tcp_prot 核心協議棧操作函式介面集合。

系統IO呼叫結構.png
  • struct sock 物件中的 sk_data_ready 函式指標設定為 sock_def_readable ,在 Socket 資料就緒的時候核心會回撥該函式。

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

  1. struct file
    struct socket
    struct sock
    socket
    struct file
    fd_array
    accept
    socket
    fd
    

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

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

  • Socket 接收緩衝區有資料,則拷貝資料到 使用者空間 ,系統呼叫返回。
  • Socket
    CPU
    阻塞狀態
    阻塞狀態
    就緒狀態
    

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

  • Socket
    read
    使用者態
    核心態
    
  • struct task_struct
    fd_array
    Socket
    fd
    struct file
    struct file
    file_operations
    read
    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 核心函式中是如何將使用者程序給阻塞掉的

系統IO呼叫阻塞原理.png
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 函式
    四元組(源IP,源埠,目的IP,目的埠)
    Socket
    
  • 最後將 sk_buffer 放到 Socket 中的接收佇列裡。

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

系統IO呼叫喚醒原理.png
  • 當軟中斷將 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,
}
eopll在程序中的整體結構.png
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
    IO就緒
    epoll
    阻塞
    IO呼叫
    Socket
    

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

  • struct list_head rdllist:
    IO就緒
    Socket
    IO活躍
    Socket
    Socket
    

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

  • struct rb_root rbr :
    查詢
    插入
    刪除
    Socket
    

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

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

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

  1. Socket連線
    struct epitem
    epoll
    socket連線
    struct epitem
    
struct epitem.png
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 採用核心輪詢方式的根本效能差異所在。

epitem建立等待項.png

這裡又出現了一個新的資料結構 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,進入 阻塞狀態

epoll_wait同步獲取資料.png

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

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

epoll_wait處理過程.png
  • 當網路資料包在軟中斷中經過核心協議棧的處理到達 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就緒
    使用者空間
    核心空間
    

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

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

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

  • epoll
    socket
    ep_poll_callback
    IO就緒
    

大部分情況下 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單執行緒
    

主從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 統統交給 核心 來做。

proactor.png

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多執行緒模型 是什麼樣子的?

netty中的reactor.png
  • 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);

總結

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