深入理解Linux IO模型與Reactor、Proactor模式

本文總結了Linux I/O模型、Proactor和Reactor模型等相關概念與核心流程,參考自相關教學資源和網絡博客,並附上自身理解。如有遺漏或錯誤,還望海涵並指出。謝謝!

一.基本概念

1.用戶空間與內核空間

Linux內核給每個進程都提供了一個獨立的虛擬空間,並且這個地址空間是連續的,進程就可以通過這個地址空間很方便地訪問虛擬內存。

用戶進程所能訪問到的地址空間被稱爲用戶空間,而被Linux內核系統調用或使用的地址被稱爲內核空間。

一個32位的Linux系統,其地址空間爲2的32次方,即爲4GB;而64位的Linux系統,其尋址空間爲2的48次方,即256TB。

其中,32位系統將低位的3GB用於充當用戶空間,高位1GB用於充當內核空間;64位系統將低位128TB充當用戶空間,中間的2的16次方充當空洞,不會使用,而高128TB充當內核空間。

在這裏插入圖片描述

一般虛擬內存空間還可劃分爲一下幾個區域:

  • 只讀段: 代碼和常量
  • 數據段:全局變量
  • 堆:動態分配內存,從低地址開始向上增長
  • 文件映射段: 動態庫、共享內存
  • 棧: 局部變量,函數調用的上下文,棧大小一般固定是8MB

2.進程切換與進程阻塞

1.進程切換

進程由CPU來進行調度和分配,通常可以有時間片輪轉、優先級隊列、多級反饋隊列等方式來進行調度,在一個進程運行的過程中,內核有能力掛起進程(通常將進程控制塊等數據換入磁盤,需要時再換出),在需要的時候在將其重新調用,這個過程被稱爲進程切換。進程切換通常需要以下過程:

  1. 保存要切換的進程上下文,包括程序計數器和一些寄存器
  2. 更新PCB信息
  3. 把進程的PCB移入相應的隊列,如就緒或阻塞隊列
  4. 選擇另一個進程執行,並更新其PCB
  5. 更新內存管理的數據結構
  6. 如果要換回原先的進程,則重複1~5這個過程

由於進程切換需要進行許多的步驟與操作,所以這是一個很消耗CPU資源的過程。

2.進程阻塞

進程阻塞指的是進程由於期待的某些事件未發生,如請求系統資源失敗、等待某種操作的完成、新數據尚未到達或無新工作做等,則由系統自動執行阻塞原語(Block),使自己由運行狀態變爲阻塞狀態。

進程的阻塞是進程自身的一種主動行爲,也因此只有處於運行態的進程(獲得CPU時間片),纔可能將其轉爲阻塞狀態。當進程進入阻塞狀態,不佔用CPU資源。

3.文件描述符

文件描述符(File Descriptor,fd)是一個用於表述指向文件的引用的抽象化概念,實際上,它是一個索引值,指向內核爲每一個進程所維護的該進程打開文件的記錄表。當程序打開一個現有文件或者創建一個新文件時,內核向進程返回一個文件描述符。進程就可以通過文件描述符表(fd_set)來找到所需的文件。

在這裏插入圖片描述

4.Buffer I/O過程

一般Linux系統的I/O過程都是具有緩衝區(Buffer)支持的,以減少讀取的次數和提升讀取效率。

在進程請求讀取數據時(例如請求讀取Socket連接數據),內核會將數據先拷貝到內核緩衝區(Kernel Buffer)中,當緩衝區被打滿後,會將其拷貝到相應進程的地址空間。

在這裏插入圖片描述

二.Linux I/O模型

由於Linux存在Buffer I/O的過程,需要將數據拷貝到內核緩衝區,然後再拷貝到相映的進程地址空間中,所以這個拷貝的過程存在的多樣性就構成了Linux的I/O模型的多樣性,這也是IO模型存在的基本原因。

我們可以將數據獲取的過程分爲兩個階段:

1.數據準備階段(打滿內核緩衝區)

