但是,I/O多路復用中是如何判斷文件“可讀”/“可寫”的?
在學習I/O多路復用時,經常會得到如下描述:
...,在其中任何一個或多個描述符 準備好進行 I/O 操作(可讀、可寫或異常)時獲得通知 。
那么,操作系統內核到底是如何判斷某個文件描述符“可讀”/“可寫”呢?在達到相關狀態后,是如何“立即”通知到應用程序的呢?本文在探究這個問題。
I/O 多路復用與文件描述符狀態檢測
在進行網絡編程或處理其他類型的 I/O 操作時,一個常見的挑戰是如何高效地管理多個并發的 I/O 通道。如果為每個連接或文件都創建一個單獨的線程或進程來阻塞等待 I/O,當連接數非常多時,系統資源的開銷(如內存、上下文切換成本)會變得非常巨大。
I/O 多路復用 (I/O Multiplexing) 技術應運而生,它允許單個進程或線程監視多個 文件描述符 (file descriptor),并在其中任何一個或多個變得“就緒”(例如,可讀或可寫)時得到通知,從而可以在單個執行流中處理多個 I/O 事件。Linux 提供了幾種經典的 I/O 多路復用系統調用,主要是 select、poll 和 epoll。
要理解這些系統調用如何工作,關鍵在于理解 Linux 內核是如何跟蹤和通知文件描述符狀態變化的。這涉及到內核中的文件系統抽象、網絡協議棧以及一種核心機制: 等待隊列 (wait queue) 。
文件描述符與內核結構
在 Linux 中,“一切皆文件”是一個核心設計哲學。無論是磁盤文件、管道、終端還是網絡套接字 (socket),在用戶空間看來,它們都通過一個非負整數來標識,即文件描述符。
當應用程序通過 socket() 系統調用創建一個套接字時,內核會執行以下關鍵步驟:
- 在內核空間創建表示該套接字的核心數據結構,通常是 struct socket。這個結構包含了套接字的狀態、類型、協議族、收發緩沖區、指向協議層處理函數的指針等信息。
- 創建一個 struct file 結構。這是內核中代表一個打開文件的通用結構,它包含訪問模式、當前偏移量等,并且有一個重要的成員 f_op,指向一個 file_operations 結構。
- file_operations 結構包含了一系列函數指針,定義了可以對這類文件執行的操作,如 read、write、poll、release 等。對于套接字,struct file 會通過其私有數據指針 (private_data) 關聯到對應的 struct socket,并且其 f_op 會指向一套適用于套接字的文件操作函數集。
- 內核在當前進程的文件描述符表中找到一個空閑位置,將該位置指向新創建的 struct file 結構,并將該位置的索引(即文件描述符)返回給用戶空間。
因此,后續所有對該文件描述符的操作(如 read, write, bind, listen, accept, select, poll, epoll_ctl 等),都會通過系統調用進入內核,內核根據文件描述符找到對應的 struct file,再通過 f_op 調用相應的內核函數來執行。
等待隊列:事件通知的核心
操作系統需要一種機制,讓某個進程在等待特定事件(例如,數據到達套接字、套接字發送緩沖區有可用空間)發生時能夠暫停執行(睡眠),并在事件發生后被喚醒。這就是 等待隊列 (wait queue) 機制 (wait_queue_head_t 在 Linux 內核中)。
struct list_head {
struct list_head *next, *prev;
};
struct wait_queue_head {
spinlock_t lock;
struct list_head head;
};
typedef struct wait_queue_head wait_queue_head_t;
struct wait_queue_entry {
unsigned int flags;
void *private;
wait_queue_func_t func;
struct list_head entry;
};
typedef struct wait_queue_entry wait_queue_entry_t;
等待隊列 (wait_queue_head_t) 是 Linux 內核中的一個數據結構,用于管理一組等待特定事件(如數據到達)的進程。它本質上是一個鏈表,鏈表中的每個節點 (wait_queue_entry_t) 代表一個等待該事件的進程。
- wait_queue_head_t:包含一個鎖(保護隊列)和一個指向等待條目鏈表的指針。
- wait_queue_entry_t:包含進程的標識(如任務結構體 task_struct)和指向下一個條目的指針。
等待隊列允許進程在事件未發生時暫停執行,并在事件發生時被喚醒,是阻塞式 I/O 和多路復用的基礎。
內核中幾乎所有可能導致阻塞等待的資源(如套接字的接收緩沖區、發送緩沖區、管道、鎖等)都會關聯一個或多個等待隊列。
數據結構中的嵌入
每個資源在內核中都有對應的數據結構。例如,對于網絡套接字,內核維護一個 struct socket 結構,其中嵌入了與接收緩沖區和發送緩沖區相關的等待隊列。通常,每個套接字會有兩個獨立的 wait_queue_head_t:
- 一個用于接收數據(等待接收緩沖區有數據)。
- 一個用于發送數據(等待發送緩沖區有空間)。
這些等待隊列是 struct socket 或相關結構(如 struct sock)的成員變量,直接與資源綁定。
當進程對資源執行操作(例如通過 read() 讀取套接字數據)時,如果資源不可用(接收緩沖區為空),內核會:
- 內核創建一個 wait_queue_entry_t,關聯到當前進程的 task_struct。
- 將這個條目加入與接收緩沖區相關的等待隊列。
- 將進程狀態設置為睡眠(TASK_INTERRUPTIBLE 或 TASK_UNINTERRUPTIBLE),然后調用調度器 schedule(),讓出 CPU,運行其他進程。
對于套接字,struct sock(TCP/IP 協議棧中的核心結構)包含字段如 sk_sleep,它指向一個等待隊列。當接收緩沖區為空時,進程會被加入這個隊列;當數據到達時,協議棧會操作這個隊列來喚醒進程。
喚醒過程是如何實現的?
當網絡協議棧(如 TCP/IP)收到數據并將其放入套接字的接收緩沖區時:
- 事件檢測: 協議棧代碼檢測到接收緩沖區從空變為非空。
- 檢查等待隊列: 協議棧訪問與該緩沖區關聯的 wait_queue_head_t,檢查是否有進程在等待。
- 調用 wake_up(): 如果隊列不為空,協議棧調用內核函數 wake_up()(或其變體,如 wake_up_interruptible())。
- wake_up() 的工作:
- 遍歷等待隊列中的每個 wait_queue_entry_t。
- 將對應的進程狀態從睡眠改為 TASK_RUNNING。
- 將這些進程加入 CPU 的運行隊列,等待調度器重新調度它們。
進程 A 調用 read(sockfd) -> 接收緩沖區為空
-> 加入等待隊列 -> 進程睡眠
數據到達 -> 協議棧放入緩沖區 -> 調用 wake_up()
-> 進程 A 被喚醒 -> 重新調度 -> read() 返回數據
可讀性
當一個套接字接收到數據時,網絡協議棧(如 TCP/IP 棧)處理完數據包后,會將數據放入該套接字的接收緩沖區。如果此時有進程正在等待該套接字變為可讀(即接收緩沖區中有數據),協議棧代碼會 喚醒 (wake up) 在該套接字接收緩沖區關聯的等待隊列上睡眠的所有進程。
可寫性
當應用程序通過 write() 或 send() 發送數據時,數據首先被復制到套接字的發送緩沖區。網絡協議棧隨后從緩沖區取出數據并發送到網絡。當數據成功發送出去,或者發送緩沖區中的空間被釋放到某個閾值以上時,協議棧代碼會 喚醒 在該套接字發送緩沖區關聯的等待隊列上睡眠的所有進程,通知它們現在可以寫入更多數據了。
假設發送緩沖區大小為 8KB,高水位標記為 6KB:
- 寫入 8KB 數據 -> 緩沖區滿 -> 進程睡眠。
- 協議棧發送 3KB 數據 -> 剩余 5KB(低于高水位) -> 喚醒進程 -> 可寫。
這個“生產者-消費者”模型(網絡棧是數據的生產者/消費者,應用程序是數據的消費者/生產者)通過等待隊列和喚醒機制實現,是理解 I/O 事件通知的基礎。
select 和 poll 的工作原理
select 和 poll 是較早的 I/O 多路復用接口。它們的工作方式類似:
- 用戶調用 :應用程序準備好要監視的文件描述符集合(select 使用 fd_set,poll 使用 struct pollfd 數組),并指定關心的事件類型(可讀、可寫、異常),然后調用 select 或 poll 系統調用。
- 內核操作 :
某個被監視的文件描述符相關的等待隊列被喚醒(例如,因為數據到達或緩沖區變空)。
超時時間到達。
收到一個信號。
- 檢查當前狀態 :立即檢查該文件描述符的當前狀態是否滿足用戶請求的事件(例如,接收緩沖區是否非空?發送緩沖區是否有足夠空間?是否有錯誤?)。如果滿足,就標記該文件描述符為就緒。
- 注冊等待 :如果當前狀態不滿足,并且調用者準備阻塞等待,則該 poll 方法會將當前進程添加到與所關心事件相關的 等待隊列 上。這是通過內核函數 poll_wait() 實現的,它并不直接使進程睡眠,只是建立一個關聯:如果未來該等待隊列被喚醒,當前正在執行 select/poll 的進程也應該被喚醒。
- 內核接收到文件描述符列表和關心的事件。
- 內核遍歷應用程序提供的 每一個 文件描述符。
- 對于每個文件描述符,內核找到對應的 struct file,然后調用其 file_operations 結構中的 poll 方法(例如,對于套接字,最終會調用到類似 sock_poll 的函數)。
- 該 poll 方法執行兩個關鍵任務:
- 遍歷完所有文件描述符后,如果發現至少有一個文件描述符是就緒的,select/poll 就將就緒信息返回給應用程序。
- 如果沒有文件描述符就緒,并且設置了超時時間,則進程會 睡眠 (阻塞),直到以下任一情況發生:
- 當進程被喚醒后(如果是因等待隊列事件喚醒),內核并 不知道 是哪個具體的文件描述符導致了喚醒。因此,內核需要 重新遍歷一遍 所有被監視的文件描述符,再次調用它們的 poll 方法檢查狀態,找出哪些現在是就緒的,然后將結果返回給用戶。
解釋一下 poll_wait() :它建立了一種關聯:當等待隊列被喚醒時,當前執行 select 或 poll 的進程也會被喚醒。
- 在 select 或 poll 的內核實現中,對于每個文件描述符,內核調用其 file_operations 中的 poll 方法(例如 sock_poll)。
- 在 poll 方法中,如果事件尚未就緒(例如接收緩沖區為空),會調用 poll_wait(file, wait_queue_head_t, poll_table),poll_wait() 中創建一個 wait_queue_entry_t,關聯到當前進程:
file:文件描述符對應的 struct file。
wait_queue_head_t:與事件(如接收緩沖區)關聯的等待隊列。
poll_table:select/poll 傳入的臨時結構,用于收集等待隊列。
- poll_wait() 只是注冊關聯,不會直接調用 schedule() 使進程睡眠。睡眠是在 select/poll 遍歷所有文件描述符后統一處理的。
select(fd_set) -> 內核遍歷 fd
-> fd1: poll() -> poll_wait(接收隊列) -> 注冊進程
-> fd2: poll() -> poll_wait(發送隊列) -> 注冊進程
無就緒 fd -> 進程睡眠
數據到達 fd1 -> wake_up(接收隊列) -> 進程喚醒 -> select 返回
select 和 poll 的主要缺點
- 效率問題 :每次調用都需要將整個文件描述符集合從用戶空間拷貝到內核空間。更重要的是,內核需要線性遍歷所有被監視的文件描述符來檢查狀態和注冊等待,喚醒后還需要再次遍歷來確定哪些就緒。當監視的文件描述符數量 N 很大時,這個 O(N) 的開銷變得非常顯著。
- select 有最大文件描述符數量的限制(通常由 FD_SETSIZE 定義)。poll 沒有這個限制,但仍有上述效率問題。
epoll:更高效的事件通知
epoll 是 Linux 對 select 和 poll 的重大改進,旨在解決大規模并發連接下的性能瓶頸。它采用了一種不同的、基于 回調 (callback) 的事件驅動機制:
- **epoll_create() / epoll_create1()**:創建一個 epoll 實例。這會在內核中創建一個特殊的數據結構,用于維護兩個列表:
監視列表 (Interest List) :通常使用高效的數據結構(如紅黑樹或哈希表)存儲所有用戶通過 epoll_ctl 添加的、需要監視的文件描述符及其關心的事件。
就緒列表 (Ready List) :一個鏈表,存儲那些已經被內核檢測到發生就緒事件、但尚未被 epoll_wait 報告給用戶的文件描述符。
這個 epoll 實例本身也由一個文件描述符表示。
2.epoll_ctl() :用于向 epoll 實例的監視列表添加 (EPOLL_CTL_ADD)、修改 (EPOLL_CTL_MOD) 或刪除 (EPOLL_CTL_DEL) 文件描述符。
- 關鍵操作:當使用 EPOLL_CTL_ADD 添加一個文件描述符 fd 時,內核不僅將其加入 epoll 實例的監視列表,更重要的是,它會在與 fd 相關的 等待隊列 上注冊一個 回調函數。
- 這個回調函數非常特殊:當 fd 對應的資源(如套接字緩沖區)狀態改變,導致其關聯的等待隊列被喚醒時,這個注冊的回調函數會被執行。
- 回調函數的任務是:檢查 fd 的當前狀態是否匹配 epoll 實例對其關心的事件。如果匹配,就將這個 fd 添加到 epoll 實例的 就緒列表 中。如果此時有進程正在 epoll_wait 中睡眠等待該 epoll 實例,則喚醒該進程。
+-----------------+ epoll_ctl(ADD fd) +----------------------------+
| epoll instance | <-------------------------- | Application Process |
| - Interest List | +----------------------------+
| - Ready List | |
| - Wait Queue | Registers Callback | system call
+-----------------+---------------------------> +----------------------------+
| Kernel |
| +------------------------+ |
| | struct file (for fd) | |
| | - f_op | |
| | - private_data (socket)| |
| +---------+--------------+ |
| | |
| v |
| +---------+-------------+ |
| | Wait Queue (e.g., rx) | |
| | - epoll callback entry| |
| +-----------------------+ |
+----------------------------+
3.**epoll_wait()**:等待 epoll 實例監視的文件描述符上發生事件。
- 調用 epoll_wait 時,內核首先檢查 epoll 實例的 就緒列表。
- 如果就緒列表 非空,內核直接將就緒列表中的文件描述符信息拷貝到用戶空間提供的緩沖區,并立即返回就緒的文件描述符數量。
- 如果就緒列表 為空,進程將 睡眠,等待在 epoll 實例自身的等待隊列上。
- 當某個被監視的文件描述符 fd 發生事件(如數據到達),其關聯的等待隊列被喚醒,觸發之前注冊的 epoll 回調。
- 回調函數將 fd 加入 epoll 實例的就緒列表,并喚醒在 epoll_wait 中等待該實例的進程。
- epoll_wait 被喚醒后,發現就緒列表非空,于是收集就緒信息并返回給用戶。
epoll 的優勢
- 高效 :epoll_wait 的復雜度通常是 O(1),因為它只需要檢查就緒列表,而不需要像 select/poll 那樣遍歷所有監視的文件描述符。文件描述符狀態的檢查和就緒列表的填充是由事件發生時的回調機制異步完成的。
- 回調機制 :避免了 select/poll 在每次調用和喚醒時都需要重復遍歷所有文件描述符的問題。文件描述符和 epoll 實例的關聯(包括回調注冊)只需要在 epoll_ctl 時建立一次。
- 邊緣觸發 (Edge Triggered, ET) 與水平觸發 (Level Triggered, LT) :epoll 支持這兩種模式。LT 模式(默認)行為類似于 poll,只要條件滿足(如緩沖區非空),epoll_wait 就會一直報告就緒。ET 模式下,只有當狀態從未就緒變為就緒時,epoll_wait 才會報告一次,之后即使條件仍然滿足也不會再報告,直到應用程序處理了該事件(例如,讀取了所有數據使得緩沖區變空,然后又有新數據到達)。ET 模式通常能提供更高的性能,但編程也更復雜,需要確保每次事件通知后都將數據處理完畢。
LT 模式(默認)
#include <sys/epoll.h>
#include <unistd.h>
#include <stdio.h>
int main() {
int epfd = epoll_create1(0);
int sockfd = /* 假設已創建并綁定監聽的套接字 */;
struct epoll_event event;
event.events = EPOLLIN; // LT 模式,默認
event.data.fd = sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &event);
struct epoll_event events[10];
while (1) {
int nfds = epoll_wait(epfd, events, 10, -1);
for (int i = 0; i < nfds; i++) {
if (events[i].data.fd == sockfd) {
char buf[1024];
ssize_t n = read(sockfd, buf, sizeof(buf));
if (n > 0) {
printf("Read %zd bytes\n", n);
// 可只讀部分數據,下次仍會觸發
} else if (n == 0) {
printf("Connection closed\n");
}
}
}
}
}
ET 模式
#include <sys/epoll.h>
#include <unistd.h>
#include <errno.h>
#include <stdio.h>
int main() {
int epfd = epoll_create1(0);
int sockfd = /* 假設已創建并綁定監聽的套接字 */;
struct epoll_event event;
event.events = EPOLLIN | EPOLLET; // ET 模式
event.data.fd = sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &event);
struct epoll_event events[10];
while (1) {
int nfds = epoll_wait(epfd, events, 10, -1);
for (int i = 0; i < nfds; i++) {
if (events[i].data.fd == sockfd) {
while (1) { // 必須一次性讀完
char buf[1024];
ssize_t n = read(sockfd, buf, sizeof(buf));
if (n > 0) {
printf("Read %zd bytes\n", n);
} else if (n == 0) {
printf("Connection closed\n");
break;
} else {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
printf("Buffer drained\n");
break;
}
}
}
}
}
}
}
連接套接字(socket, bind, listen, accept)與就緒狀態
現在我們將這些概念與服務器套接字的工作流程聯系起來:
- socket() : 創建一個套接字文件描述符 sockfd。此時它通常既不可讀也不可寫。
- bind() : 將 sockfd 綁定到一個本地地址和端口。這本身通常不改變其可讀寫狀態。
- listen() : 將 sockfd 標記為監聽套接字,并創建兩個隊列(SYN 隊列和 Accept 隊列)。此時 sockfd 仍不可直接讀寫數據。
何時監聽套接字 sockfd 變為“可讀”?
當一個客戶端連接請求完成 TCP 三次握手后,內核會創建一個代表這個新連接的 已完成連接 (established connection),并將其放入與監聽套接字 sockfd 關聯的 Accept 隊列 中。此時,對于 select/poll/epoll 來說,監聽套接字 sockfd 就被認為是 可讀 的。調用 accept(sockfd, ...) 將會從 Accept 隊列中取出一個已完成連接,并返回一個 新的 文件描述符 connfd,這個 connfd 才代表了與客戶端的實際通信通道。如果 Accept 隊列為空,則監聽套接字 sockfd 不可讀。
- accept(): 從監聽套接字的 Accept 隊列中取出一個已完成的連接,返回一個新的已連接套接字 connfd。
何時已連接套接字 connfd 變為“可讀”?
當內核的網絡協議棧收到屬于 connfd 這個連接的數據,并將數據放入其 接收緩沖區 后,connfd 就變為可讀。此時,網絡棧會喚醒在該套接字接收緩沖區等待隊列上的進程(包括通過 epoll 注冊的回調)。
何時已連接套接字 connfd 變為“可寫”?
當 connfd 的 發送緩沖區 有足夠的可用空間來容納更多待發送的數據時,connfd 就變為可寫。當內核成功將發送緩沖區中的數據發送到網絡,釋放了空間后,會喚醒在該套接字發送緩沖區等待隊列上的進程(包括 epoll 回調)。初始狀態下,新創建的 connfd 通常是可寫的。
異常狀態 :通常指帶外數據到達,或者發生某些錯誤(如連接被對方重置 RST)。
總結
Linux 內核通過為每個可能阻塞的 I/O 資源(如套接字緩沖區)維護 等待隊列 來跟蹤哪些進程在等待事件。當事件發生時(數據到達、緩沖區變空),內核代碼(如網絡協議棧)會 喚醒 相應等待隊列上的進程。
- select 和 poll 在每次調用時,都需要遍歷所有被監視的文件描述符,檢查它們的當前狀態,并將進程注冊到相關的等待隊列上。喚醒后還需要再次遍歷以確定哪些就緒。
- epoll 通過 epoll_ctl 預先在文件描述符的等待隊列上注冊 回調函數 。當事件發生并喚醒等待隊列時,回調函數被觸發,它負責將就緒的文件描述符添加到 epoll 實例的 就緒列表 中,并喚醒等待在 epoll_wait 上的進程。epoll_wait 只需檢查這個就緒列表即可,大大提高了效率。
理解等待隊列和喚醒機制,以及 epoll 基于回調的事件驅動模型,是掌握 Linux 下高性能網絡編程和 I/O 多路復用技術的關鍵。
總結
- 可讀
監聽套接字:Accept 隊列非空(有新連接)。
已連接套接字:接收緩沖區有數據。
內核通過網絡協議棧監控緩沖區狀態。
- 可寫
發送緩沖區有足夠空間(低于高水位)。
協議棧監控發送進度并更新空間。
如何“立即”通知應用程序?
- 等待隊列機制: 資源狀態變化時,協議棧調用 wake_up() 喚醒等待隊列上的進程。
- select/poll: 通過 poll_wait() 注冊等待,事件發生時喚醒并重新檢查狀態。
- epoll: 通過回調函數異步將就緒文件描述符加入就緒列表,epoll_wait 直接返回。