主頁 > 後端開發 > 聊聊Netty那些事兒之從內核角度看IO模型

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

2022-07-04 01:28:42 後端開發

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

它的設計例外優雅簡潔,擴展性高,穩定性強,擁有非常詳細完整的用戶檔案,

同時內置了很多非常有用的模塊基本上做到了開箱即用,用戶只需要撰寫短短幾行代碼,就可以快速構建出一個具有高吞吐低延時更少的資源消耗高性能(非必要的記憶體拷貝最小化)等特征的高并發網路應用程式,

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

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

網路包接收流程

image

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

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

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

sk_buff 緩沖區,是一個維護網路幀結構的雙向鏈表,鏈表中的每一個元素都是一個網路幀,雖然 TCP/IP 協議堆疊分了好幾層,但上下不同層之間的傳遞,實際上只需要操作這個資料結構中的指標,而無需進行資料復制

  • 內核執行緒ksoftirqd發現有軟中斷請求到來,隨后呼叫網卡驅動注冊的poll函式poll函式sk_buffer中的網路資料包送到內核協議堆疊中注冊的ip_rcv函式中,

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

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

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

傳輸層的處理函式:TCP協議對應內核協議堆疊中注冊的tcp_rcv函式UDP協議對應內核協議堆疊中注冊的udp_rcv函式

  • 當我們采用的是TCP協議時,資料包到達傳輸層時,會在內核協議堆疊中的tcp_rcv函式處理,在tcp_rcv函式中去掉TCP頭,根據四元組(源IP,源埠,目的IP,目的埠)查找對應的Socket,如果找到對應的Socket則將網路資料包中的傳輸資料拷貝到Socket中的接識訓沖區中,如果沒有找到,則發送一個目標不可達icmp包,

  • 內核在接收網路資料包時所做的作業我們就介紹完了,現在我們把視角放到應用層,當我們程式通過系統呼叫read讀取Socket接識訓沖區中的資料時,如果接識訓沖區中沒有資料,那么應用程式就會在系統呼叫上阻塞,直到Socket接識訓沖區有資料,然后CPU內核空間(Socket接識訓沖區)的資料拷貝用戶空間,最后系統呼叫read回傳,應用程式讀取資料,

性能開銷

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

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

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

網路包發送流程

image

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

  • 呼叫內核協議堆疊函式inet_sendmsg ,發送流程進入內核協議堆疊處理,在進入到內核協議堆疊之后,內核會找到Socket上的具體協議的發送函式,

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

  • TCP協議的發送函式tcp_sendmsg中,創建內核資料結構sk_buffer,將
    struct msghdr結構體中的發送資料拷貝sk_buffer中,呼叫tcp_write_queue_tail函式獲取Socket發送佇列中的隊尾元素,將新創建的sk_buffer添加到Socket發送佇列的尾部,

Socket的發送佇列是由sk_buffer組成的一個雙向鏈表

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

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

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

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

image

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

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

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

    通過route命令可以查看本機路由配置,

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

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

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

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

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

    • 選擇發送佇列(RingBuffer),因為網卡擁有多個發送佇列,所以在發送前需要選擇一個發送佇列,
    • sk_buffer添加到發送佇列中,
    • 回圈從發送佇列(RingBuffer)中取出sk_buffer,呼叫內核函式sch_direct_xmit 發送資料,其中會呼叫網卡驅動程式來發送資料,

以上程序全部是用戶執行緒的內核態在執行,占用的CPU時間是系統態時間(sy),當分配給用戶執行緒的CPU quota用完的時候,會觸發NET_TX_SOFTIRQ型別的軟中斷,內核執行緒ksoftirqd 會回應這個軟中斷,并執行NET_TX_SOFTIRQ型別的軟中斷注冊的回呼函式net_tx_action ,在回呼函式中會執行到驅動程式函式 dev_hard_start_xmit來發送資料,