2.內核數據複製到進程用戶空間

Linux系統由此產生了五種網絡模式的方案。

  • 阻塞 I/O(Blocking IO)
  • 非阻塞 I/O(Nonblocking IO)
  • I/O 多路複用( IO Multiplexing)
  • 信號驅動 I/O( Signal Driven IO)
  • 異步 I/O(Asynchronous IO)

下面來簡單地看看每一種網絡模型的工作過程:

1.阻塞I/O

在這裏插入圖片描述

在阻塞I/O中,應用進程相內核發出接收調用(recvfrom),內核會在數據緩存區打滿後,將數據拷貝到用戶進程空間,然後返回一個成功的標示,應用進程獲取到數據。

在上述過程中,發起recvfrom的應用進程是完全阻塞在該條請求鏈路中的,直到內核將數據準備好返回給它。

所以可以看出,阻塞I/O有性能上的不足,一個進程在一段時間內都需要阻塞在一條請求鏈路之中,如果產生了大量的請求那麼就需要開啓大量的進程去完成數據讀取的過程。

2.非阻塞I/O

在這裏插入圖片描述

非阻塞I/O與阻塞I/O最大的不同在於,用戶進程需要不斷的主動輪詢內核數據準備好了沒有,如果數據沒有準備好,那麼內核會立即返回錯誤,此時用戶進程無需在鏈路中阻塞,而是做其他的操作,在一定時間後繼續發起輪訓。當內核準備好了數據時,用戶進程纔開始正式地阻塞在這條請求鏈路之中,直到數據拷貝到用戶空間完成,響應成功。

可以看出,非阻塞I/O相對於阻塞I/O來說,對於應用進程的利用效率更高了,因爲應用進程不需要每次發送read請求後就阻塞,而是可以繼續做自己的事情,直到內核準備好了數據纔開始阻塞,處理數據。

但是需要注意的是,非阻塞I/O仍不能解決大量請求的問題,因爲在發生數據拷貝的過程中,進程仍然是需要阻塞的。

3.I/O多路複用

在這裏插入圖片描述

I/O多路複用有三個特別的系統調用select、poll、epoll函數。select調用是內核級別的,select輪詢相對非阻塞I/O的輪詢的區別在於:

1.select可以等待多個socket文件描述符,只需要一個進程就能實現同時對多個IO端口進行監聽,當其中任何一個socket的數據準好了,就能返回進行可讀,然後調用其他進程再進行recv系統調用,將數據由內核拷貝到用戶進程。

2.select或poll調用之後,會阻塞當前進程,但與阻塞I/O、非阻塞I/O不同在於,此時的select不是等到內核數據全部到達再處理, 而是有了一部分數據就會調用用戶進程來處理,這個過程看起來就像是“非阻塞的”(實在發生recv時還是阻塞的)。

I/O多路複用技術通過把多個I/O的阻塞複用到同一個select的阻塞上,從而使得系統在單線程的情況下可以同時處理多個客戶端請求。與傳統的多線程/多進程模型比,I/O多路複用的最大優勢是系統開銷小,系統不需要創建新的額外進程或者線程,也不需要維護這些進程和線程的運行,降底了系統的維護工作量,節省了系統資源,I/O多路複用的主要應用場景如下:

  1. 服務器需要同時處理多個處於監聽狀態或者多個連接狀態的套接字。

  2. 服務器需要同時處理多種網絡協議的套接字。

4.事件通知I/O

在這裏插入圖片描述

允許Socket進行信號驅動IO,並安裝一個信號處理函數,進程繼續運行並不阻塞。當數據準備好時,進程會收到一個SIGIO信號,可以在信號處理函數中調用I/O操作函數處理數據。

5.異步非阻塞I/O

在這裏插入圖片描述

相對於同步IO,異步IO不是順序執行。用戶進程進行aio_read系統調用之後,無論內核數據是否準備好,都會直接返回給用戶進程,然後用戶態進程可以去做別的事情。等到socket數據準備好了,內核直接複製數據給進程,然後從內核向進程發送通知。在I/O的兩個階段,進程都是非阻塞的。

