­

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

引言

上一篇文章中介紹了一些無緩衝文件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高性能服務器編程》
《後台開發 核心技術與應用實踐》

Tags: