Linux下的5種I/O模型與3組I/O複用

HickeyZhang發表於2022-02-03

引言

上一篇文章中介紹了一些無緩衝檔案I/O函式,但應該什麼時機呼叫這些函式,呼叫這些I/O函式時程式和核心的行為如何,如何高效率地實現I/O?這篇文章就來談一談Linux下的5種I/O模型,以及高效能伺服器程式設計中常用的I/O複用,為後面實現精簡版本的高效能伺服器做鋪墊。

Linux下的5種I/O模型

Linux下可用的5種I/O模型:

  • 阻塞I/O
  • 非阻塞I/O
  • I/O複用
  • 訊號驅動式I/O
  • 非同步I/O

一個輸入操作通常包括兩個不同階段:

  1. 等待資料準備好
  2. 從核心向程式複製資料

對於套接字的輸入操作來說,第一步是等待資料從網路中到達,當分組到達時,它被暫存到核心的某個緩衝區。第二步就是把資料從核心緩衝區複製到應用程式緩衝區。

阻塞式I/O

預設情況下,所有套接字都是阻塞的。

阻塞I/O的典型情形如下:

image-20220203153948017

程式對TCP套接字呼叫read,系統呼叫直到資料包到達並被複制到應用程式的緩衝區或發生錯誤才返回。其中等待資料的時間是不可控的,取決於網路何時有資料到來。而對於期間可能發生的錯誤,最常見的就是被訊號中斷。

這樣的I/O就是阻塞式I/O。

非阻塞I/O

當程式把某個檔案描述符設定為非阻塞時,典型的情形如下所示:

image-20220203155600033

對設定為非阻塞的檔案描述符呼叫recvfrom,若沒有資料準備好,recvfrom立即返回錯誤(錯誤號為EWOULDBLOCK)(呼叫真的出錯時也會立即返回,需要根據errno來區分,常見的因非阻塞事件未發生時的errno為EAGAINEWOULDBLOCKEINPROGRESS)。

若資料準備好,則需要等待核心把資料從核心緩衝區拷貝到程式空間緩衝區。

I/O複用

典型的I/O複用模型如下:

image-20220203160504612

有了I/O複用,我們可以阻塞在I/O複用函式上(select、poll、epoll),等待多個檔案就緒,而不用阻塞於對單個檔案描述符讀寫的系統呼叫。使用select的優勢在於可以等待多個描述符就緒。

訊號驅動I/O

典型的訊號驅動I/O模型如下:

image-20220203160440039

首先開啟套接字的訊號驅動I/O功能,並安裝訊號處理函式,程式接下來繼續做其他工作,沒有被阻塞

當資料包準備好時,核心就為程式產生SIGIO訊號,呼叫前面安裝的訊號處理函式,在處理函式中呼叫recvfrom。

優勢在於等待資料包到達期間程式無需阻塞。

非同步I/O

非同步I/O模型如下:

image-20220203160900493

在非同步I/O中,呼叫aio_read將描述符、程式緩衝區指標、程式緩衝區大小、檔案偏移告知核心,並告訴核心當操作完成時如何通知我們。該系統呼叫立即返回,在等待I/O期間程式不被阻塞。

5種I/O模型比較

仔細分析就會發現,雖然有些I/O模型可以不必等待資料就緒(不必阻塞於資料在核心緩衝區準備好的這段時間),但除了非同步I/O外,其餘4種I/O都無法避免程式阻塞於資料從核心複製到程式空間的這部分時間。

因此,前4種I/O都屬於同步式I/O,只有非同步I/O模型與POSIX定義的非同步I/O匹配。非同步I/O將資料從核心複製到使用者空間這一任務完全託付給核心處理,不必由程式實時監管,程式只需要給出一些必要資訊即可(學過作業系統的同學很快就會發現這有點類似於通過通道處理機在裝置和記憶體之間傳送資料)。

下圖是這5種I/O模型的時序比較,幫助大家更好理解他們之間的差別:

image-20220203162319638

I/O複用

Linux提供了三個系統呼叫以支援I/O複用:

  • select
  • poll
  • epoll_wait

我們可以用這些系統呼叫同時等待多個描述符上的事件就緒。I/O複用函式本身是阻塞的,他們能提高效率的原因就在於具有同時監聽多個I/O事件的能力。

select呼叫

select函式的定義如下:

#include <sys/select.h>
#include <sys/time.h>
int select(int maxfdp1, fd_set* read_set, fd_set* write_set, fd_set* except_set, const struct timeval* 
            timeout);
返回值:
    有就緒描述符時返回就緒的描述符的數目,超時則返回0,出錯返回-1.

引數說明