6.5種I/O模型總結

在這裏插入圖片描述

總體上看,阻塞I/O、非阻塞I/O、I/O多路複用、事件事件通知I/O都屬於同步式I/O,只有異步I/O屬於非同步式I/O。

因爲區分同步或非同步的重要一點在與,整個進程對於數據的I/O過程是否需要阻塞,也就是在內核將數據拷貝到用戶進程空間時需不需要阻塞。

三.select、poll與epoll

select、poll和epoll都是操作系統爲了支持I/O多路複用所提供的內核調用,它們使得單個進程監聽多個連接Socket成爲現實,極大地減少了進程的創建數量。

1.select

select函數的定義爲:

int select (int n, 
    fd_set *readfds, 
    fd_set *writefds, 
    fd_set *exceptfds, 
    struct timeval *timeout);

select 函數監視的文件描述符分3類,分別是

  1. readfds:(可選)指針,指向一組等待可讀性檢查的Socket。
  2. writefds:(可選)指針,指向一組等待可寫性檢查的Socket。
  3. exceptfds:(可選)指針,指向一組等待錯誤檢查的Socket。

其中,timeout爲select()最多等待時間,對阻塞操作則爲NULL。

select大致的實現過程:

  1. 從用戶空間拷貝fd_set到內核空間
  2. 對fd_set中的fd進行遍歷操作,獲取到fd的狀態(可讀、可寫、異常)
  3. 只要有事件觸發,系統調用返回,將fd_set從內核空間拷貝到用戶空間,回到用戶態,用戶就可以對相關的fd作進一步的讀或者寫操作
  4. 如果沒有發生事件觸發,經過短暫等待後,繼續執行上述遍歷過程

在這裏插入圖片描述

可以看出,select的實現是基於對fd_set中的fd遍歷,並且依次判斷該fd是否具備可讀、可寫或異常的情況發生。

select有以下缺點:

1.監聽的fd有限:當系統爲32位時,只可監聽1024個fd;當系統爲64位時,最多可監聽2048個fd。

2.效率低下:由於select採用的是輪詢fd_set的方式來判斷fd的狀態,所以效率低下。

3.需要維護fd_set,當觸發事件時,需要將其從內核地址空間拷貝到用戶地址空間以讓相應的進程得以使用,消耗資源。

當然,select也有優點,譬如幾乎所有主流操作系統都實現了select函數,所以select可以作爲IO多路複用的最基礎實現函數。

2.poll

int poll (struct pollfd *fds, 
unsigned int nfds, 
int timeout);

poll的本質和select一樣,都是對fd_set中的fd進行遍歷然後獲取到觸發的事件,但是poll是基於鏈表來描述fd的,所以對於監聽的fd數量沒有限制。

poll的水平(LT)觸發:

當poll遍歷到的fd發生了觸發事件時,如果該次沒有進行處理,那麼下次遍歷到時還會有提示請求處理。

3.epoll

通過select和poll的缺點發現,如果能給套接字註冊某個回調函數,當他們活躍時(可讀、可寫或異常),發起通知完成相關操作,那就避免了輪詢,也可以提升效率。這就是epoll的本質改變:通知機制。

epoll的操作過程涉及到三個接口:

int epoll_create(int size)int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

在這裏插入圖片描述

1.int epoll_create(int size)

創建一個epoll的句柄,size用來告訴內核這個監聽的數目一共有多大,這個參數不同於select()中的第一個參數,給出最大監聽的fd+1的值,參數size並不是限制了epoll所能監聽的描述符最大個數,只是對內核初始分配內部數據結構的一個建議。

2.int epoll_ctl(int epfd,
int op,
int fd,
struct epoll_event event)