注意:當觸發NET_TX_SOFTIRQ軟中斷來發送資料時,后邊消耗的 CPU 就都顯示在 si 這里了,不會消耗用戶行程的系統態時間(sy)了,

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

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

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

  • 當資料發送完畢后,還有最后一項重要的作業,就是清理作業,資料發送完畢后,網卡設備會向CPU發送一個硬中斷,CPU呼叫網卡驅動程式注冊的硬中斷回應程式,在硬中斷回應中觸發NET_RX_SOFTIRQ型別的軟中斷,在軟中斷的回呼函式igb_poll 中清理釋放 sk_buffer,清理網卡發送佇列(RingBuffer),解除 DMA 映射,

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

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

性能開銷

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

  • 和接收資料一樣,應用程式在呼叫系統呼叫send的時候會從用戶態轉為內核態以及發送完資料后,系統呼叫回傳時從內核態轉為用戶態的開銷,

  • 用戶執行緒內核態CPU quota用盡時觸發NET_TX_SOFTIRQ型別軟中斷,內核回應軟中斷的開銷,

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

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

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

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

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

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

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

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

image

  • 資料準備階段: 在這個階段,網路資料包到達網卡,通過DMA
    的方式將資料包拷貝到記憶體中,然后經過硬中斷,軟中斷,接著通過內核執行緒ksoftirqd 經過內核協議堆疊的處理,最終將資料發送到內核Socket的接識訓沖區中,

  • 資料拷貝階段: 當資料到達內核Socket的接識訓沖區中時,此時資料存在于內核空間中,需要將資料拷貝用戶空間中,才能夠被應用程式讀取,

阻塞與非阻塞

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

當應用程式發起系統呼叫read時,執行緒從用戶態轉為內核態,讀取內核Socket的接識訓沖區中的網路資料,

阻塞

如果這時內核Socket的接識訓沖區沒有資料,那么執行緒就會一直等待,直到Socket接識訓沖區有資料為止,隨后將資料從內核空間拷貝到用戶空間,系統呼叫read回傳,

image

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

非阻塞

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

  • 在第一階段,當Socket的接識訓沖區中沒有資料的時候,阻塞模式下應用執行緒會一直等待,非阻塞模式下應用執行緒不會等待,系統呼叫直接回傳錯誤標志EWOULDBLOCK

  • Socket的接識訓沖區中有資料的時候,阻塞非阻塞的表現是一樣的,都會進入第二階段等待資料從內核空間拷貝到用戶空間,然后系統呼叫回傳

image

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

同步與異步

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

前邊我們提到在資料拷貝階段主要是將資料從內核空間拷貝到用戶空間,然后應用程式才可以讀取資料,

當內核Socket的接識訓沖區有資料到達時,進入第二階段,

同步

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

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

image

異步

異步模式下是由內核來執行第二階段的資料拷貝操作,當內核執行完第二階段,會通知用戶執行緒IO操作已經完成,并將資料回呼給用戶執行緒,所以在異步模式資料準備階段資料拷貝階段均是由內核來完成,不會對應用程式造成任何阻塞,

基于以上特征,我們可以看到異步模式需要內核的支持,比較依賴作業系統底層的支持,

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

而常用來作為服務器使用的Linux,異步IO機制實作的不夠成熟,與NIO相比性能提升的也不夠明顯,

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

image

IO模型

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

在《UNIX 網路編程》一書中介紹了五種IO模型:阻塞IO,非阻塞IO,IO多路復用,信號驅動IO,異步IO,每一種IO模型的出現都是對前一種的升級優化,

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

阻塞IO(BIO)

image

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

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

阻塞讀

當用戶執行緒發起read系統呼叫,用戶執行緒從用戶態切換到內核態,在內核中去查看Socket接識訓沖區是否有資料到來,

  • Socket接識訓沖區中有資料,則用戶執行緒在內核態將內核空間中的資料拷貝到用戶空間,系統IO呼叫回傳,

  • Socket接識訓沖區中無資料,則用戶執行緒讓出CPU,進入阻塞狀態,當資料到達Socket接識訓沖區后,內核喚醒阻塞狀態中的用戶執行緒進入就緒狀態,隨后經過CPU的調度獲取到CPU quota進入運行狀態,將內核空間的資料拷貝到用戶空間,隨后系統呼叫回傳,

