IO模型、IO多路复用
IO多路復用
- 基礎概述
- 用戶空間和內核空間
- PIO與DMA
- 緩存IO和直接IO
- 緩存IO
- 優點
- 缺點
- 直接IO
- IO訪問方式
- 磁盤IO
- 網絡IO
- 磁盤IO和網絡IO對比
- Socket網絡編程
- 客戶端
- 服務端
- 同步IO和異步IO
- 阻塞IO和非阻塞IO
- IO設計模式之Reactor和Proactor
- 反應器Reactor
- 概述
- 為什么使用Reactor模式
- Reactor模式結構
- 業務流程及時序圖
- Proactor模式
- Proactor模式結構
- 業務流程及時序圖
- Reactor和Proactor對比
- 主動和被動
- 實現
- 優點
- 缺點
- 適用場景
- 漫談五種IO模型
- 高性能IO模型淺析
- IO模型舉例理解
- 例1
- 例2
- 同步阻塞IO
- 同步非阻塞IO
- IO多路復用
- 異步IO
- Redis的IO多路復用技術
- 為什么Redis中要使用I/O多路復用
- epoll實現機制
- redis epoll底層實現
基礎概述
用戶空間和內核空間
- Linux 中有兩個詞:User space (用戶空間)和 Kernel space (內核空間)
- 簡單說,Kernel space 是 Linux 內核的運行空間,User space 是用戶程序的運行空間。為了安全起見,它們是隔離的,即使用戶的程序崩潰了,內核也不受影響。
- 虛擬內存被操作系統劃分為兩塊:內核空間和用戶空間,內核空間是內核代碼運行的地方,用戶空間是用戶程序代碼運行的地方。當進程運行在內核空間時就處于內核態,當進程運行在用戶空間時就處于用戶態。
- Kernel space 可以執行任意命令,調用系統的一切資源;User space 只能執行簡單的運算,不能直接調用系統資源,必須通過系統接口(又稱 system call),才能向內核發出指令。
- 通過系統接口,進程可以從用戶空間切換到內核空間。例如:
- 上面代碼中,第一行和第二行都是簡單的賦值運算,在 User space 執行。第三行需要寫入文件,就要切換到Kernel space,因為用戶不能直接寫文件,必須通過內核安排。第四行又是賦值運算,就切換回 User space。
- 查看 CPU 時間在 User space 與 Kernel Space 之間的分配情況,可以使用top命令。它的第三行輸出就是 CPU時間分配統計。
- 這一行有 8 項統計指標:其中,第一項 0.7 us(user 的縮寫)就是 CPU 消耗在 User space 的時間百分比,第二項 0.3 sy(system 的縮寫)是消耗在 Kernel space 的時間百分比。
- 其他 6 個指標的含義:
- ni :niceness 的縮寫,CPU 消耗在 nice 進程(低優先級)的時間百分比。
- id :idle 的縮寫,CPU 消耗在閑置進程的時間百分比,這個值越低,表示 CPU 越忙。
- wa :wait 的縮寫,CPU 等待外部 I/O 的時間百分比,這段時間 CPU 不能干其他事,但是也沒有執行運算,這個值太高就說明外部設備有問題。
- hi :hardware interrupt 的縮寫,CPU 響應硬件中斷請求的時間百分比。
- si :software interrupt 的縮寫,CPU 響應軟件中斷請求的時間百分比。
- st :stole time 的縮寫,該項指標只對虛擬機有效,表示分配給當前虛擬機的 CPU 時間之中,被同一臺物理機上的其他虛擬機偷走的時間百分比。
PIO與DMA
- PIO:我們拿磁盤來說,很早以前,磁盤和內存之間的數據傳輸是需要CPU控制的,也就是說如果我們讀取磁盤文件到內存中,數據需要經過 CPU 存儲轉發,這種方式稱為 PIO。顯然這種方式非常不合理,需要占用大量的CPU時間來讀取文件,造成文件訪問時系統幾乎停止響應。
- DMA:后來DMA(直接內存訪問,Direct Memory Access)取代了PIO,它可以不經過CPU而直接進行磁盤和內存(內核空間)的數據交換。在DMA模式下,CPU只需要向DMA控制器下達指令,讓DMA控制器來處理數據在DMA模式下,CPU只需要向DMA控制器下達指令,讓DMA控制器來處理數據率,大大節省了系統資源,而它的傳輸速度與PIO的差異其實并不十分明顯,因為這主要取決于慢速設備的速度。
緩存IO和直接IO
- 緩存IO:數據從磁盤先通過DMA copy到內核空間,再從內核空間通過cpu copy到用戶空間。
- 直接IO:數據從磁盤通過DMA copy到用戶空間。
緩存IO
緩存I/O又被稱作標準I/O,大多數文件系統的默認I/O操作都是緩存I/O。在Linux的緩存I/O機制中,數據先從磁盤復制到內核空間的緩沖區,然后從內核空間緩沖區復制到應用程序的地址空間。
- 讀操作:操作系統檢查內核的緩沖區有沒有需要的數據,如果已經緩存了,那么就直接從緩存中返回;否則從磁盤中讀取,然后緩存在操作系統的緩存中。
- 寫操作:將數據從用戶空間復制到內核空間的緩存中,這時對用戶程序來說寫操作就已經完成,至于什么時候再寫到磁盤中由操作系統決定,除非顯示地調用了 sync 同步命令。【珍藏】linux 同步IO: sync、fsync與fdatasync
優點
- 在一定程度上分離了內核空間和用戶空間,保護系統本身的運行安全。
- 可以減少讀盤的次數,從而提高性能。
缺點
- 在緩存 I/O 機制中,DMA 方式可以將數據直接從磁盤讀到頁緩存中,或者將數據從頁緩存直接寫回到磁盤上,而不能直接在應用程序地址空間和磁盤之間進行數據傳輸,這樣,數據在傳輸過程中需要在應用程序地址空間(用戶空間)和緩存(內核空間)之間進行多次數據拷貝操作,這些數據拷貝操作所帶來的CPU以及內存開銷是非常大的。
直接IO
直接IO就是應用程序直接訪問磁盤數據,而不經過內核緩沖區,也就是繞過內核緩沖區,自己管理I/O緩存區,這樣做的目的是減少一次從內核緩沖區到用戶程序緩存的數據復制。
- 引入內核緩沖區的目的在于提高磁盤文件的訪問性能,因為當進程需要讀取磁盤文件時,如果文件內容已經在內核緩沖區中,那么就不需要再次訪問磁盤;而當進程需要向文件中寫入數據時,實際上只是寫到了內核緩沖區便告訴進程已經寫成功,而真正寫入磁盤是通過一定的策略進行延遲的。
- 然而,對于一些較復雜的應用,比如數據庫服務器,它們為了充分提高性能,希望繞過內核緩存區,由自己在用戶態空間實現并管理I/O緩沖區,包括緩存機制和寫延遲機制等,以支持獨特的查詢機制,比如數據庫可以根據更加合理的策略來提高查詢緩存命中率。另一方面,繞過內核緩沖區也可以減少系統內存的開銷,因為內核緩沖區本身就在使用系統內存。
- 應用程序直接訪問磁盤數據,不經過操作系統內核數據緩沖區,這樣做的目的是減少一次從內核緩沖區到用戶程序緩存的數據復制。這種方式通常是在對數據的緩存管理由應用程序實現的數據庫管理系統中。
- 直接I/O的缺點就是如果訪問的數據不在應用程序緩存中,那么每次數據都會直接從磁盤進行加載,這種直接加載會非常慢。通常直接I/O根異步I/O結合使用會得到較好的性能。
- Linux提供了對這種需求的支持,即在 open() 系統調用中增加參數選項 O_DIRECT,用它打開的文件便可以繞過內核緩沖區的直接訪問,這樣便有效避免了CPU和內存的多余時間開銷。
- 順便提一下,與O_DIRECT類似的一個選項是O_SYNC,后者只對寫數據有效,它將寫入內核緩沖區的數據立即寫入磁盤,將機器故障時數據的丟失減少到最小,但是它仍然要經過內核緩沖區。
IO訪問方式
磁盤IO
- 當應用程序調用read接口時,操作系統檢查在內核的高速緩存有沒有需要的數據,如果已經緩存了,那么就直接從緩存中返回,如果沒有,則從磁盤中讀取,然后緩存在操作系統的緩存中。
- 應用程序調用write接口時,將數據從用戶地址空間復制到內核地址空間的緩存中,這時對用戶程序來說,寫操作已經完成,至于什么時候再寫到磁盤中,由操作系統決定,除非顯示調用了sync同步命令。
網絡IO
- ① 操作系統將數據從磁盤復制到操作系統內核的頁緩存中;② 應用將數據從內核緩存復制到應用的緩存中;③應用將數據寫回內核的Socket緩存中;④操作系統將數據從Socket緩存區復制到網卡緩存,然后將其通過網絡發出。
- ① 當調用read系統調用時,通過DMA(Direct Memory Access)將數據copy到內核模式;② 然后由CPU控制將內核模式數據copy到用戶模式下的 buffer中;③ read調用完成后,write調用首先將用戶模式下 buffer中的數據copy到內核模式下的socket buffer中;④最后通過DMA copy將內核模式下的socket buffer中的數據copy到網卡設備中傳送。
- 從上面的過程可以看出,數據白白從內核模式到用戶模式走了一圈,浪費了兩次copy,而這兩次copy都是CPUcopy,即占用CPU資源。
磁盤IO和網絡IO對比
- 首先,磁盤IO主要的延時是由(以15000rpm硬盤為例):機械轉動延時(機械磁盤的主要性能瓶頸,平均為2ms) + 尋址延時(2~3ms) + 塊傳輸延時(一般4k每塊,40m/s的傳輸速度,延時一般為0.1ms) 決定。(平均為5ms)。
- 而網絡IO主要延時是由:服務器響應延時 + 帶寬限制 + 網絡延時 + 跳轉路由延時 + 本地接收延時 決定。(一般為幾十到幾千毫秒,受環境干擾極大)。
- 所以兩者一般來說網絡IO延時要大于磁盤IO的延時。
Socket網絡編程
客戶端
public class SocketClient {public static void main(String args[]) throws Exception {// 要連接的服務端IP地址和端口 String host = "127.0.0.1"; int port = 55533;// 與服務端建立連接Socket socket = new Socket(host, port);// 建立連接后獲得輸出流OutputStream outputStream = socket.getOutputStream(); String message="你好 yiwangzhibujian";socket.getOutputStream().write(message.getBytes("UTF-8")); outputStream.close();socket.close();} }服務端
public class SocketServer {public static void main(String[] args) throws Exception {// 監聽指定的端口int port = 55533;ServerSocket server = new ServerSocket(port);// server將一直等待連接的到來 System.out.println("server將一直等待連接的到來"); Socket socket = server.accept();// 建立好連接后,從socket中獲取輸入流,并建立緩沖區進行讀取 InputStream inputStream = socket.getInputStream(); byte[] bytes = new byte[1024];int len;StringBuilder sb = new StringBuilder();while ((len = inputStream.read(bytes)) != -1) {//注意指定編碼格式,發送方和接收方一定要統一,建議使用UTF-8sb.append(new String(bytes, 0, len,"UTF-8"));}System.out.println("get message from client: " + sb);inputStream.close();socket.close();server.close();} } public class SocketServer {public static void main(String args[]) throws Exception {// 監聽指定的端口int port = 55533;ServerSocket server = new ServerSocket(port); // server將一直等待連接的到來 System.out.println("server將一直等待連接的到來");//如果使用多線程,那就需要線程池,防止并發過高時創建過多線程耗盡資源 ExecutorService threadPool = Executors.newFixedThreadPool(100);while (true) {Socket socket = server.accept();Runnable runnable = ()->{try {// 建立好連接后,從socket中獲取輸入流,并建立緩沖區進行讀取 InputStream inputStream = socket.getInputStream(); byte[] bytes = new byte[1024];int len;StringBuilder sb = new StringBuilder();while ((len = inputStream.read(bytes)) != -1) {// 注意指定編碼格式,發送方和接收方一定要統一,建議使用UTF-8sb.append(new String(bytes, 0, len, "UTF-8"));}System.out.println("get message from client: " + sb);inputStream.close();socket.close();} catch (Exception e) {e.printStackTrace();} };threadPool.submit(runnable);}} }同步IO和異步IO
同步和異步是針對應用程序和內核的交互而言的,同步指的是用戶進程觸發IO操作并等待或者輪詢的去查看IO操作是否就緒,而異步是指用戶進程觸發IO操作以后便開始做自己的事情,而當IO操作已經完成的時候會得到IO完成的通知。
- 指的是用戶空間和內核空間數據交互的方式
- 同步:用戶空間要的數據,必須等到內核空間給它才做其他事情。
- 異步:用戶空間要的數據,不需要等到內核空間給它,才做其他事情。內核空間會異步通知用戶進程,并把數據直接給到用戶空間。
阻塞IO和非阻塞IO
阻塞方式下讀取或者寫入函數將一直等待,而非阻塞方式下,讀取或者寫入函數會立即返回一個狀態值。
- 指的是用戶空間和內核空間IO操作的方式
- 阻塞:用戶空間通過系統調用(systemcall)和內核空間發送IO操作時,該調用是阻塞的。
- 非阻塞:用戶空間通過系統調用(systemcall)和內核空間發送IO操作時,該調用是不堵塞的,直接返回的,只是返回時,可能沒有數據而已。
IO設計模式之Reactor和Proactor
- 平時接觸的開源產品如Redis、ACE,事件模型都使用的Reactor模式;而同樣做事件處理的Proactor,由于操作系統的原因,相關的開源產品也少。
反應器Reactor
概述
反應器設計模式(Reactor pattern)是一種為處理并發服務請求,并將請求提交到一個或者多個服務處理程序的事件設計模式。當客戶端請求抵達后,服務處理程序使用多路分配策略,由一個非阻塞的線程來接收所有的請求,然后派發這些請求至相關的工作線程進行處理。
Reactor模式主要包含下面幾部分內容:
- 初始事件分發器(Initialization Dispatcher):用于管理Event Handler,定義注冊、移除EventHandler等。它還作為Reactor模式的入口調用Synchronous Event Demultiplexer的select方法以阻塞等待事件返回,當阻塞等待返回時,根據事件發生的Handle將其分發給對應的Event Handler處理,即回調EventHandler中的handle_event()方法。
- 同步(多路)事件分離器(Synchronous Event Demultiplexer):無限循環等待新事件的到來,一旦發現有新的事件到來,就會通知初始事件分發器去調取特定的事件處理器。
- 系統處理程序(Handles):操作系統中的句柄,是對資源在操作系統層面上的一種抽象,它可以是打開的文件、一個連接(Socket)、Timer等。由于Reactor模式一般使用在網絡編程中,因而這里一般指SocketHandle,即一個網絡連接(Connection,在Java NIO中的Channel)。這個Channel注冊到Synchronous Event Demultiplexer中,以監聽Handle中發生的事件,對ServerSocketChannnel可以是CONNECT事件,對 SocketChannel 可以是READ、WRITE、CLOSE事件等。
- 事件處理器(Event Handler):定義事件處理方法,以供Initialization Dispatcher回調使用。
- 對于Reactor模式,可以將其看做由兩部分組成,一部分是由Boss組成,另一部分是由worker組成。Boss就像老板一樣,主要是拉活兒、談項目,一旦Boss接到活兒了,就下發給下面的work去處理。也可以看做是項目經理和程序員之間的關系。
為什么使用Reactor模式
并發系統常使用reactor模式代替常用的多線程的處理方式,節省系統的資源,提高系統的吞吐量。例如:在高并發的情況下,既可以使用多處理處理方式,也可以使用Reactor處理方式。
- 多線程處理:為每個單獨到來的請求,專門啟動一條線程,這樣的話造成系統的開銷很大,并且在單核的機上,多線程并不能提高系統的性能,除非在有一些阻塞的情況發生。否則線程切換的開銷會使處理的速度變慢。
- Reactor模式處理:服務器端啟動一條單線程,用于輪詢IO操作是否就緒,當有就緒的才進行相應的讀寫操作,這樣的話就減少了服務器產生大量的線程,也不會出現線程之間的切換產生的性能消耗。(目前JAVA的NIO就采用的此種模式,這里引申出一個問題:在多核情況下NIO的擴展問題)
- 以上兩種處理方式都是基于同步的,多線程的處理是我們傳統模式下對高并發的處理方式,Reactor模式的處理是現今面對高并發和高性能一種主流的處理方式。
Reactor模式結構
Reactor包含如下角色:
- Handle 句柄:用來標識socket連接或是打開文件;
- Synchronous Event Demultiplexer:同步事件多路分解器,由操作系統內核實現的一個函數;用于阻塞等 待發生在句柄集合上的一個或多個事件(如select/epoll)。
- Event Handler:事件處理接口。
- Concrete Event HandlerA:實現應用程序所提供的特定事件處理邏輯。
- Reactor:反應器,定義一個接口,實現以下功能
- 供應用程序注冊和刪除關注的事件句柄;
- 運行事件循環
- 有就緒事件到來時,分發事件到之前注冊的回調函數上處理。
- Initiation Dispatcher:用于管理Event Handler,即EventHandler的容器,用以注冊、移除 EventHandler等;另外,它還作為Reactor模式的入口調用Synchronous Event Demultiplexer的select方法以阻塞等待事件返回,當阻塞等待返回時,根據事件發生的Handle將其分發給對應的Event Handler處理, 即回調EventHandler中的handle_event()方法。
業務流程及時序圖
- 應用啟動,將關注的事件handle注冊到Reactor中。
- 調用Reactor,進入無限事件循環,等待注冊的事件到來。
- 事件到來,select返回,Reactor將事件分發到之前注冊的回調函數中處理。
Proactor模式
運用于異步I/O操作,Proactor模式中,應用程序不需要進行實際的讀寫過程,它只需要從緩存區讀取或者寫入即可,操作系統會讀取緩存區或者寫入緩存區到真正的IO設備。
Proactor中對于寫入操作和讀取操作,只感興趣的是寫入完成事件。
Proactor模式結構
Proactor主動器模式包含如下角色:
- Handle 句柄:用來標識socket連接或是打開文件。
- Asynchronous Operation:異步操作。
- Asynchronous Operation Processor:異步操作處理器;負責執行異步操作,一般由操作系統內核實現。
- Completion Event Queue:完成事件隊列;異步操作完成的結果放到隊列中等待后續使用。
- Proactor:主動器;為應用程序進程提供事件循環;從完成事件隊列中取出異步操作的結果,分發調用相應的后續處理邏輯。
- Completion Handler:完成事件接口;一般是由回調函數組成的接口。
- Concrete Completion Handler:完成事件處理邏輯;實現接口定義特定的應用處理邏輯。
業務流程及時序圖
- 應用程序啟動,調用異步操作處理器提供的異步操作接口函數,調用之后應用程序和異步操作處理就獨立運行;應用程序可以調用新的異步操作,而其它操作可以并發進行。
- 應用程序啟動Proactor主動器,進行無限的事件循環,等待完成事件到來。
- 異步操作處理器執行異步操作,完成后將結果放入到完成事件隊列。
- 主動器從完成事件隊列中取出結果,分發到相應的完成事件回調函數處理邏輯中。
Reactor和Proactor對比
主動和被動
- Reactor將handle放到select(),等待可寫就緒,然后調用write()寫入數據;寫完處理后續邏輯。
- Proactor調用aoi_write后立刻返回,由內核負責寫操作,寫完后調用相應的回調函數處理后續邏輯。
- 可以看出,Reactor被動的等待指示事件的到來并做出反應;它有一個等待的過程,做什么都要先放入到監聽事件集合中等待handler可用時再進行操作; Proactor直接調用異步讀寫操作,調用完后立刻返回。
實現
- Reactor實現了一個被動的事件分離和分發模型,服務等待請求事件的到來,再通過不受間斷的同步處理事件,從而做出反應。
- Proactor實現了一個主動的事件分離和分發模型;這種設計允許多個任務并發的執行,從而提高吞吐量;并可執行耗時長的任務(各個任務間互不影響)。
優點
- Reactor實現相對簡單,對于耗時短的處理場景處理高效; 操作系統可以在多個事件源上等待,并且避免了多線程編程相關的性能開銷和編程復雜性; 事件的串行化對應用是透明的,可以順序的同步執行而不需要加鎖; 事務分離:將與應用無關的多路分解和分配機制和與應用相關的回調函數分離開來。
- Proactor性能更高,能夠處理耗時長的并發場景。
缺點
- Reactor處理耗時長的操作會造成事件分發的阻塞,影響到后續事件的處理。
- Proactor實現邏輯復雜,依賴操作系統對異步的支持,目前實現了純異步操作的操作系統少,實現優秀的如windows IOCP,但由于其windows系統用于服務器的局限性,目前應用范圍較小;而Unix/Linux系統對純異步的支持有限,應用事件驅動的主流還是通過select/epoll來實現。
適用場景
- Reactor:同時接收多個服務請求,并且依次同步的處理它們的事件驅動程序。
- Proactor:異步接收和同時處理多個服務請求的事件驅動程序。
漫談五種IO模型
高性能IO模型淺析
服務器端編程經常需要構造高性能的IO模型,常見的IO模型有四種:
- 同步阻塞IO(Blocking IO):即傳統的IO模型。
- 同步非阻塞IO(Non-blocking IO):默認創建的socket都是阻塞的,非阻塞IO要求socket被設置為NONBLOCK。注意這里所說的NIO并非Java的NIO(New IO)庫。
- IO多路復用(IO Multiplexing):即經典的Reactor設計模式,有時也稱為異步阻塞IO,Java中的Selector和Linux中的epoll都是這種模型。
- 異步IO(Asynchronous IO):即經典的Proactor設計模式,也稱為異步非阻塞IO。
IO模型舉例理解
例1
- 阻塞IO,給女神發一條短信,說我來找你了,然后就默默的一直等著女神下樓,這個期間除了等待你不會做其他事情,屬于備胎做法。
- 非阻塞IO,給女神發短信,如果不回,接著再發,一直發到女神下樓,這個期間你除了發短信等待不會做其他事情,屬于專一做法。
- IO多路復用,是找一個宿管大媽來幫你監視下樓的女生,這個期間你可以些其他的事情。例如可以順便看看其他妹子,玩玩王者榮耀,上個廁所等。IO復用又包括 select、poll、epoll 模式。那么它們的區別是什么?
- select 大媽每一個女生下樓,select大媽都不知道這個是不是你的女神,她需要一個一個詢問,并且select大媽能力還有限,最多一次幫你監視1024個妹子。
- poll大媽不限制盯著女生的數量,只要是經過宿舍樓門口的女生,都會幫你去問是不是你女神。
- epoll大媽不限制盯著女生的數量,并且也不需要一個一個去問。那么如何做呢?epoll大媽會為每個進宿舍樓的女生臉上貼上一個大字條,上面寫上女生自己的名字,只要女生下樓了,epoll大媽就知道這個是不是你女神了,然后大媽再通知你。
- 上面這些同步IO有一個共同點就是,當女神走出宿舍門口的時候,你已經站在宿舍門口等著女神的,此時你屬于阻塞狀態。
- 接下來是異步IO的情況:你告訴女神我來了,然后你就去王者榮耀了,一直到女神下樓了,發現找不見你了,女神再給你打電話通知你,說我下樓了,你在哪呢?這時候你才來到宿舍門口。此時屬于逆襲做法。
例2
- 阻塞I/O模型:老李去火車站買票,排隊三天買到一張退票。耗費:在車站吃喝拉撒睡 3天,其他事一件沒干。
- 非阻塞I/O模型:老李去火車站買票,隔12小時去火車站問有沒有退票,三天后買到一張票。耗費:往返車站6次,路上6小時,其他時間做了好多事。
- I/O復用模型:
- select/poll:老李去火車站買票,委托黃牛,然后每隔6小時電話黃牛詢問,黃牛三天內買到票,然后老李去火車站交錢領票。 耗費:往返車站2次,路上2小時,黃牛手續費100元,打電話17次。
- epoll:老李去火車站買票,委托黃牛,黃牛買到后即通知老李去領,然后老李去火車站交錢領票。 耗費:往返車站2次,路上2小時,黃牛手續費100元,無需打電話。
- 信號驅動I/O模型:老李去火車站買票,給售票員留下電話,有票后,售票員電話通知老李,然后老李去火車站交錢領票。 耗費:往返車站2次,路上2小時,免黃牛費100元,無需打電話。
- 異步I/O模型:老李去火車站買票,給售票員留下電話,有票后,售票員電話通知老李并快遞送票上門。 耗費:往返車站1次,路上1小時,免黃牛費100元,無需打電話。
同步阻塞IO
- 同步阻塞IO模型是最簡單的IO模型,用戶線程在內核進行IO操作時被阻塞。
- 用戶線程通過系統調用read發起IO讀操作,由用戶空間轉到內核空間。內核等到數據包到達后,然后將接收的數據拷貝到用戶空間,完成read操作。
- 用戶線程使用同步阻塞IO模型的偽代碼描述為:
- 即用戶需要等待read將socket中的數據讀取到buffer后,才繼續處理接收的數據。整個IO請求的過程中,用戶線程是被阻塞的,這導致用戶在發起IO請求時,不能做任何事情,對CPU的資源利用率不夠。
同步非阻塞IO
- 同步非阻塞IO是在同步阻塞IO的基礎上,將socket設置為NONBLOCK。這樣做用戶線程可以在發起IO請求后可以立即返回。
- 由于socket是非阻塞的方式,因此用戶線程發起IO請求時立即返回。但并未讀取到任何數據,用戶線程需要不斷地發起IO請求,直到數據到達后,才真正讀取到數據,繼續執行。
- 用戶線程使用同步非阻塞IO模型的偽代碼描述為:
IO多路復用
- IO多路復用模型是建立在內核提供的多路分離函數select基礎之上的,使用select函數可以避免同步非阻塞IO模型中輪詢等待的問題。
- 用戶首先將需要進行IO操作的socket添加到select中,然后阻塞等待select系統調用返回。當數據到達時,socket被激活,select函數返回。用戶線程正式發起read請求,讀取數據并繼續執行。
- 從流程上來看,使用select函數進行IO請求和同步阻塞模型沒有太大的區別,甚至還多了添加監視socket,以及調用select函數的額外操作,效率更差。但是,使用select以后最大的優勢是用戶可以在一個線程內同時處理多個socket的IO請求。用戶可以注冊多個socket,然后不斷地調用select讀取被激活的socket,即可達到在同一個線程內同時處理多個IO請求的目的。而在同步阻塞模型中,必須通過多線程的方式才能達到這個目的。
- 用戶線程使用select函數的偽代碼描述為:
- 其中while循環前將socket添加到select監視中,然后在while內一直調用select獲取被激活的socket,一旦socket可讀,便調用read函數將socket中的數據讀取出來。
- 然而,使用select函數的優點并不僅限于此。雖然上述方式允許單線程內處理多個IO請求,但是每個IO請求的過程還是阻塞的(在select函數上阻塞),平均時間甚至比同步阻塞IO模型還要長。
- 如果用戶線程只注冊自己感興趣的socket或者IO請求,然后去做自己的事情,等到數據到來時再進行處理,則可以提高CPU的利用率。
- IO多路復用模型使用了Reactor設計模式實現了這一機制。
- 通過Reactor的方式,可以將用戶線程輪詢IO操作狀態的工作統一交給handle_events事件循環進行處理。用戶線程注冊事件處理器之后可以繼續執行做其他的工作(異步),而Reactor線程負責調用內核的select函數檢查socket狀態。當有socket被激活時,則通知相應的用戶線程(或執行用戶線程的回調函數),執行handle_event進行數據讀取、處理的工作。由于select函數是阻塞的,因此多路IO復用模型也被稱為異步阻塞IO模型。注意,這里的所說的阻塞是指select函數執行時線程被阻塞,而不是指socket。一般在使用IO多路復用模型時,socket都是設置為NONBLOCK的,不過這并不會產生影響,因為用戶發起IO請求時,數據已經到達了,用戶線程一定不會被阻塞。
- 用戶線程使用IO多路復用模型的偽代碼描述為:
- 用戶需要重寫EventHandler的handle_event函數進行讀取數據、處理數據的工作,用戶線程只需要將自己的EventHandler注冊到Reactor即可。Reactor中handle_events事件循環的偽代碼大致如下。
- 事件循環不斷地調用select獲取被激活的socket,然后根據獲取socket對應的EventHandler,執行器handle_event函數即可。
- IO多路復用是最常使用的IO模型,但是其異步程度還不夠“徹底”,因為它使用了會阻塞線程的select系統調用。因此IO多路復用只能稱為異步阻塞IO,而非真正的異步IO。
異步IO
- “真正”的異步IO需要操作系統更強的支持。在IO多路復用模型中,事件循環將文件句柄的狀態事件通知給用戶線程,由用戶線程自行讀取數據、處理數據。而在異步IO模型中,當用戶線程收到通知時,數據已經被內核讀取完畢,并放在了用戶線程指定的緩沖區內,內核在IO完成后通知用戶線程直接使用即可。
- 異步IO模型使用了Proactor設計模式實現了這一機制。
- 異步IO模型中,用戶線程直接使用內核提供的異步IO API發起read請求,且發起后立即返回,繼續執行用戶線程代碼。不過此時用戶線程已經將調用的AsynchronousOperation和CompletionHandler注冊到內核,然后操作系統開啟獨立的內核線程去處理IO操作。當read請求的數據到達時,由內核負責讀取socket中的數據,并寫入用戶指定的緩沖區中。最后內核將read的數據和用戶線程注冊的CompletionHandler分發給內部Proactor,Proactor將IO完成的信息通知給用戶線程(一般通過調用用戶線程注冊的完成事件處理函數),完成異步IO。
- 用戶線程使用異步IO模型的偽代碼描述為:
- 用戶需要重寫CompletionHandler的handle_event函數進行處理數據的工作,參數buffer表示Proactor已經準備好的數據,用戶線程直接調用內核提供的異步IO API,并將重寫的CompletionHandler注冊即可。
- 相比于IO多路復用模型,異步IO并不十分常用,不少高性能并發服務程序使用IO多路復用模型+多線程任務處理的架構基本可以滿足需求。況且目前操作系統對異步IO的支持并非特別完善,更多的是采用IO多路復用模型模擬異步IO的方式(IO事件觸發時不直接通知用戶線程,而是將數據讀寫完畢后放到用戶指定的緩沖區中)。
Redis的IO多路復用技術
redis 是一個單線程卻性能非常好的內存數據庫, 主要用來作為緩存系統。 redis 采用網絡IO多路復用技術來保證在多連接的時候, 系統的高吞吐量。
為什么Redis中要使用I/O多路復用
- 首先,Redis 是跑在單線程中的,所有的操作都是按照順序線性執行的,但是由于讀寫操作等待用戶輸入或輸出都是阻塞的,所以 I/O 操作在一般情況下往往不能直接返回,這會導致某一文件的 I/O 阻塞導致整個進程無法對其它客戶提供服務,而 I/O 多路復用就是為了解決這個問題而出現的。
- select,poll,epoll都是IO多路復用的機制。I/O多路復用就通過一種機制,可以監視多個描述符,一旦某個描述符就緒,能夠通知程序進行相應的操作。
- redis的io模型主要是基于epoll實現的,不過它也提供了 select和kqueue的實現,默認采用epoll。
epoll實現機制
設想一下如下場景:有100萬個客戶端同時與一個服務器進程保持著TCP連接。而每一時刻,通常只有幾百上千個TCP連接是活躍的(事實上大部分場景都是這種情況)。如何實現這樣的高并發?
- 在select/poll時代,服務器進程每次都把這100萬個連接告訴操作系統(從用戶態復制句柄數據結構到內核態),讓操作系統內核去查詢這些套接字上是否有事件發生,輪詢完后,再將句柄數據復制到用戶態,讓服務器應用程序輪詢處理已發生的網絡事件,這一過程資源消耗較大,因此,select/poll一般只能處理幾千的并發連接。
- 如果沒有I/O事件產生,我們的程序就會阻塞在select處。但是依然有個問題,我們從select那里僅僅知道了,有I/O事件發生了,但卻并不知道是那幾個流(可能有一個,多個,甚至全部),我們只能 所有流,找出能讀出數據,或者寫入數據的流,對他們進行操作。
- 但是使用select,我們有O(n)的無差別輪詢復雜度,同時處理的流越多,每一次無差別輪詢時間就越長。
- 總結:select 和 poll 的缺點如下:
- 每次調用select/poll,都需要把fd集合從用戶態拷貝到內核態,這個開銷在fd很多時會很大。
- 同時每次調用select/poll都需要在內核遍歷傳遞進來的所有fd,這個開銷在fd很多時也很大。
- 針對select支持的文件描述符數量太小了,默認是1024。
- select返回的是含有整個句柄的數組,應用程序需要遍歷整個數組才能發現哪些句柄發生了事件。
- select的觸發方式是水平觸發,應用程序如果沒有完成對一個已經就緒的文件描述符進行IO操作,那么之后每次select調用還是會將這些文件描述符通知進程。
- 相比select模型,poll使用鏈表保存文件描述符,因此沒有了監視文件數量的限制,但其他三個缺點依然存在。
- epoll的設計和實現與select完全不同。epoll是poll的一種優化,返回后不需要對所有的fd進行遍歷,在內核中維持了fd的列表。select和poll是將這個內核列表維持在用戶態,然后傳遞到內核中。與poll/select不同,epoll不再是一個單獨的系統調用,而是由epoll_create、epoll_ctl、epoll_wait三個系統調用組成。
- epoll在2.6以后的內核才支持。
- epoll通過在Linux內核中申請一個簡易的文件系統(文件系統一般用什么數據結構實現?B+樹)。把原先的select/poll調用分成了3個部分:
- 調用epoll_create()建立一個epoll對象(在epoll文件系統中為這個句柄對象分配資源)。
- 調用epoll_ctl向epoll對象中添加這100萬個連接的套接字。
- 調用epoll_wait收集發生的事件的連接。
- 如此一來,要實現上面說是的場景,只需要在進程啟動時建立一個epoll對象,然后在需要的時候向這個epoll對象中添加或者刪除連接。同時,epoll_wait的效率也非常高,因為調用epoll_wait時,并沒有一股腦的向操作系統復制這100萬個連接的句柄數據,內核也不需要去遍歷全部的連接。
- 總結epoll優點:
- epoll 沒有最大并發連接的限制,上限是最大可以打開文件的數目,這個數字一般遠大于 2048, 一般來說這個數目和系統內存關系很大 ,具體數目可以 cat /proc/sys/fs/file-max 察看。
- 效率提升, epoll 最大的優點就在于它只管你“活躍”的連接 ,而跟連接總數無關,因此在實際的網絡環境中, epoll 的效率就會遠遠高于 select 和 poll 。
- 內存拷貝, epoll 在這點上使用了“共享內存”,這個內存拷貝也省略了。
redis epoll底層實現
- 當某一進程調用epoll_create方法時,Linux內核會創建一個eventpoll結構體,這個結構體中有兩個成員與epoll的使用方式密切相關。
- eventpoll結構體如下所示:
- 每一個epoll對象都有一個獨立的eventpoll結構體,用于存放通過epoll_ctl方法向epoll對象中添加進來的事件。這些事件都會掛載在紅黑樹中,如此,重復添加的事件就可以通過紅黑樹而高效的識別出來(紅黑樹的插入時間效率是lgn,其中n為樹的高度)。
- 而所有添加到epoll中的事件都會與設備(網卡)驅動程序建立回調關系,也就是說,當相應的事件發生時會調用這個回調方法。這個回調方法在內核中叫ep_poll_callback,它會將發生的事件添加到rdlist雙鏈表中。
- 在epoll中,對于每一個事件,都會建立一個epitem結構體,如下所示:
-
當調用epoll_wait檢查是否有事件發生時,只需要檢查eventpoll對象中的rdlist雙鏈表中是否有epitem元素即可。如果rdlist不為空,則把發生的事件復制到用戶態,同時將事件數量返回給用戶。
-
優勢:
- 不用重復傳遞:我們調用epoll_wait時就相當于以往調用select/poll,但是這時卻不用傳遞socket句柄給內核,因為內核已經在epoll_ctl中拿到了要監控的句柄列表。
- 在內核里,一切皆文件:所以,epoll向內核注冊了一個文件系統,用于存儲上述的被監控socket。當你調用epoll_create時,就會在這個虛擬的epoll文件系統里創建一個file結點。當然這個file不是普通文件,它只服務于epoll。epoll在被內核初始化時(操作系統啟動),同時會開辟出epoll自己的內核高速cache區,用于安置每一個我們想監控的socket,這些socket會以紅黑樹的形式保存在內核cache里,以支持快速的查找、插入、刪除。這個內核高速cache區,就是建立連續的物理內存頁,然后在之上建立slab層,簡單的說,就是物理上分配好你想要的size的內存對象,每次使用時都是使用空閑的已分配好的對象。
- 極其高效的原因:這是由于我們在調用epoll_create時,內核除了幫我們在epoll文件系統里建了個file結點,在內核cache里建了個紅黑樹用于存儲以后epoll_ctl傳來的socket外,還會再建立一個list鏈表,用于存儲準備就緒的事件,當epoll_wait調用時,僅僅觀察這個list鏈表里有沒有數據即可。有數據就返回,沒有數據就sleep,等到timeout時間到后即使鏈表沒數據也返回。所以,epoll_wait非常高效。
-
這個準備就緒list鏈表是怎么維護的呢?
- 當我們執行epoll_ctl時,除了把socket放到epoll文件系統里file對象對應的紅黑樹上之外,還會給內核中斷處理程序注冊一個回調函數,告訴內核,如果這個句柄的中斷到了,就把它放到準備就緒list鏈表里。所以,當一個socket上有數據到了,內核在把網卡上的數據copy到內核中后就來把socket插入到準備就緒鏈表里了。(注:好好理解這句話!)
- 從上面這句可以看出,epoll的基礎就是回調呀!
-
如此,一顆紅黑樹,一張準備就緒句柄鏈表,少量的內核cache,就幫我們解決了大并發下的socket處理問題。執行epoll_create時,創建了紅黑樹和就緒鏈表,執行epoll_ctl時,如果增加socket句柄,則檢查在紅黑樹中是否存在,存在立即返回,不存在則添加到樹干上,然后向內核注冊回調函數,用于當中斷事件來臨時向準備就緒鏈表中插入數據。執行epoll_wait時立刻返回準備就緒鏈表里的數據即可。
-
最后看看epoll獨有的兩種模式LT和ET。無論是LT和ET模式,都適用于以上所說的流程。區別是,LT模式下,只要一個句柄上的事件一次沒有處理完,會在以后調用epoll_wait時次次返回這個句柄,而ET模式僅在第一次返回。
-
關于LT,ET,有一端描述,LT和ET都是電子里面的術語,ET是邊緣觸發,LT是水平觸發,一個表示只有在變化的邊際觸發,一個表示在某個階段都會觸發。
-
LT, ET這件事怎么做到的呢?當一個socket句柄上有事件時,內核會把該句柄插入上面所說的準備就緒list鏈表,這時我們調用epoll_wait,會把準備就緒的socket拷貝到用戶態內存,然后清空準備就緒list鏈表,最后,epoll_wait干了件事,就是檢查這些socket,如果不是ET模式(就是LT模式的句柄了),并且這些socket上確實有未處理的事件時,又把該句柄放回到剛剛清空的準備就緒鏈表了。所以,非ET的句柄,只要它上面還有事件,epoll_wait每次都會返回這個句柄。(從上面這段,可以看出,LT還有個回放的過程,低效了)
總結
以上是生活随笔為你收集整理的IO模型、IO多路复用的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 小儿推拿22种手法(动态图)轻松解决各种
- 下一篇: 过敏性鼻炎怎么治疗