epoll_ctl函數是epoll體系中的核心,它使得epoll可以基於事件通知的機制,而不需要通過輪詢fd,大大地提高了效率。

  • epfd:是epoll_create()的返回值
  • op:表示操作,用三個宏來表示:添加EPOLL_CTL_ADD,刪除EPOLL_CTL_DEL,修改EPOLL_CTL_MOD。分別添加、刪除和修改對fd的監聽事件
  • fd:是需要監聽的fd
  • epoll_event:是告訴內核需要監聽什麼事,當發生了該事件則進行回調

3.int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout)

epoll_wait函數用於獲取到epoll_ctl返回的fd觸發回調,然後執行相應的操作。

TIP:epoll的水平觸發與邊緣觸發:

  • 水平觸發模式(Level Trigger,LT):當epoll_wait檢測到描述符事件發生並將此事件通知應用程序,應用程序可以不立即處理該事件。下次調用epoll_wait時,會再次響應應用程序並通知此事件。(默認)
  • 邊緣觸發模式(Edge Trigger,ET):當epoll_wait檢測到描述符事件發生並將此事件通知應用程序,應用程序必須立即處理該事件。如果不處理,下次調用epoll_wait時,不會再次響應應用程序並通知此事件。(效率高)

四.Reactor與Proactor模式

說完了I/O模型和IO多路複用、select、poll和epoll,現在來看看兩種網絡設計中常使用的模式:Reactor模式和Proactor模式;

通常,同步I/O模型通常用於實現Reactor模式,異步I/O模型則用於實現Proactor模式。

1.Reactor模式

Reactor模式要求主線程(I/O處理單元)只負責監聽fd上是否有事件發生,有的話就立即將該事件通知工作線程來對這個fd進行處理。除此之外,主線程不做任何其他實質性的工作。讀寫數據,接受新的連接,以及處理客戶請求均在工作線程中完成。

在這裏插入圖片描述

例如,使用epoll實現的I/O多路複用基於Reactor模式實現如下:

  1. 主線程通過epoll_create創建對socket fd列表上的監聽
  2. 主線程調用epoll_wait等待socket fd上的事件觸發
  3. 當socket fd上有事件觸發時,epoll_wait接收到回調並通知主線程將socket fd上的事件放入請求隊列
  4. 睡眠在請求隊列上的某個工作線程被喚醒,它從socket讀取數據,並處理客戶請求
  5. 工作線程將事件處理完成之後,通知epoll_wait
  6. 不斷執行上述過程

許多高性能網絡應用都應用了Reactor模式,譬如Redis、Netty、Nginx等。Reactor模式只需要一個線程/進程來處理所有的socket fd,並且將其註冊到epoll_wait中,當socket fd中有事件觸發時即通知epoll_wait,然後主線程/進程將事件放入請求隊列,喚醒工作線程/進程來對事件進行處理。

2.Proactor模式

與Reactor模式不同,Proactor模式將所有I/O操作都交給主線程和內核來處理,工作線程僅僅負責業務邏輯。這是一種典型的異步I/O模型,因爲I/O操作都交給主線程和內核來處理,這就要求I/O處理必須是非阻塞的。

在這裏插入圖片描述

使用了異步I/O的Proactor模式經典的工作流程:

  1. 主線程調用aio_read函數向內核註冊socket上的讀事件,並通知內核用戶讀緩衝區的位置,以及讀操作完成時如何通知應用程序
  2. 主線程繼續處理其他業務邏輯
  3. 當socket上的數據被讀入用戶緩衝區後,內核將嚮應用程序發送一個信號,已通知應用程序數據已經可以使用了
  4. 應用程序預先定義好的信號處理函數選擇一個工作線程來處理客戶請求
  5. 主線程繼續處理I/O操作
  6. 不斷執行上述流程

3.總結

簡單地講,Reactor模式適用於I/O多路複用模式(select、poll、epoll等),主線程只負責監聽fd並且在fd觸發時將其分發給工作線程處理,是一種經典的同步模式;Proactor模式適用於異步I/O模型下,主線程需要監聽fd並且完成I/O讀取操作,然後通知工作線程來完成業務邏輯即可。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章