阻塞寫

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

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

  • Socket發送緩沖區空間不夠,無法容納下全部發送資料時,用戶執行緒讓出CPU,進入阻塞狀態,直到Socket發送緩沖區能夠容納下全部發送資料時,內核喚醒用戶執行緒,執行后續發送流程,

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

阻塞IO模型

image

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

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

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

適用場景

基于以上阻塞IO模型的特點,該模型只適用于連接數少并發度低的業務場景,

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

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

非阻塞IO(NIO)

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

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

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

image

非阻塞讀

當用戶執行緒發起非阻塞read系統呼叫時,用戶執行緒從用戶態轉為內核態,在內核中去查看Socket接識訓沖區是否有資料到來,

  • Socket接識訓沖區中無資料,系統呼叫立馬回傳,并帶有一個 EWOULDBLOCKEAGAIN 錯誤,這個階段用戶執行緒不會阻塞,也不會讓出CPU,而是會繼續輪訓直到Socket接識訓沖區中有資料為止,

  • Socket接識訓沖區中有資料,用戶執行緒在內核態會將內核空間中的資料拷貝到用戶空間注意這個資料拷貝階段,應用程式是阻塞的,當資料拷貝完成,系統呼叫回傳,

非阻塞寫

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

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

非阻塞IO模型

image

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

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

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

適用場景

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

但是它仍然有很大的性能問題,因為在非阻塞IO模型下,需要用戶執行緒去不斷地發起系統呼叫去輪訓Socket接識訓沖區,這就需要用戶執行緒不斷地從用戶態切換到內核態內核態切換到用戶態,隨著并發量的增大,這個背景關系切換的開銷也是巨大的,

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

IO多路復用

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

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

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

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

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

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

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

但是非阻塞IO模型最大的問題就是需要不斷的發起系統呼叫去輪詢各個Socket中的接識訓沖區是否有資料到來,頻繁系統呼叫隨之帶來了大量的背景關系切換開銷,隨著并發量的提升,這樣也會導致非常嚴重的性能問題,

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

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

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

select

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

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

image

  • 首先用戶執行緒在發起select系統呼叫的時候會阻塞select系統呼叫上,此時,用戶執行緒從用戶態切換到了內核態完成了一次背景關系切換

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

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

image

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

  • 當用戶執行緒呼叫完select后開始進入阻塞狀態內核開始輪詢遍歷fd陣列,查看fd對應的Socket接識訓沖區中是否有資料到來,如果有資料到來,則將fd對應BitMap的值設定為1,如果沒有資料到來,則保持值為0

注意這里內核會修改原始的fd陣列!!

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

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

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

  • 由于內核在遍歷的程序中已經修改了fd陣列,所以在用戶執行緒遍歷完fd陣列后獲取到IO就緒Socket后,就需要重置fd陣列,并重新呼叫select傳入重置后的fd陣列,讓內核發起新的一輪遍歷輪詢,

API介紹

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

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

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

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

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

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

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

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

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

上小節提到,在內核遍歷完fd陣列后,發現有IO就緒fd,則會將該fd對應的BitMap中的值設定為1,并將修改后的fd陣列,回傳給用戶執行緒,

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

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

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

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

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

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

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

性能開銷

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

  • 在發起select系統呼叫以及回傳時,用戶執行緒各發生了一次用戶態內核態以及內核態用戶態的背景關系切換開銷,發生2次背景關系切換

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

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

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

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

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

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

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

很明顯select也不能解決C10K問題,只適用于1000個左右的并發連接場景,

poll

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

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

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

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

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

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

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

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

epoll

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

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

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

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

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

Socket的創建

服務端執行緒呼叫accept系統呼叫后開始阻塞,當有客戶端連接上來并完成TCP三次握手后,內核會創建一個對應的Socket作為服務端與客戶端通信的內核介面,

在Linux內核的角度看來,一切皆是檔案,Socket也不例外,當內核創建出Socket之后,會將這個Socket放到當前行程所打開的檔案串列中管理起來,