timeout引數指定select函式最多等待多長時間,如果這段事件內沒有就緒描述符,就返回0。timeval結構體定義如下:

struct timeval{
  	long tv_sec;
    long tv_usec;
};

timeout的3種情形:

  • timeout引數可以為NULL,代表select將永遠阻塞直到有描述符就緒。
  • timeout引數不為NULL,且timeval各欄位不為0。
  • timeout引數不為NULL,但timeval各欄位為0。

select在阻塞期間會被程式在等待期間捕獲的訊號中斷,並從訊號處理函式返回,那麼select就會返回EINTR的錯誤。

read_set、write_set、except_set分別指定等待可讀、可寫、異常(異常條件目前只需關注套接字存在帶外資料時的情況)發生的檔案描述符集。

fd_set的設定要通過巨集來操作:

void FD_ZERO(fd_set* fdset);  // 清空,fd_set變數使用前必須要清空
void FD_SET(int fd, fd_set* fdset);  // 設定某描述符
void FD_CLR(int fd, fd_set* fdset);  // 取消設定某描述符
int FD_ISSET(int fd, fd_set* fdset); // 檢查是否設定

maxfdp1引數為等待的所有描述符的最大值加1。

select返回時,會修改fd_set,將未就緒的置為0,就緒的保持。因此中間三個引數為傳入傳出引數。而且,我們必須通過FD_ISSET巨集遍歷fd_set,來檢查哪些描述符就緒,每次重新呼叫select時,我們還必須重新設定要等待的描述符集合fd_set。

當某個套接字發生錯誤時,由select標記為既可讀又可寫。此處的錯誤不是excep_set等待的異常條件。

侷限

通過上面的介紹可以發現,select返回時我們需要輪詢以獲取就緒的描述符,效率不高,而且每次呼叫select必須重新設定fd_set。

此外,select能監聽的最大描述符數目是有限制的。

poll呼叫

poll函式的定義如下:

#include <poll.h>
int poll(strcut pollfd* fd_array, unsigned long nfds, int timeout);

返回值:
    若有就緒返回就緒描述符數目;
    超時返回0;
    出錯返回-1。

引數說明

struct pollfd定義如下:

struct pollfd{
    int fd;
    short events;
    short revents;
};

要測試的條件由events指定,返回時revents儲存就緒事件。

timeout引數指定等待的毫秒數。可以為INFTIM、0、正數。

侷限

雖然poll沒有了監聽描述符數目的限制,但poll返回時我們還是要輪詢遍歷pollfd,來檢查就緒的描述符,同樣效率不高。

epoll呼叫

不同於select、poll,epoll把使用者關心的檔案描述符上的事件放在核心的一個事件表中,因此無需每次呼叫都重複傳入檔案描述符集或事件集。epoll需要使用一個額外的檔案描述符,來標識核心中的這個事件表

建立事件表的函式epoll_create函式定義如下:

#include <sys/epoll.h>
int epoll_create(int size);

返回:
    成功返回表示事件表的描述符。

size引數只是給核心一個提示,告訴核心事件表需要多大。

操作事件表的函式如下:

#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, struct epoll_event* event);

返回值:
    成功返回0;
    失敗返回-1。

epfd為epoll_create的返回值。

op指定操作型別,可以為:

  • EPOLL_CTL_ADD:註冊fd上的事件。
  • EPOLL_CTL_MOD:修改fd上的註冊事件。
  • EPOLL_CTL_DEL:刪除fd上的註冊事件。

event指定事件和描述符,struct epoll_event定義如下:

struct epoll_event{
	__uint32_t events; //事件
    epoll_data_t data; //使用者資料
};

typedef union epoll_data{
    void* ptr;
    int fd;  //指定描述符
    uint32_t u32;
    uint64_t u64;
}epoll_data_t;

發起等待的epoll_wait函式定義如下:

#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout);

返回值:
    成功返回就緒的描述符的個數;
    失敗返回-1。

events為傳出引數,結合maxevents說明了預先開闢的陣列空間用於存放就緒的epoll_event。timeout引數與poll介面的timeout相同。

epoll_wait函式如果檢測到事件,就將所有就緒的事件從核心事件表中複製到events指向的陣列中,這個引數只為傳出引數,不像select、poll引數既傳入又傳出需要輪詢,提高了效率。

3種呼叫的區別

對於3種呼叫的詳細區別以及他們的具體使用例子不可能在本篇文章的篇幅中展開,以後的文章具體分析,這裡貼出《Linux高效能伺服器程式設計》一書上的圖例:

image-20220203174253833

參考資料

《UNP 卷1》 3/e
《Linux高效能伺服器程式設計》
《後臺開發 核心技術與應用實踐》

相關文章