從今天開始我們來聊聊Netty的那些事兒,我們都知道Netty是一個高效能非同步事件驅動的網路框架。
它的設計異常優雅簡潔,擴充套件性高,穩定性強。擁有非常詳細完整的使用者文件。
同時內建了很多非常有用的模組基本上做到了開箱即用,使用者只需要編寫短短几行程式碼,就可以快速構建出一個具有高吞吐
,低延時
,更少的資源消耗
,高效能(非必要的記憶體拷貝最小化)
等特徵的高併發網路應用程式。
本文我們來探討下支援Netty具有高吞吐
,低延時
特徵的基石----netty的網路IO模型
。
由Netty的網路IO模型
開始,我們來正式揭開本系列Netty原始碼解析的序幕:
網路包接收流程
- 當
網路資料幀
通過網路傳輸到達網路卡時,網路卡會將網路資料幀通過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/0
和ksoftirqd/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拷貝
網路資料包到記憶體
中的開銷。
網路包傳送流程
-
當我們在應用程式中呼叫
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
的時候,在把指標移動一下就行,避免頻繁的記憶體申請和拷貝,效率很高。
為什麼不直接使用
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中特別容易混淆的概念:阻塞與同步
,非阻塞與非同步
。
網上各種博文還有各種書籍中有大量的關於這兩個概念的解釋,但是筆者覺得還是不夠形象化,只是對概念的生硬解釋,如果硬套概念的話,其實感覺阻塞與同步
,非阻塞與非同步
還是沒啥區別,時間長了,還是比較模糊容易混淆。
所以筆者在這裡嘗試換一種更加形象化,更加容易理解記憶的方式來清晰地解釋下什麼是阻塞與非阻塞
,什麼是同步與非同步
。
經過前邊對網路資料包接收流程的介紹,在這裡我們可以將整個流程總結為兩個階段:
-
資料準備階段: 在這個階段,網路資料包到達網路卡,通過
DMA
的方式將資料包拷貝到記憶體中,然後經過硬中斷,軟中斷,接著通過核心執行緒ksoftirqd
經過核心協議棧的處理,最終將資料傳送到核心Socket
的接收緩衝區中。 -
資料拷貝階段: 當資料到達
核心Socket
的接收緩衝區中時,此時資料存在於核心空間
中,需要將資料拷貝
到使用者空間
中,才能夠被應用程式讀取。
阻塞與非阻塞
阻塞與非阻塞的區別主要發生在第一階段:資料準備階段
。
當應用程式發起系統呼叫read
時,執行緒從使用者態轉為核心態,讀取核心Socket
的接收緩衝區中的網路資料。
阻塞
如果這時核心Socket
的接收緩衝區沒有資料,那麼執行緒就會一直等待
,直到Socket
接收緩衝區有資料為止。隨後將資料從核心空間拷貝到使用者空間,系統呼叫read
返回。
從圖中我們可以看出:阻塞的特點是在第一階段和第二階段都會等待
。
非阻塞
阻塞
和非阻塞
主要的區分是在第一階段:資料準備階段
。
-
在第一階段,當
Socket
的接收緩衝區中沒有資料的時候,阻塞模式下
應用執行緒會一直等待。非阻塞模式下
應用執行緒不會等待,系統呼叫
直接返回錯誤標誌EWOULDBLOCK
。 -
當
Socket
的接收緩衝區中有資料的時候,阻塞
和非阻塞
的表現是一樣的,都會進入第二階段等待
資料從核心空間
拷貝到使用者空間
,然後系統呼叫返回
。
從上圖中,我們可以看出:非阻塞的特點是第一階段不會等待
,但是在第二階段還是會等待
。
同步與非同步
同步
與非同步
主要的區別發生在第二階段:資料拷貝階段
。
前邊我們提到在資料拷貝階段
主要是將資料從核心空間
拷貝到使用者空間
。然後應用程式才可以讀取資料。
當核心Socket
的接收緩衝區有資料到達時,進入第二階段。
同步
同步模式
在資料準備好後,是由使用者執行緒
的核心態
來執行第二階段
。所以應用程式會在第二階段發生阻塞
,直到資料從核心空間
拷貝到使用者空間
,系統呼叫才會返回。
Linux下的 epoll
和Mac 下的 kqueue
都屬於同步 IO
。
非同步
非同步模式
下是由核心
來執行第二階段的資料拷貝操作,當核心
執行完第二階段,會通知使用者執行緒IO操作已經完成,並將資料回撥給使用者執行緒。所以在非同步模式
下 資料準備階段
和資料拷貝階段
均是由核心
來完成,不會對應用程式造成任何阻塞。
基於以上特徵,我們可以看到非同步模式
需要核心的支援,比較依賴作業系統底層的支援。
在目前流行的作業系統中,只有Windows 中的 IOCP
才真正屬於非同步 IO,實現的也非常成熟。但Windows很少用來作為伺服器使用。
而常用來作為伺服器使用的Linux,非同步IO機制
實現的不夠成熟,與NIO相比效能提升的也不夠明顯。
但Linux kernel 在5.1版本由Facebook的大神Jens Axboe引入了新的非同步IO庫io_uring
改善了原來Linux native AIO的一些效能問題。效能相比Epoll
以及之前原生的AIO
提高了不少,值得關注。
IO模型
在進行網路IO操作時,用什麼樣的IO模型來讀寫資料將在很大程度上決定了網路框架的IO效能。所以IO模型的選擇是構建一個高效能網路框架的基礎。
在《UNIX 網路程式設計》一書中介紹了五種IO模型:阻塞IO
,非阻塞IO
,IO多路複用
,訊號驅動IO
,非同步IO
,每一種IO模型的出現都是對前一種的升級優化。
下面我們就來分別介紹下這五種IO模型各自都解決了什麼問題,適用於哪些場景,各自的優缺點是什麼?
阻塞IO(BIO)
經過前一小節對阻塞
這個概念的介紹,相信大家可以很容易理解阻塞IO
的概念和過程。
既然這小節我們談的是IO
,那麼下邊我們來看下在阻塞IO
模型下,網路資料的讀寫過程。
阻塞讀
當使用者執行緒發起read
系統呼叫,使用者執行緒從使用者態切換到核心態,在核心中去檢視Socket
接收緩衝區是否有資料到來。
-
Socket
接收緩衝區中有資料
,則使用者執行緒在核心態將核心空間中的資料拷貝到使用者空間,系統IO呼叫返回。 -
Socket
接收緩衝區中無資料
,則使用者執行緒讓出CPU,進入阻塞狀態
。當資料到達Socket
接收緩衝區後,核心喚醒阻塞狀態
中的使用者執行緒進入就緒狀態
,隨後經過CPU的排程獲取到CPU quota
進入執行狀態
,將核心空間的資料拷貝到使用者空間,隨後系統呼叫返回。
阻塞寫
當使用者執行緒發起send
系統呼叫時,使用者執行緒從使用者態切換到核心態,將傳送資料從使用者空間拷貝到核心空間中的Socket
傳送緩衝區中。
-
當
Socket
傳送緩衝區能夠容納下傳送資料時,使用者執行緒會將全部的傳送資料寫入Socket
緩衝區,然後執行在《網路包傳送流程》這小節介紹的後續流程,然後返回。 -
當
Socket
傳送緩衝區空間不夠,無法容納下全部傳送資料時,使用者執行緒讓出CPU,進入阻塞狀態
,直到Socket
傳送緩衝區能夠容納下全部傳送資料時,核心喚醒使用者執行緒,執行後續傳送流程。
阻塞IO
模型下的寫操作做事風格比較硬剛,非得要把全部的傳送資料寫入傳送緩衝區才肯善罷甘休。
阻塞IO模型
由於阻塞IO
的讀寫特點,所以導致在阻塞IO
模型下,每個請求都需要被一個獨立的執行緒處理。一個執行緒在同一時刻只能與一個連線繫結。來一個請求,服務端就需要建立一個執行緒用來處理請求。
當客戶端請求的併發量突然增大時,服務端在一瞬間就會建立出大量的執行緒,而建立執行緒是需要系統資源開銷的,這樣一來就會一瞬間佔用大量的系統資源。
如果客戶端建立好連線後,但是一直不發資料,通常大部分情況下,網路連線也並不
總是有資料可讀,那麼在空閒的這段時間內,服務端執行緒就會一直處於阻塞狀態
,無法幹其他的事情。CPU也無法得到充分的發揮
,同時還會導致大量執行緒切換的開銷
。
適用場景
基於以上阻塞IO模型
的特點,該模型只適用於連線數少
,併發度低
的業務場景。
比如公司內部的一些管理系統,通常請求數在100個左右,使用阻塞IO模型
還是非常適合的。而且效能還不輸NIO。
該模型在C10K之前,是普遍被採用的一種IO模型。
非阻塞IO(NIO)
阻塞IO模型
最大的問題就是一個執行緒只能處理一個連線,如果這個連線上沒有資料的話,那麼這個執行緒就只能阻塞在系統IO呼叫上,不能幹其他的事情。這對系統資源來說,是一種極大的浪費。同時大量的執行緒上下文切換,也是一個巨大的系統開銷。
所以為了解決這個問題,我們就需要用盡可能少的執行緒去處理更多的連線。,網路IO模型的演變
也是根據這個需求來一步一步演進的。
基於這個需求,第一種解決方案非阻塞IO
就出現了。我們在上一小節中介紹了非阻塞
的概念,現在我們來看下網路讀寫操作在非阻塞IO
下的特點:
非阻塞讀
當使用者執行緒發起非阻塞read
系統呼叫時,使用者執行緒從使用者態
轉為核心態
,在核心中去檢視Socket
接收緩衝區是否有資料到來。
-
Socket
接收緩衝區中無資料
,系統呼叫立馬返回,並帶有一個EWOULDBLOCK
或EAGAIN
錯誤,這個階段使用者執行緒不會阻塞
,也不會讓出CPU
,而是會繼續輪訓
直到Socket
接收緩衝區中有資料為止。 -
Socket
接收緩衝區中有資料
,使用者執行緒在核心態
會將核心空間
中的資料拷貝到使用者空間
,注意這個資料拷貝階段,應用程式是阻塞的
,當資料拷貝完成,系統呼叫返回。
非阻塞寫
前邊我們在介紹阻塞寫
的時候提到阻塞寫
的風格特別的硬朗,頭比較鐵非要把全部傳送資料一次性都寫到Socket
的傳送緩衝區中才返回,如果傳送緩衝區中沒有足夠的空間容納,那麼就一直阻塞死等,特別的剛。
相比較而言非阻塞寫
的特點就比較佛系,當傳送緩衝區中沒有足夠的空間容納全部傳送資料時,非阻塞寫
的特點是能寫多少寫多少
,寫不下了,就立即返回。並將寫入到傳送緩衝區的位元組數返回給應用程式,方便使用者執行緒不斷的輪訓
嘗試將剩下的資料
寫入傳送緩衝區中。
非阻塞IO模型
基於以上非阻塞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
系統呼叫的時候會阻塞
在select
系統呼叫上。此時,使用者執行緒從使用者態
切換到了核心態
完成了一次上下文切換
-
使用者執行緒將需要監聽的
Socket
對應的檔案描述符fd
陣列通過select
系統呼叫傳遞給核心。此時,使用者執行緒將使用者空間
中的檔案描述符fd
陣列拷貝
到核心空間
。
這裡的檔案描述符陣列其實是一個BitMap
,BitMap
下標為檔案描述符fd
,下標對應的值為:1
表示該fd
上有讀寫事件,0
表示該fd
上沒有讀寫事件。
檔案描述符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
陣列中值為1
的Socket
檔案描述符。最後對這些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_ZERO
和FD_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
的建立過程有很大的幫助。
程式中管理檔案列表結構
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_iter
。Socket
發起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
,然後基於這個Socket
進行bind
,listen
,我們先將這個Socket
稱作為監聽Socket
。
- 當我們呼叫
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
。
- 接著核心會為
已連線的Socket
建立struct file
並初始化,並把Socket檔案操作函式集合(socket_file_ops
)賦值給struct file
中的f_ops
指標。然後將struct socket
中的file
指標指向這個新分配申請的struct file
結構體。
核心會維護兩個佇列:
- 一個是已經完成
TCP三次握手
,連線狀態處於established
的連線佇列。核心中為icsk_accept_queue
。- 一個是還沒有完成
TCP三次握手
,連線狀態處於syn_rcvd
的半連線佇列。
- 然後呼叫
socket->ops->accept
,從Socket核心結構圖
中我們可以看到其實呼叫的是inet_accept
,該函式會在icsk_accept_queue
中查詢是否有已經建立好的連線,如果有的話,直接從icsk_accept_queue
中獲取已經建立好的struct sock
。並將這個struct sock
物件賦值給struct socket
中的sock
指標。
struct sock
在struct socket
中是一個非常核心的核心物件,正是在這裡定義了我們在介紹網路包的接收傳送流程
中提到的接收佇列
,傳送佇列
,等待佇列
,資料就緒回撥函式指標
,核心協議棧操作函式集合
- 根據建立
Socket
時發起的系統呼叫sock_create
中的protocol
引數(對於TCP協議
這裡的引數值為SOCK_STREAM
)查詢到對於 tcp 定義的操作方法實現集合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 sock
中sk_prot
指標指向的tcp_prot
核心協議棧操作函式介面集合。
-
將
struct sock
物件中的sk_data_ready
函式指標設定為sock_def_readable
,在Socket
資料就緒的時候核心會回撥該函式。 -
struct sock
中的等待佇列
中存放的是系統IO呼叫發生阻塞的程式fd
,以及相應的回撥函式
。記住這個地方,後邊介紹epoll的時候我們還會提到!
- 當
struct file
,struct socket
,struct 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_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
核心函式中是如何將使用者程式給阻塞掉的
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
上的使用者程式fd
。func
用來關聯等待項上註冊的回撥函式。這裡註冊的是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
中的接收佇列裡。
上邊這些過程是核心接收網路資料的完整過程,下邊我們來看下,當資料包接收完畢後,使用者程式是如何被喚醒的。
-
當軟中斷將
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,
}
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
集合。
這裡正是
epoll
比select ,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_ctl
向epoll
中新增我們需要管理的Socket
連線了。
- 首先要在epoll核心中建立一個表示
Socket連線
的資料結構struct epitem
,而在epoll
中為了綜合效能的考慮,採用一顆紅黑樹來管理這些海量socket連線
。所以struct epitem
是一個紅黑樹節點。
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
成員,後面我們會用到。
- 在核心中建立完表示
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
採用核心輪詢方式的根本效能差異所在。
這裡又出現了一個新的資料結構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_callback
。在ep_poll_callback
中需要找到epitem
,將IO就緒
的epitem
放入epoll
中的就緒佇列中。
而socket
等待佇列中型別是wait_queue_t
無法關聯到epitem
。所以就出現了struct eppoll_entry
結構體,它的作用就是關聯Socket
等待佇列中的等待項wait_queue_t
和epitem
。
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
註冊的func
是ep_poll_callback
而不是autoremove_wake_function
,阻塞程式
並不需要autoremove_wake_function
來喚醒,所以這裡設定private
為null
- 當在
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
-
使用者程式呼叫
epoll_wait
後,核心首先會查詢epoll中的就緒佇列eventpoll->rdllist
是否有IO就緒
的epitem
。epitem
裡封裝了socket
的資訊。如果就緒佇列中有就緒的epitem
,就將就緒的socket
資訊封裝到epoll_event
返回。 -
如果
eventpoll->rdllist
就緒佇列中沒有IO就緒
的epitem
,則會建立等待項wait_queue_t
,將使用者程式的fd
關聯到wait_queue_t->private
上,並在等待項wait_queue_t->func
上註冊回撥函式default_wake_function
。最後將等待項新增到epoll
中的等待佇列中。使用者程式讓出CPU,進入阻塞狀態
。
這裡和
阻塞IO模型
中的阻塞原理是一樣的,只不過在阻塞IO模型
中註冊到等待項wait_queue_t->func
上的是autoremove_wake_function
,並將等待項新增到socket
中的等待佇列中。這裡註冊的是default_wake_function
,將等待項新增到epoll
中的等待佇列上。
- 前邊做了那麼多的知識鋪墊,下面終於到了
epoll
的整個工作流程了:
-
當網路資料包在軟中斷中經過核心協議棧的處理到達
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
中的epitem
的IO就緒
socket資訊封裝到struct epoll_event
中返回。 -
使用者程式拿到
epoll_event
獲取IO就緒
的socket,發起系統IO呼叫讀取資料。
再談水平觸發和邊緣觸發
網上有大量的關於這兩種模式的講解,大部分講的比較模糊,感覺只是強行從概念上進行描述,看完讓人難以理解。所以在這裡,筆者想結合上邊epoll
的工作過程,再次對這兩種模式做下自己的解讀,力求清晰的解釋出這兩種工作模式的異同。
經過上邊對epoll
工作過程的詳細解讀,我們知道,當我們監聽的socket
上有資料到來時,軟中斷會執行epoll
的回撥函式ep_poll_callback
,在回撥函式中會將epoll
中描述socket資訊
的資料結構epitem
插入到epoll
中的就緒佇列rdllist
中。隨後使用者程式從epoll
的等待佇列中被喚醒,epoll_wait
將IO就緒
的socket
返回給使用者程式,隨即epoll_wait
會清空rdllist
。
水平觸發和邊緣觸發最關鍵的區別就在於當socket
中的接收緩衝區還有資料可讀時。epoll_wait
是否會清空rdllist
。
-
水平觸發:在這種模式下,使用者執行緒呼叫
epoll_wait
獲取到IO就緒
的socket後,對Socket
進行系統IO呼叫讀取資料,假設socket
中的資料只讀了一部分沒有全部讀完,這時再次呼叫epoll_wait
,epoll_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
預設的就是邊緣觸發
模式。JDK
的NIO
預設是水平觸發
模式。
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模型
就很像。
在訊號驅動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多路複用模型
就像是我們在飯店門口排隊等待叫號。叫號器就好比select,poll,epoll
可以統一管理全部顧客的吃飯就緒
事件,客戶好比是socket
連線,誰可以去吃飯了,叫號器就通知誰。
非同步IO(AIO)
以上介紹的四種IO模型
均為同步IO
,它們都會阻塞在第二階段資料拷貝階段
。
通過在前邊小節《同步與非同步》中的介紹,相信大家很容易就會理解非同步IO模型
,在非同步IO模型
下,IO操作在資料準備階段
和資料拷貝階段
均是由核心來完成,不會對應用程式造成任何阻塞。應用程式只需要在指定的陣列
中引用資料即可。
非同步 IO
與訊號驅動 IO
的主要區別在於:訊號驅動 IO
由核心通知何時可以開始一個 IO 操作
,而非同步 IO
由核心通知 IO 操作何時已經完成
。
舉個生活中的例子:非同步IO模型
就像我們去一個高檔飯店裡的包間吃飯,我們只需要坐在包間裡面,點完餐(類比非同步IO呼叫
)之後,我們就什麼也不需要管,該喝酒喝酒,該聊天聊天,飯餐做好後服務員(類比核心
)會自己給我們送到包間(類比使用者空間
)來。整個過程沒有任何阻塞。
非同步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
是利用NIO
對IO執行緒
進行不同的分工:
- 使用前邊我們提到的
IO多路複用模型
比如select,poll,epoll,kqueue
,進行IO事件的註冊和監聽。 - 將監聽到
就緒的IO事件
分發dispatch
到各個具體的處理Handler
中進行相應的IO事件處理
。
通過IO多路複用技術
就可以不斷的監聽IO事件
,不斷的分發dispatch
,就像一個反應堆
一樣,看起來像不斷的產生IO事件
,因此我們稱這種模式為Reactor
模型。
下面我們來看下Reactor模型
的三種分類:
單Reactor單執行緒
Reactor模型
是依賴IO多路複用技術
實現監聽IO事件
,從而源源不斷的產生IO就緒事件
,在Linux系統下我們使用epoll
來進行IO多路複用
,我們以Linux系統為例:
- 單
Reactor
意味著只有一個epoll
物件,用來監聽所有的事件,比如連線事件
,讀寫事件
。 單執行緒
意味著只有一個執行緒來執行epoll_wait
獲取IO就緒
的Socket
,然後對這些就緒的Socket
執行讀寫,以及後邊的業務處理也依然是這個執行緒。
單Reactor單執行緒
模型就好比我們開了一個很小很小的小飯館,作為老闆的我們需要一個人幹所有的事情,包括:迎接顧客(accept事件
),為顧客介紹選單等待顧客點菜(IO請求
),做菜(業務處理
),上菜(IO響應
),送客(斷開連線
)。
單Reactor多執行緒
隨著客人的增多(併發請求
),顯然飯館裡的事情只有我們一個人幹(單執行緒
)肯定是忙不過來的,這時候我們就需要多招聘一些員工(多執行緒
)來幫著一起幹上述的事情。
於是就有了單Reactor多執行緒
模型:
- 這種模式下,也是隻有一個
epoll
物件來監聽所有的IO事件
,一個執行緒來呼叫epoll_wait
獲取IO就緒
的Socket
。 - 但是當
IO就緒事件
產生時,這些IO事件
對應處理的業務Handler
,我們是通過執行緒池來執行。這樣相比單Reactor單執行緒
模型提高了執行效率,充分發揮了多核CPU的優勢。
主從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
是基於AIO
對IO執行緒
進行分工的一種模型。前邊我們介紹了非同步IO模型
,它是作業系統核心支援的一種全非同步程式設計模型,在資料準備階段
和資料拷貝階段
全程無阻塞。
ProactorIO執行緒模型
將IO事件的監聽
,IO操作的執行
,IO結果的dispatch
統統交給核心
來做。
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 Processor
將completion handler
和proactor
註冊到核心。
Proactor模型
執行過程:
-
使用者執行緒發起
aio_read
,並告訴核心
使用者空間中的讀緩衝區地址,以便核心
完成IO操作
將結果放入使用者空間
的讀緩衝區,使用者執行緒直接可以讀取結果(無任何阻塞
)。 -
Initiator
初始化aio_read
非同步讀取操作(asynchronous operation
),並將completion handler
註冊到核心。
在
Proactor
中我們關心的IO完成事件
:核心已經幫我們讀好資料並放入我們指定的讀緩衝區,使用者執行緒可以直接讀取。
在Reactor
中我們關心的是IO就緒事件
:資料已經到來,但是需要使用者執行緒自己去讀取。
-
此時使用者執行緒就可以做其他事情了,無需等待IO結果。而核心與此同時開始非同步執行IO操作。當
IO操作
完成時會產生一個completion event
事件,將這個IO完成事件
放入completion event queue
中。 -
Proactor
從completion 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多執行緒模型
是什麼樣子的?
-
Reactor
在netty
中是以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核數 * 2
。SubReactorGroup
中的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呼叫
。所以PipeLine
中ChannelHandler
中執行的邏輯不能耗時太長,儘量將耗時的業務邏輯處理放入單獨的業務執行緒池中處理,否則會影響其他連線的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的技術小屋