下面我們來看下行程管理這些打開的檔案串列相關的內核資料結構是什么樣的?在了解完這些資料結構后,我們會更加清晰的理解Socket在內核中所發揮的作用,并且對后面我們理解epoll的創建程序有很大的幫助,

行程中管理檔案串列結構

image

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

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

對于任何一個行程,默認情況下,檔案描述符 0 表示 stdin 標準輸入,檔案描述符 1 表示 stdout 標準輸出,檔案描述符2表示 stderr 標準錯誤輸出

行程中打開的檔案串列fd_array定義在內核資料結構struct files_struct中,在struct fdtable結構中有一個指標struct fd **fd指向fd_array

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

用于封裝檔案元資訊的內核資料結構struct file中的private_data指標指向具體的Socket結構,

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

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


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

Socket內核結構

image

在我們進行網路程式的撰寫時會首先創建一個Socket,然后基于這個Socket進行bindlisten,我們先將這個Socket稱作為監聽Socket

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

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

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

內核會維護兩個佇列:

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

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

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

這里可以回看下本小節開頭的《Socket內核結構圖》捋一下他們之間的關系,

socket相關的操作介面定義在inet_stream_ops函式集合中,負責對上給用戶提供介面,而socket與內核協議堆疊之間的操作介面定義在struct sock中的sk_prot 指標上,這里指向tcp_prot協議操作函式集合,

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

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

image

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

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

  1. struct filestruct socketstruct sock這些核心的內核物件創建好之后,最后就是把socket物件對應的struct file放到行程打開的檔案串列fd_array中,隨后系統呼叫accept回傳socket的檔案描述符fd給用戶程式,

阻塞IO中用戶行程阻塞以及喚醒原理

在前邊小節我們介紹阻塞IO的時候提到,當用戶行程發起系統IO呼叫時,這里我們拿read舉例,用戶行程會在內核態查看對應Socket接識訓沖區是否有資料到來,

  • Socket接識訓沖區有資料,則拷貝資料到用戶空間,系統呼叫回傳,
  • Socket接識訓沖區沒有資料,則用戶行程讓出CPU進入阻塞狀態,當資料到達接識訓沖區時,用戶行程會被喚醒,從阻塞狀態進入就緒狀態,等待CPU調度,

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

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

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

熟悉了內核函式呼叫堆疊后,我們來看下系統IO呼叫在tcp_recvmsg內核函式中是如何將用戶行程給阻塞掉的

image

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

    .................省略非核心代碼...............

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

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

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

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

等待型別wait_queue_t中的private用來關聯阻塞在當前socket上的用戶行程fdfunc用來關聯等待項上注冊的回呼函式,這里注冊的是autoremove_wake_function

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

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

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

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

下面我們接著介紹當資料就緒后,用戶行程是如何被喚醒的

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

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

上邊這些程序是內核接收網路資料的完整程序,下邊我們來看下,當資料包接收完畢后,用戶行程是如何被喚醒的,

image

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

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

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

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

記住wait_queue_t中的func函式指標,在epoll中這里會注冊epoll的回呼函式,

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

epoll_create創建epoll物件

epoll_create是內核提供給我們創建epoll物件的一個系統呼叫,當我們在用戶行程中呼叫epoll_create時,內核會為我們創建一個struct eventpoll物件,并且也有相應的struct file與之關聯,同樣需要把這個struct eventpoll物件所關聯的struct file放入行程打開的檔案串列fd_array中管理,

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

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

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

image

struct eventpoll {

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

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

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

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

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

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

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

  • struct rb_root rbr : 由于紅黑樹在查找插入洗掉等綜合性能方面是最優的,所以epoll內部使用一顆紅黑樹來管理海量的Socket連接,

select陣列管理連接,poll鏈表管理連接,

epoll_ctl向epoll物件中添加監聽的Socket

當我們呼叫epoll_create在內核中創建出epoll物件struct eventpoll后,我們就可以利用epoll_ctlepoll中添加我們需要管理的Socket連接了,

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

image

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

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

