【Linux網路程式設計】I/O 多路複用技術

杨谖之發表於2024-08-28

【Linux網路程式設計】I/O 多路複用技術

什麼是 I/O 多路複用?為什麼需要 I/O 多路複用

最簡單的 socket 網路模型,就是單執行緒模型,一個同時進行監聽、處理,然而,單執行緒模型同時只能服務一個客戶端,當執行緒發生阻塞的時候,其他客戶端只能排隊等待,甚至連線失敗。

為了能夠同時服務更多的客戶端,可以使用多程序模型,多程序模型中,主程序負責監聽 socket 連線,當有客戶端連線後,建立新的程序負責專門處理該客戶端的請求,但多程序往往佔用資源較多。

多執行緒模型與多程序模型類似,但是執行緒的資源佔用遠遠小於程序。然而,在實際生產環境中,大多數網路請求往往處理很快,相對客戶端請求處理而言,執行緒的建立和銷燬佔用了更多的資源。

為了減少執行緒的建立和銷燬,可以採用執行緒池模型,執行緒池模型預先建立多個執行緒,使用“執行緒池”對這些執行緒進行管理,當有客戶端連線後,主執行緒將請求放入待處理請求佇列,而執行緒池從待處理請求佇列中獲取請求,分配給空閒的執行緒,執行緒處理完後並不直接銷燬,而是阻塞等待新的請求產生。同時,執行緒池可以根據當前請求產生速度自適應改變執行緒數量,當請求少的時候,縮減執行緒池規模,減少資源佔用,當請求多的時候,執行緒池擴容,防止請求大量堆積。

然而,以上的這些模型,一個執行緒都只能處理一個客戶端的請求,使用“I/O 多路複用”,可以使單個執行緒處理多個請求,I/O 多路複用將等待連線、讀、寫Socket等事件發生交給核心,因此執行緒不會因為這些事件阻塞。I/O 多路複用模型透過API將SOCKET交給核心,等待事件發生,當一個或多個事件,API函式將事件從核心態返回,因此執行緒可以一次處理一個或多個事件。

Linux作業系統提供了三種 I/O 多路複用的 API,即 select/poll/epoll。

select

select 系統呼叫的原型如下:

#include <sys/select.h>
int select(int nfds, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, struct timeval* timeout);

nfds 引數指定被監聽的檔案描述符的總數。它被設定為 select 監聽所有檔案描述符中的最大值加 1,因為檔案描述符是從 0 開始計數的。readfds、writefds 和 exceptfds 引數分別指向可讀、可寫與異常等事件對應的檔案描述符集合。

如下為 select 的工作模式:

select 工作模式

當呼叫 select,fd 集合需要從使用者態複製至核心態,然後由核心遍歷傳遞進來的 fd 集合,並改變傳送資料的 fd 的狀態,再由核心態複製至使用者態,使用者再遍歷已改變狀態的 fd 集合,並讀取資料。

缺點:

每次呼叫 select,都需要將 fd 集合由使用者態複製至核心態,當 fd 很多時開銷很大。
每次呼叫 select,都需要在核心態與使用者態遍歷一遍 fd 集合,當 fd 很多時仍很大。
select 支援的檔案描述符數量有限,預設是 1024(fd_set 型別決定的)。
fd 集合不能重用,每次都需要重置。

poll

poll 系統呼叫原型如下:

#include <poll>
int poll(struct pollfd* fds, nfds_t nfds, int timeout);

struct pollfd {
  int fd;         // 檔案描述符
  short events;   // 註冊的事件
  short revents;  // 實際發生的事,由核心填充
}

其中 events 告訴 poll 監聽 fd 上的哪些事件,由一系列事件的按位或,revents 則有核心修改,通知應用程式 fd 實際發生了哪些事件。poll 支援的事件型別可參考:pool 事件型別

poll 與 select 相似,相比於 select 優點則是沒有檔案描述符數量上的限制與 fd 集合也無需每次呼叫 poll 就要重置。

epoll

核心事件表

epoll 在實現和使用上與 select、poll 有很大的差異。首先 epoll 是使用一組函式來完成任務,而不是單個函式,其次 epoll 把使用者關心的檔案描述符上的事件放在核心的一個事件表中,從而無需像 select 和 poll 那樣每次呼叫都要重複傳入檔案描述符集或事件集,但 epoll 需要一個額外的檔案描述符,來唯一標識核心中的這個事件表。

使用 epoll_create() 來建立這個檔案描述符:

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

該函式返回的檔案描述符將用作其它所有 epoll 系統呼叫的第一個引數,以指定要訪問的核心事件表。

使用 epoll_ctl() 來操作 epoll 的核心事件表:

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

fd 是要操作的檔案描述符,op 則指定操作型別,操作型別有如下三種:

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

event 指定事件,它是 epoll_event 結構指標型別。epoll_event 的定義如下:

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

events 為成員描述事件型別,epoll 支援的事件型別與 poll 基本相同。data 成員中有一個 fd ,以指定事件所屬目標的檔案描述符。

epoll_ctl() 成功返回 0,失敗返回 -1 並設定 errno。

epoll_wait 函式

epoll 系列系統呼叫的主要介面是 epoll_wait(),它在一段超時時間內等待一組檔案描述符上的事件,其原型如下:

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

關於函式的引數,我們從後往前討論。timeout 指定 epoll 的超時值,當 timeout 為 -1 時,epoll_wait() 將永遠阻塞直至某個事件發生,當 timeout 為 0 時,epoll_wait() 立即返回,當 timeout 大於 0 時,表示其阻塞的時長。maxevents 指定最多監聽多少個事件,必須大於 0。

當 epoll_wait() 檢測到事件,會將所有的就緒事件從核心事件表(epfd 引數指定)複製到第二個引數 events 所指向的陣列中。而這個陣列只用於 epoll_wait() 檢測到的就緒事件,以極大提高應用程式索引就緒檔案描述符的效率。

LT 觸發與 ET 觸發

LT(Level Trigger,水平觸發):LT 是預設的工作模式,當 epoll_wait() 檢測到其上有事件發生並將此事件通知應用程式後,應用程式可以不立即處理該事件,因為應用程式下一次呼叫 epoll_wait() 後,epoll_wait() 還會再次嚮應用程式告知此事件,直至事件被處理。

ET(Edge Trigger,邊沿觸發):在檔案描述符上註冊 EPOLLET 事件後,當 epoll_wait() 檢測到其上有事件發生並將此事件通知應用程式後,應用程式必須立即處理該事件,因為後續的 epoll_wait() 不會再向應用程式通知這一事件。可見 ET 模式是是一種高效的工作模式,因為它很大程度上減少了同一個 epoll 事件被觸發的次數,同時 ET 模式是需要與非阻塞一起使用,因為需要迴圈的處理讀寫事件直至完全。

相關文章