聊聊 Netty 那些事兒之從內核角度看 IO 模型
從今天開始我們來聊聊 Netty 的那些事兒,我們都知道 Netty 是一個高性能異步事件驅動的網絡框架。
它的設計異常優雅簡潔,擴展性高,穩定性強。擁有非常詳細完整的用戶文檔。
同時內置了很多非常有用的模塊基本上做到了開箱即用,用戶只需要編寫短短几行代碼,就可以快速構建出一個具有高吞吐
,低延時
,更少的資源消耗
,高性能(非必要的內存拷貝最小化)
等特徵的高併發網絡應用程序。
本文我們來探討下支持 Netty 具有高吞吐
,低延時
特徵的基石 ----netty 的網絡IO模型
。
由 Netty 的網絡IO模型
開始,我們來正式揭開本系列 Netty 源碼解析的序幕:
網絡包接收流程
網絡包收發過程. png
- 當
網絡數據幀
通過網絡傳輸到達網卡時,網卡會將網絡數據幀通過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拷貝
網絡數據包到內存
中的開銷。
網絡包發送流程
網絡包發送過程. 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
,並將原來的 sk_buffer拷貝
到多個小的 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
接收緩衝區中無數據
,系統調用立馬返回,並帶有一個EWOULDBLOCK
或EAGAIN
錯誤,這個階段用戶線程不會阻塞
,也不會讓出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
數組拷貝
到內核空間
。
這裏的文件描述符數組其實是一個BitMap
,BitMap
下標爲文件描述符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
數組中值爲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
的創建過程有很大的幫助。
進程中管理文件列表結構
進程中管理文件列表結構. 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_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 內核結構. png
在我們進行網絡程序的編寫時會首先創建一個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
內核協議棧操作函數接口集合。
系統 IO 調用結構. png
-
將
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
內核函數中是如何將用戶進程給阻塞掉的
系統 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
上的用戶進程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
中的接收隊列裏。
上邊這些過程是內核接收網絡數據的完整過程,下邊我們來看下,當數據包接收完畢後,用戶進程是如何被喚醒的。
系統 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 中的等待隊列,隊列裏存放的是阻塞
在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.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
成員,後面我們會用到。
- 在內核中創建完表示
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_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,進入阻塞狀態
。
epoll_wait 同步獲取數據. png
這裏和
阻塞IO模型
中的阻塞原理是一樣的,只不過在阻塞IO模型
中註冊到等待項wait_queue_t->func
上的是autoremove_wake_function
,並將等待項添加到socket
中的等待隊列中。這裏註冊的是default_wake_function
,將等待項添加到epoll
中的等待隊列上。
數據到來 epoll_wait 流程. png
- 前邊做了那麼多的知識鋪墊,下面終於到了
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
中的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.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
是利用NIO
對IO線程
進行不同的分工:
-
使用前邊我們提到的
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
是基於AIO
對IO線程
進行分工的一種模型。前邊我們介紹了異步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 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多線程模型
是什麼樣子的?
netty 中的 reactor.png
-
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模型
。
感謝大家聽我嘮叨到這裏,哈哈,現在大家可以揉揉眼,伸個懶腰,好好休息一下了。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/zAh1yD5IfwuoYdrZ1tGf5Q