  1. 在內核中創建完表示Socket連接的資料結構struct epitem后,我們就需要在Socket中的等待佇列上創建等待項wait_queue_t并且注冊epoll的回呼函式ep_poll_callback

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

epoll的回呼函式ep_poll_callback正是epoll同步IO事件通知機制的核心所在,也是區別于select,poll采用內核輪詢方式的根本性能差異所在,

image

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

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

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

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

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

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

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

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

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

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

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

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

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

  1. 用戶程式呼叫epoll_wait后,內核首先會查找epoll中的就緒佇列eventpoll->rdllist是否有IO就緒epitemepitem里封裝了socket的資訊,如果就緒佇列中有就緒的epitem,就將就緒的socket資訊封裝到epoll_event 回傳,

  2. 如果eventpoll->rdllist就緒佇列中沒有IO就緒epitem,則會創建等待項wait_queue_t,將用戶行程的fd關聯到wait_queue_t->private上,并在等待項wait_queue_t->func上注冊回呼函式default_wake_function ,最后將等待項添加到epoll中的等待佇列中,用戶行程讓出CPU,進入阻塞狀態

image

這里和阻塞IO模型中的阻塞原理是一樣的,只不過在阻塞IO模型中注冊到等待項wait_queue_t->func上的是autoremove_wake_function,并將等待項添加到socket中的等待佇列中,這里注冊的是default_wake_function,將等待項添加到epoll中的等待佇列上,

image

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

image

  • 當網路資料包在軟中斷中經過內核協議堆疊的處理到達socket的接識訓沖區時,緊接著會呼叫socket的資料就緒回呼指標sk_data_ready,回呼函式為sock_def_readable,在socket的等待佇列中找出等待項,其中等待項中注冊的回呼函式為ep_poll_callback

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

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

  • 如果有等待項,則回到注冊在等待項中的回呼函式default_wake_function,在回呼函式中喚醒阻塞行程,并將就緒佇列rdllist中的epitemIO就緒socket資訊封裝到struct epoll_event中回傳,

  • 用戶行程拿到epoll_event獲取IO就緒的socket,發起系統IO呼叫讀取資料,

再談水平觸發和邊緣觸發

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

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

水平觸發邊緣觸發最關鍵的區別就在于當socket中的接識訓沖區還有資料可讀時,epoll_wait是否會清空rdllist

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

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

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

Netty中實作的EpollSocketChannel默認的就是邊緣觸發模式,JDKNIO默認是水平觸發模式,

epoll對select,poll的優化總結

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

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

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

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

  • epoll通過在socket的等待佇列上注冊回呼函式ep_poll_callback 通知用戶程式IO就緒的socket,避免了在內核中輪詢的開銷,

大部分情況下socket上并不總是IO活躍的,在面對海量連接的情況下,select,poll采用內核輪詢的方式獲取IO活躍的socket,無疑是性能低下的核心原因,

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

利用epoll多路復用IO模型可以輕松的解決C10K問題,

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

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

信號驅動IO

信號驅動IO.png

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

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

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

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

信號驅動 IO模型 相比于前三種 IO 模型,實作了在等待資料就緒時,行程不被阻塞,主回圈可以繼續作業,所以理論上性能更佳,

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

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

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


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

阻塞IO.png

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

IO多路復用.png

異步IO(AIO)

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

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

異步 IO信號驅動 IO 的主要區別在于:信號驅動 IO 由內核通知何時可以開始一個 IO 操作,而異步 IO 由內核通知 IO 操作何時已經完成

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

異步IO.png

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

Linux系統對異步IO機制實作的不夠成熟,且與NIO的性能相比提升也不明顯,

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

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

IO執行緒模型

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

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

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

Reactor

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

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

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

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

單Reactor單執行緒

單Reactor單執行緒

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

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

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

單Reactor多執行緒

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

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

單Reactor多執行緒

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

主從Reactor多執行緒

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

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

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

主從Reactor多執行緒

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

