聊聊 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_opssocket 操作函數,最終調用到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