  • 當創建好連接,建立好對應的socket后,在acceptor中將要監聽的read事件注冊到從Reactor中,由從Reactor來監聽socket上的讀寫事件,

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

注意:這里向從Reactor注冊的只是read事件,并沒有注冊write事件,因為read事件是由epoll內核觸發的,而write事件則是由用戶業務執行緒觸發的(什么時候發送資料是由具體業務執行緒決定的),所以write事件理應是由用戶業務執行緒去注冊,

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

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

Proactor

Proactor是基于AIOIO執行緒進行分工的一種模型,前邊我們介紹了異步IO模型,它是作業系統內核支持的一種全異步編程模型,在資料準備階段資料拷貝階段全程無阻塞,

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

image

Proactor模型組件介紹:

  • completion handler 為用戶程式定義的異步IO操作回呼函式,在異步IO操作完成時會被內核回呼并通知IO結果,

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

  • Asynchronous Operation Processor 負責異步IO的執行,執行完成后產生IO完成事件放入Completion Event Queue 佇列中,

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

  • Initiator 初始化異步操作(asynchronous operation)并通過Asynchronous Operation Processorcompletion handlerproactor注冊到內核,

Proactor模型執行程序:

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

  • Initiator 初始化aio_read異步讀取操作(asynchronous operation),并將completion handler注冊到內核,

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

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

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

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

Reactor與Proactor對比

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

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

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

  • Proactor 的實作邏輯復雜,編碼成本較 Reactor 要高很多,

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

Netty的IO模型

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

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

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

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

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

image

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

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

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

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

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

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

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

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

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

配置單Reactor單執行緒

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

配置多Reactor多執行緒

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

配置主從Reactor多執行緒

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

總結

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

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

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

轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/497774.html

標籤:其他

上一篇:用于字串插值的PL/SQL轉義字串

下一篇:利用MySQL實作分布式鎖,涉及到樂觀鎖和悲觀鎖的思想

標籤雲
其他(157675) Python(38076) JavaScript(25376) Java(17977) C(15215) 區塊鏈(8255) C#(7972) AI(7469) 爪哇(7425) MySQL(7132) html(6777) 基礎類(6313) sql(6102) 熊猫(6058) PHP(5869) 数组(5741) R(5409) Linux(5327) 反应(5209) 腳本語言(PerlPython)(5129) 非技術區(4971) Android(4554) 数据框(4311) css(4259) 节点.js(4032) C語言(3288) json(3245) 列表(3129) 扑(3119) C++語言(3117) 安卓(2998) 打字稿(2995) VBA(2789) Java相關(2746) 疑難問題(2699) 细绳(2522) 單片機工控(2479) iOS(2429) ASP.NET(2402) MongoDB(2323) 麻木的(2285) 正则表达式(2254) 字典(2211) 循环(2198) 迅速(2185) 擅长(2169) 镖(2155) 功能(1967) .NET技术(1958) Web開發(1951) python-3.x(1918) HtmlCss(1915) 弹簧靴(1913) C++(1909) xml(1889) PostgreSQL(1872) .NETCore(1853) 谷歌表格(1846) Unity3D(1843) for循环(1842)

熱門瀏覽
  • 【C++】Microsoft C++、C 和匯編程式檔案

    ......

    uj5u.com 2020-09-10 00:57:23 more
  • 例外宣告

    相比于斷言適用于排除邏輯上不可能存在的狀態,例外通常是用于邏輯上可能發生的錯誤。 例外宣告 Item 1:當函式不可能拋出例外或不能接受拋出例外時,使用noexcept 理由 如果不打算拋出例外的話,程式就會認為無法處理這種錯誤,并且應當盡早終止,如此可以有效地阻止例外的傳播與擴散。 示例 //不可 ......

    uj5u.com 2020-09-10 00:57:27 more
  • Codeforces 1400E Clear the Multiset(貪心 + 分治)

    鏈接:https://codeforces.com/problemset/problem/1400/E 來源:Codeforces 思路:給你一個陣列,現在你可以進行兩種操作,操作1:將一段沒有 0 的區間進行減一的操作,操作2:將 i 位置上的元素歸零。最終問:將這個陣列的全部元素歸零后操作的最少 ......

    uj5u.com 2020-09-10 00:57:30 more
  • UVA11610 【Reverse Prime】

    本人看到此題沒有翻譯,就附帶了一個自己的翻譯版本 思考 這一題,它的第一個要求是找出所有 $7$ 位反向質數及其質因數的個數。 我們應該需要質數篩篩選1~$10^{7}$的所有數,這里就不慢慢介紹了。但是,重讀題,我們突然發現反向質數都是 $7$ 位,而將它反過來后的數字卻是 $6$ 位數,這就說明 ......

    uj5u.com 2020-09-10 00:57:36 more
  • 統計區間素數數量

    1 #pragma GCC optimize(2) 2 #include <bits/stdc++.h> 3 using namespace std; 4 bool isprime[1000000010]; 5 vector<int> prime; 6 inline int getlist(int ......

    uj5u.com 2020-09-10 00:57:47 more
  • C/C++編程筆記:C++中的 const 變數詳解,教你正確認識const用法

    1、C中的const 1、區域const變數存放在堆疊區中,會分配記憶體(也就是說可以通過地址間接修改變數的值)。測驗代碼如下: 運行結果: 2、全域const變數存放在只讀資料段(不能通過地址修改,會發生寫入錯誤), 默認為外部聯編,可以給其他源檔案使用(需要用extern關鍵字修飾) 運行結果: ......

    uj5u.com 2020-09-10 00:58:04 more
  • 【C++犯錯記錄】VS2019 MFC添加資源不懂如何修改資源宏ID

    1. 首先在資源視圖中,添加資源 2. 點擊新添加的資源,復制自動生成的ID 3. 在解決方案資源管理器中找到Resource.h檔案,編輯,使用整個專案搜索和替換的方式快速替換 宏宣告 4. Ctrl+Shift+F 全域搜索,點擊查找全部,然后逐個替換 5. 為什么使用搜索替換而不使用屬性視窗直 ......

    uj5u.com 2020-09-10 00:59:11 more
  • 【C++犯錯記錄】VS2019 MFC不懂的批量添加資源

    1. 打開資源頭檔案Resource.h,在其中預先定義好宏 ID(不清楚其實ID值應該設定多少,可以先新建一個相同的資源項,再在這個資源的ID值的基礎上遞增即可) 2. 在資源視圖中選中專案資源,按F7編輯資源檔案,按 ID 型別 相對路徑的形式添加 資源。(別忘了先把檔案拷貝到專案中的res檔案 ......

    uj5u.com 2020-09-10 01:00:19 more
  • C/C++編程筆記:關于C++的參考型別,專供新手入門使用

    今天要講的是C++中我最喜歡的一個用法——參考,也叫別名。 參考就是給一個變數名取一個變數名,方便我們間接地使用這個變數。我們可以給一個變數創建N個參考,這N + 1個變數共享了同一塊記憶體區域。(參考型別的變數會占用記憶體空間,占用的記憶體空間的大小和指標型別的大小是相同的。雖然參考是一個物件的別名,但 ......

    uj5u.com 2020-09-10 01:00:22 more
  • 【C/C++編程筆記】從頭開始學習C ++:初學者完整指南

    眾所周知,C ++的學習曲線陡峭,但是花時間學習這種語言將為您的職業帶來奇跡,并使您與其他開發人員區分開。您會更輕松地學習新語言,形成真正的解決問題的技能,并在編程的基礎上打下堅實的基礎。 C ++將幫助您養成良好的編程習慣(即清晰一致的編碼風格,在撰寫代碼時注釋代碼,并限制類內部的可見性),并且由 ......

    uj5u.com 2020-09-10 01:00:41 more
最新发布
  • Rust中的智能指標:Box<T> Rc<T> Arc<T> Cell<T> RefCell<T> Weak

    Rust中的智能指標是什么 智能指標(smart pointers)是一類資料結構,是擁有資料所有權和額外功能的指標。是指標的進一步發展 指標(pointer)是一個包含記憶體地址的變數的通用概念。這個地址參考,或 ” 指向”(points at)一些其 他資料 。參考以 & 符號為標志并借用了他們所 ......

    uj5u.com 2023-04-20 07:24:10 more
  • Java的值傳遞和參考傳遞

    值傳遞不會改變本身,參考傳遞(如果傳遞的值需要實體化到堆里)如果發生修改了會改變本身。 1.基本資料型別都是值傳遞 package com.example.basic; public class Test { public static void main(String[] args) { int ......

    uj5u.com 2023-04-20 07:24:04 more
  • [2]SpinalHDL教程——Scala簡單入門

    第一個 Scala 程式 shell里面輸入 $ scala scala> 1 + 1 res0: Int = 2 scala> println("Hello World!") Hello World! 檔案形式 object HelloWorld { /* 這是我的第一個 Scala 程式 * 以 ......

    uj5u.com 2023-04-20 07:23:58 more
  • 理解函式指標和回呼函式

    理解 函式指標 指向函式的指標。比如: 理解函式指標的偽代碼 void (*p)(int type, char *data); // 定義一個函式指標p void func(int type, char *data); // 宣告一個函式func p = func; // 將指標p指向函式func ......

    uj5u.com 2023-04-20 07:23:52 more
  • Django筆記二十五之資料庫函式之日期函式

    本文首發于公眾號:Hunter后端 原文鏈接:Django筆記二十五之資料庫函式之日期函式 日期函式主要介紹兩個大類,Extract() 和 Trunc() Extract() 函式作用是提取日期,比如我們可以提取一個日期欄位的年份,月份,日等資料 Trunc() 的作用則是截取,比如 2022-0 ......

    uj5u.com 2023-04-20 07:23:45 more
  • 一天吃透JVM面試八股文

    什么是JVM? JVM,全稱Java Virtual Machine(Java虛擬機),是通過在實際的計算機上仿真模擬各種計算機功能來實作的。由一套位元組碼指令集、一組暫存器、一個堆疊、一個垃圾回收堆和一個存盤方法域等組成。JVM屏蔽了與作業系統平臺相關的資訊,使得Java程式只需要生成在Java虛擬機 ......

    uj5u.com 2023-04-20 07:23:31 more
  • 使用Java接入小程式訂閱訊息!

    更新完微信服務號的模板訊息之后,我又趕緊把微信小程式的訂閱訊息給實作了!之前我一直以為微信小程式也是要企業才能申請,沒想到小程式個人就能申請。 訊息推送平臺🔥推送下發【郵件】【短信】【微信服務號】【微信小程式】【企業微信】【釘釘】等訊息型別。 https://gitee.com/zhongfuch ......

    uj5u.com 2023-04-20 07:22:59 more
  • java -- 緩沖流、轉換流、序列化流

    緩沖流 緩沖流, 也叫高效流, 按照資料型別分類: 位元組緩沖流:BufferedInputStream,BufferedOutputStream 字符緩沖流:BufferedReader,BufferedWriter 緩沖流的基本原理,是在創建流物件時,會創建一個內置的默認大小的緩沖區陣列,通過緩沖 ......

    uj5u.com 2023-04-20 07:22:49 more
  • Java-SpringBoot-Range請求頭設定實作視頻分段傳輸

    老實說,人太懶了,現在基本都不喜歡寫筆記了,但是網上有關Range請求頭的文章都太水了 下面是抄的一段StackOverflow的代碼...自己大修改過的,寫的注釋挺全的,應該直接看得懂,就不解釋了 寫的不好...只是希望能給視頻網站開發的新手一點點幫助吧. 業務場景:視頻分段傳輸、視頻多段傳輸(理 ......

    uj5u.com 2023-04-20 07:22:42 more
  • Windows 10開發教程_編程入門自學教程_菜鳥教程-免費教程分享

    教程簡介 Windows 10開發入門教程 - 從簡單的步驟了解Windows 10開發,從基本到高級概念,包括簡介,UWP,第一個應用程式,商店,XAML控制元件,資料系結,XAML性能,自適應設計,自適應UI,自適應代碼,檔案管理,SQLite資料庫,應用程式到應用程式通信,應用程式本地化,應用程式 ......

    uj5u.com 2023-04-20 07:22:35 more