什麼是檔案?

在 Unix 系統中,有「everything is a file」的概念,如硬體設備、進程資訊、網路連線、處理器狀態等都抽象化為檔案系統中的一個路徑。透過這種設計,使用者只需要使用一套統一的 API(例如 open(), read(), write(), close()),就能操作各種截然不同的系統資源,而不需要為每一種硬體另外撰寫專屬的操作介面。比方說 /proc/cpuinfo 這個路徑包含了 CPU 資訊, /dev/sda 則代表整顆實體硬碟。

對於使用者來說,一個檔案通常用一個路徑表示;在大部分 C 程式內,則用一個 FILE* 指標表示。而在作業系統中,則是用 File Descriptor 、 File Description 、 V-node 三個分層架構來代表一個檔案。

File Descriptor (檔案描述符,簡稱 FD),存在於進程內部(User Space 下),只是一個整數索引值。當一個程式用 fopen 開啟一個檔案並回傳 FILE* 指標時, 一個新的 FD 會被加入到每個進程獨立的 File Descriptor Table 中。實際上, FILE* 指標也只是對 FD 的包裝。
FD 的用途,是當程式要求對檔案進行操作時,讓 Kernel 知道順著該索引值,從 File Descriptor Table 知道要去存取哪個 File Description。對進程來說,它不需要知道硬碟在哪裡、檔案系統是什麼。它只要拿到這個整數代號,就能透過 Syscall 告訴核心要對代號 3 寫入資料。

File Description (檔案描述項),儲存了本次開啟的操作狀態,包含讀寫的 Offset、開啟模式(如唯讀)、Reference Count(有多少個 FD 指向這個 File Description)。當 Kernel 對檔案進行操作時,需要知道寫入的位置在哪(例如寫入第 6767 byte),如果每次存取都需要重新指定位置、或者明明不該寫入卻有實習生 vibe 出來, File Description 解決諸如此類問題。
而且 File Description 可以是每個進程各有一個,或者多個進程共用一個,因此進程們可以選擇「各自擁有獨立的狀態」,也可以選擇「多個進程一起共用同一個狀態」。可以想像,多個進程如果他們的不同 FD 都指向同一個 File Desctiption,那麼 A 讀取到第 1000 bytes, B 也會從 1000 bytes 開始讀取。

V-node,檔案的實體指標,儲存檔案的擁有者、權限、檔案大小、修改時間,以及資料實際儲存在硬碟哪些磁區的對應指標,由 File Description 指著,實際存在硬碟上。他會在檔案開啟時由 Kernel 從硬碟上複製到記憶體中,可以加快檔案存取速度。

Kernel 會分別維護三種結構來儲存上面三個東西:File Descriptor Table、Open File Table、V-node Table(或 I-node Table)。

FD Table 每個進程都會有一個,他實際上只是一個陣列,所以可以用整數 FD 存取。預設情況下,該 Table 會預先開啟三個 FD,分別是 stdin、stdout、stderr,由 0、1、2 表示。再往後開啟的檔案,都由 3 往上加。其他兩者都是整個系統共用的,不會有其他副本。

下圖展示了上面三個數據結構的關係:

來源:lusp_fileio_slides.pdf

Source Code Trace

task_struct 是 Process Control Block ,每個進程都有一個,包含該進程的各種資訊,當中的 files 是一個指向 File Descriptor Table 的指標:
/include/linux/sched.h

struct task_struct {
    ...
    struct files_struct *files;
    ...
};

這就是每個 Process 的 File Descriptor Table,可以看到 fd_array 是一個陣列:
/include/linux/fdtable.h

struct files_struct {
    ...
    struct fdtable __rcu *fdt;
    struct fdtable fdtab;
    struct file __rcu *fd_array[NR_OPEN_DEFAULT];
    ...
};

/include/linux/fdtable.h

struct fdtable {
    ...
    struct file __rcu **fd;
    ...
};

初始狀態下, files_struct.fdt 指向 files_struct.fdtab ,而 files_struct.fdtab.fd 又指向 files_struct.fd_array 。這樣實作的好處是,大部分進程開啟的檔案數量都很少(<64),所以先以靜態分配出 NR_OPEN_DEFAULT 大小,讓 fdtab.fd 指向 fd_array ,後續擴展再更換 files_struct.fdt 所指向的位置,存取時只需要統一讀取 files_struct.fdt.fd ,此外還能供多執行緒在無鎖狀態下讀取。

下面的 struct file 是存在 Open File Table 中的結構:
/include/linux/fs.h

struct file {
    ...
    fmode_t	f_mode; // 檔案模式:唯讀、唯寫...
	struct inode *f_inode; // 指向該檔案的 metadata
	loff_t f_pos; // 當前讀寫位置
	file_ref_t f_ref; // 追蹤 FD 引用
    ...
}

而 struct file 結構內則有 f_inode 指向該檔案的 metadata。實際上, linux 中沒有所謂的 Open File Table 這個"結構",他是靠 Mempool 來分配 struct file 實際上的空間,以及利用 f_ref 追蹤有多少指標指向這塊記憶體,再加上 fdtable 來達成 $O(1)$ 存取 struct file。

Vnode vs. Inode

Vnode (Virtual Node)由 Sun Microsystems 為 Solaris / BSD 系統開發,主要由 Unix 系統使用。早期的 Unix 只支援 Unix 自家的檔案系統(UFS),可以直接使用實體磁碟上的 Inode。但後來為了支援網路檔案系統(NFS)以及其他非 Unix 檔案系統,引入了 VFS (Virtual File System) 與 Vnode 抽象介面。Vnode 代表記憶體中的通用檔案物件,封裝跨檔案系統的統一操作介面,並指向底層具體檔案系統的實體 Inode。 Inode 是實際存在磁碟上的,由 OS 從磁碟讀取到記憶體中。

Linux 採用的 Inode (Index Node)繼承並改進了 VFS 的思想,直接將 Vnode 的抽象操作介面整合進記憶體的 Inode 。其包含儲存檔案的 Metadata,例如:檔案大小、權限、修改時間、存取控制等等。此外,Linux 將路徑結構從 Inode 拆分出來,引入了 Dentry(Directory Entry),而 Dentry 結構再指向 Inode。

傳統 Vnode 機制在解析路徑時,需透過檔案系統遞迴執行 VOP_LOOKUP;而 Linux 透過 Dentry Cache 的 Hash Table,能快速找到對應的 Inode。這也是為什麼 Linux 在記憶體層面可以輕鬆處理硬連結,只要將 Dentry 中的不同路徑指向同一個 Inode 就好。

什麼是 I/O?

I/O(Input/Outputs)指任何與檔案的操作,在 Unix 中, I/O 被分為緩衝式 IO(Buffered/Standard I/Os)與非緩衝式 IO (Unbuffered I/Os)。

  • Buffered I/Os:存取會儲存輸入在中介的緩衝區中,當某些條件滿足才會呼叫 syscall
  • Unbuffered I/O: 每次存取都會呼叫 syscall ,但 Kernel 中還是可以有緩衝區

舉例來說,常見的 Buffered IO 有 fread/fwrite , C Standard Library 中有對這兩者作一個存在 User Space 下的緩衝區,當緩衝區滿了、程式呼叫 fflush 、呼叫 fclose 關閉檔案、正常結束時,才會將緩衝區內容用 Syscall 實際寫入檔案。

而 read/write 等 Syscall 的緩存是存在 Kernel 的 Page Cache 中, write 將資料複製到 Kernel 的 Page Cache 後便立即返回,此時資料頁被標記為 Dirty Page,由背景執行緒(如 flusher/pdflush)非同步寫回磁碟。read 會優先從 Page Cache 搜尋,若有則直接複製到 User Space;沒有才從磁碟讀取並載入 Cache。

測測看以不同緩衝區大小讀取 ~516MB 的檔案,所需的時間:

來源:APUE 3rd Edition Figure 3.6

注意到在 BUFFSIZE >= 32 之後,所需的實際時間(Clock time)都落在 8~9 秒,這就是先前提到的 Page Cache 在發力,這兩種優化大型讀寫操作的機制稱為 Read Ahead 與 Delayed Write。

  • Read Ahead:當 Kernel 注意到有人在依序讀取一個檔案的內容時,會事先讀取更多內容到 Page Cache 中,未來的讀取操作只在記憶體中完成。可以用 posix_fadvise(2) ,主動通知 Kernel 提前將資料從磁碟載入 Page Cache。
  • Delayed Write:當一個程式多次寫入同一個檔案區塊時, Kernel 不會每次都實際存取硬碟,而是先覆蓋掉先前的 Page Cache 之後再一次寫入到磁碟中。可以用 fsync(2) ,強制將 Dirty Page 立即刷回實體磁碟。

操作檔案的 Syscall

int open(const char *pathname, int flags, ... /* mode_t mode */ );

開啟或建立指定的檔案路徑,回傳新的 File Descriptor。

  • flags:控制開啟行為(如 O_RDONLY唯讀、O_WRONLY唯寫、O_CREAT建立、O_APPEND附加、O_CLOEXEC執行時關閉)。
  • mode:當包含 O_CREAT 時必須傳入,指定新建檔案的存取權限(如 0644)。
int openat(int dirfd, const char *pathname, int flags, ... /* mode_t mode */ );

以指定目錄 dirfd 為基準點開啟相對路徑檔案,用於防止 TOCTOU(競態條件)攻擊與多執行緒下的路徑解析競爭。

  • dirfd == AT_FDCWD:行為完全等同於傳統 open()(以當前工作目錄為基準)。
  • 若 pathname 為相對路徑:相對於 dirfd 所指向的目錄進行路徑解析。
  • 若 pathname 為絕對路徑:忽略 dirfd 參數。

較新版的 Unix 系統提供以 /dev/fd/x 開啟 File Descriptor x 。比方說,以 open 打開 /dev/fd/0 等同於 dup(0)。


int close(int fd);

關閉指定的 fd,釋放 Kernel 中對應的資源與減少系統檔案結構參考次數,相關的 File Description 直到所有指向他的 fd 都關閉了才會釋放。


ssize_t read(int fd, void buf[.count], size_t count);

嘗試從 fd 指向的檔案或設備中,讀取最多 count 個位元組到 User Space 的緩衝區 buf 中,讀取完會移動 File Offset。回傳實際讀取到的位元組數;回傳 0 表示讀取到 EOF;回傳 -1 表示失敗並設定 errno。


ssize_t write(int fd, const void buf[.count], size_t count);

將 User Space 緩衝區 buf 中最多 count 個位元組寫入至 fd 對應的檔案或設備中,讀取完會移動 File Offset。回傳實際寫入的 bytes 數;回傳 -1 表示失敗並設定 errno。如果 count 是 0 且有錯誤發生,回傳 -1 並設定 errno ,否則無事發生。


off_t lseek(int fd, off_t offset, int whence);

設定fd 的開啟檔案偏移量。

  • SEEK_SET:將 Offset 設定為檔案開頭加上 offset 位元組。
  • SEEK_CUR:將 Offset 設定為當前位置加上 offset 位元組。
  • SEEK_END:將 Offset 設定為檔案結尾加上 offset 位元組。

而 lseek 也可以用來在檔案中創建 File Hole,參考以下程式碼:

char buf1[] = "part1";
char buf2[] = "part2";
int fd = open("file.hole", O_WRONLY | O_CREAT);
write(fd, buf1, sizeof(buf1));
lseek(fd, 16384, SEEK_SET);
write(fd, bu2, sizeof(buf2));

然後將 file.hole 的內容寫入到另一個檔案 file.nohole 中:

$ cat file.hole > file.nohole

來看看兩者所使用的磁碟大小:

$ ls -ls file.hole file.nohole
 8 -r-xrwx--- 1 admin admin 16390 Oct  2 19:43 file.hole
20 -rw-rw-r-- 1 admin admin 16390 Oct  2 19:43 file.nohole

第一個數字代表使用的磁碟區塊數量,可以看到 file.hole 使用的區塊僅有 8 個。這是因為當寫入的地方遠大於檔案末尾時,作業系統會在 Inode 中注記這裡有個空洞,而不是直接寫入那麼多個 bytes。

譯自:linux - what is file hole and how can it be used?


int dup(int oldfd);

複製 oldfd,並自動使用當前編號最小且未使用的 FD 作為新的檔案描述符。

int dup2(int oldfd, int newfd);

將 oldfd 複製到指定編號的 newfd 上(常用於 Shell 的 Redirection,如重導向 stdout 到檔案)。若 newfd 已經被開啟,dup2 會先自動且原子性地將 newfd 關閉再進行複製;若 oldfd == newfd 則直接回傳 newfd。


int fcntl(int fd, int op, ...);

對已開啟的 fd 進行低階屬性控制與操作,有許多種 op 可選,後面再加上該 op 指定的參數,以下列出其中幾種 op。

  • F_GETFL / F_SETFL:取得或修改檔案 Status Flags(如切換是否為 O_NONBLOCK 非阻塞模式)。
  • F_DUPFD / F_DUPFD_CLOEXEC:複製 FD 並設定指定下限。
  • F_SETLK / F_SETLKW:對檔案區域進行加鎖。

int ioctl(int fd, int op, ...);

設備驅動程式的萬用控制介面,用於處理無法映射到一般 read/write 的硬體設備與特殊檔案操作。通常來說, op 會根據寫入的驅動程式有所不同,且驅動可以自定義 op。
例如:查詢/設定網路卡介面參數(如 MAC/IP)、控制 Serial Port 傳送速率、取得終端機視窗大小(TIOCGWINSZ)、控制驅動特有硬體行為。

Atomic Operations & File/Record Locking

考慮有兩個進程 A、B 同時執行這份程式碼,將 buf 寫入檔案末尾:

if (lseek(fd, 0, SEEK_END) < 0)
    err_sys("lseek error");
if (write(fd, buf, 100) < 0)
    err_sys("write error");

假設 A 寫入:

There are no race conditions here.\n

假設 B 寫入:

There are 67

如果 A 先執行 lseek + write ,B 再執行 lseek + write ,或者反過來。那麼一切安好,兩者寫入的內容會先後出現在檔案中:

There are no race conditions here.
There are 67

但我們知道 CPU 會作排程,因此有可能 B 先執行 lseek ,換 A 執行 lseek + write 再換 B 執行 write:

There are 67 race conditions here.

而 A 寫入的東西就被 B 覆蓋掉,這種情況叫做 Race Condition。

如果有多個使用者同時執行一個寫入紀錄檔到同一個檔案的程式,這些紀錄就有可能互相覆蓋。為了解決這個問題, Unix 引入了兩種操作: Atomic Operation 與 File/Record Locking 。

Atomic Operations

ssize_t pread(int fd, void buf[.count], size_t count, off_t offset);
ssize_t pwrite(int fd, const void buf[.count], size_t count, off_t offset);

從指定的 offset 位置讀取/寫入,不會改變檔案原本的 File Offset。

  • 好處:將 lseek() 與 read() 合併為一個操作。
  • 常用場景:多執行緒或父子進程共用同一個 fd 進行並行讀取時,避免因為 lseek 與 read 之間的 Race Condition 導致資料讀取錯亂。

File/Record Locking

Advisory Lock (僅供參考的鎖)是一種依賴進程之間合作的檔案鎖,所有要存取該資料的進程都要先檢查該檔案是否有鎖、等待鎖釋放再進行存取。之所以說僅供參考,是因為若當一個外來進程不檢查檔案是否有所就直接存取,系統是無法擋下該次操作的。

Mandatory Lock 會讓 Kernel 去檢查該次存取有沒有違反鎖的條件,若違反則強制擋下該操作。現在 Linux 已經捨棄 Mandatory Lock ,因為 Kernel 必須在每次 read() 與 write() 呼叫時都檢查檔案鎖狀態,極度消耗系統資源。

  • File Locking:確保只有特定程式能夠存取整個檔案的機制
  • Record locking (or byte range locking): 確保只有特定程式能夠存取部份檔案區塊的機制

鎖還有共用(Shared)與排他(Exclusive)的區別,這種區別的核心思想是:寫入時其他人不能讀取或寫入;讀取時其他人也能讀取,但不能寫入。如果可以同時寫入,就會出現上面的 There are 67 race conditions ;同時讀取與寫入,就不保證讀取那方接收到的是正確的資料,唯一能接受的就是同時讀取,因為資料不會變。
共用鎖,就是保證大家可以同時讀取到對的資料;排他鎖,就是保證同時只有一個人能寫入與讀取。因此共用鎖可以多人擁有,而排他鎖與共用鎖不能共存。

以下介紹給檔案上鎖的 Functions:

int flock(int fd, int op);

對 fd 所指的檔案整個加上鎖,成功回傳 0 ,不成功回傳 -1 並設定 errno。op 可以是以下多個值:

  • LOCK_SH:請求共用鎖
  • LOCK_EX:請求排他鎖
  • LOCK_UN:移除自己擁有的鎖
  • LOCK_NB:當有其他進程持有排他鎖時,不等待該進程釋放鎖就返回,須搭配其他參數使用

int lockf(int fd, int op, off_t size);

由 Standard C Library 提供,包裝 fcntl 的區域加鎖功能,對當前檔案 offset 後 size bytes 加鎖。op 可以是以下多個值:

  • F_LOCK:請求排他鎖,若已被其他進程鎖定,會持續等待直到鎖釋放
  • F_TLOCK:測試並加鎖,若已被鎖定則不等待,立即回傳失敗(errno 設為 EACCES 或 EAGAIN)
  • F_ULOCK:解鎖指定範圍,也可以用於將已鎖定的區域解鎖一部份
  • F_TEST:測試鎖狀態,僅檢查指定區域是否被其他人鎖定,不會真的加鎖,若已被鎖定回傳 -1,未被鎖定回傳 0

使用 fcntl(2) 實現 lockf 操作:

struct flock {
    short l_type; /* F_RDLCK, F_WRLCK, F_UNLCK */
    short l_whence; /* SEEK_SET, SEEK_CUR, or SEEK_END, same as the whence in lseek*/
    off_t l_start; /* offset in bytes relative to whence */
    off_t l_len; /* length, in bytes, 0 means lock to EOF */
    pid_t l_pid; /* filled in by F_GETLK, ignore otherwise */
}
#include <fcntl.h>
struct flock lock;
lock.l_type = F_RDLCK; /* F_RDLCK, F_WRLCK, F_UNLCK */
lock.l_start = 0; /* byte offset, relative to l_whence */
lock.l_whence = SEEK_SET; /* SEEK_SET, SEEK_CUR, SEEK_END */
lock.l_len = 6767; /* #bytes (0 means to EOF) */
int ret = fcntl(fd, F_SETLK, &lock);  /* F_SETLK: set the lock */
  • F_RDLCK : 請求 Read Lock ,也就是共用鎖。
  • F_WRLCK : 請求 Write Lock 排他鎖,也就是排他鎖。

fcntl 還支持 F_GETLK 參數,用來測試目前給定的鎖能否被放置,如果可以,便把 lock.l_type 改成 F_UNLCK ;不行的話就把 lock 的各參數改為目前放在檔案上的鎖的細節。此外,F_SETLKW 參數會等待其他進程釋放鎖再設置請求的鎖。

Source Code Trace

在 Linux 中, File Locks 被存在 inode 中:
/inclue/linux/fs.h

struct inode {
    ...
    struct file_lock_context	*i_flctx;
    ...
};
struct file_lock_context {
    spinlock_t          flc_lock;   /* Protects the fields/lists below */
    struct list_head    flc_flock;  /* Head of the list for flock(2) locks */
    struct list_head    flc_posix;  /* Head of the list for fcntl(2) / POSIX locks */
    struct list_head    flc_lease;  /* Head of the list for file leases */
};

程式實作

這個程式讀取使用者輸入並寫入到檔案末尾,同時會給檔案加上 Exclusive Lock 防止其他程式同時寫入。

char buf[100];

int main() {
    int fd;
    if ((fd = open("log", O_WRONLY | O_CREAT, 0644)) < 0)
        err_sys("open error");

    printf("waiting for exclusive lock... ");
    fflush(stdout);
    if (flock(fd, LOCK_EX) < 0)  // wait for exclusive lock
        err_sys("flock error");
    printf("lock acquired.\n");

    printf("content to write: ");
    scanf("%s", buf);

    if (lseek(fd, 0, SEEK_END) < 0)
        err_sys("lseek error");
    if (write(fd, buf, sizeof(buf)) < 0)
        err_sys("write error");

    if (flock(fd, LOCK_UN) < 0) // release lock
        err_sys("flock error");
    printf("lock released.\n");

    if (close(fd) < 0)
        err_sys("close error");
}

如果同時執行兩個程式:

$ ./write_log
waiting for exclusive lock... lock acquired.
content to write: 
$ ./write_log
waiting for exclusive lock...

第二個 Process 會卡在等待 Exclusive Lock 的過程直到第一個 Process 完成寫入。

$ ./write_log
waiting for exclusive lock... lock acquired.
content to write: There are no race conditions here.
lock released.
$ ./write_log
waiting for exclusive lock...
content to write: 

Blocking vs. Nonblocking I/O

Slow System Call 指會需要不確定時間來完成的 Syscall ,比方說 read 從終端機讀取使用者輸入就要等待使用者按下 Enter。而 Fast System Call 指一定會在確定的時間內完成的 Syscall ,例如 lseek 。

而 Blocking IO 就像 Slow System Call,直到所有操作都做完了才會回傳結果,讓呼叫該 Syscall 的程式卡著。

反之, Nonblocking IO 讓我們發起一個操作請求,然後直接回傳請求能否執行的結果(Ex: EAGAIN)。舉例來說,Nonblocking read 可以盡量讀取資料而不會卡住整個進程,但同時讀取也可能是什麼都沒有,此時 errno 會說明操作無法完成。


來源:Go 语言网络轮询器的实现原理

要讓某個 FD 開啟 Nonblocking 模式,可以在 open 時加上 O_NONBLOCK 標誌,或者後面使用 fcntl 加上 O_NONBLOCK:

open(filename, O_WRONLY | O_NONBLOCK, ...);
// or
fcntl(fd, F_SETFL, O_NONBLOCK);

程式實作:

char buf[500000];

void set_fl(int fd, int flags) {
    int val;
    val = fcntl(fd, F_GETFL, 0);
    val |= flags;
    fcntl(fd, F_SETFL, val);
}

int main() {
    int bytes_read = read(STDIN_FILENO, buf, sizeof(buf));
    fprintf(stderr, "read %d bytes from stdin\n", bytes_read);

    char* ptr = buf;
    set_fl(STDOUT_FILENO, O_NONBLOCK);

    int bytes_left = bytes_read;
    int eagain = 0;
    while (bytes_left > 0) { // 一直等待寫入的過程就叫 polling
        errno = 0;
        int bytes_written = write(STDOUT_FILENO, ptr, bytes_left);
        if (errno == EAGAIN) {
            eagain++;
        } else {
            if (eagain > 0) {
                fprintf(stderr, "EAGAIN * %d...\n", eagain);
                eagain = 0;
            }
            fprintf(stderr, "write %d bytes, errno = %d\n", bytes_written, errno);
        }
        if (bytes_written > 0) {
            ptr += bytes_written;
            bytes_left -= bytes_written;
        }
    }
    exit(0);
}

執行:

$ ./a.out < /var/log/syslog 2>stderr.out

stderr 輸出:

read 500000 bytes from stdin
write 20204 bytes, errno = 0
EAGAIN * 13...
write 6027 bytes, errno = 0
EAGAIN * 1...
write 93152 bytes, errno = 0
EAGAIN * 23...
write 3868 bytes, errno = 0
EAGAIN * 19759...
write 4357 bytes, errno = 0
EAGAIN * 22...
write 4025 bytes, errno = 0
EAGAIN * 8...
write 4417 bytes, errno = 0
write 97453 bytes, errno = 0
EAGAIN * 14178...
write 3500 bytes, errno = 0
write 87348 bytes, errno = 0
EAGAIN * 14785...
write 3436 bytes, errno = 0
write 104257 bytes, errno = 0
EAGAIN * 12351...
write 4750 bytes, errno = 0
EAGAIN * 7...
write 5021 bytes, errno = 0
EAGAIN * 14...
write 3193 bytes, errno = 0
EAGAIN * 258...
write 54992 bytes, errno = 0

I/O Multiplexing

你正在開發一個線上聊天程式,當使用者發送訊息的時候,會將內容廣播給所有人,而其他人發送的訊息也要同步給使用者看到。因此,這個程式會需要從 stdin 讀取使用者的輸入內容,同時從網路 socket 讀取伺服器傳給使用者的其他聊天訊息。

你自然而然的寫:

read(STDIN_FILENO, buf, sizeof(buf));
...
read(socket_fd, netbuf, sizeof(netbuf));

但 Blocking IO 沒辦法同時讀取兩個輸入,也就是必須等到使用者輸入完內容,才會將網路內容讀入 netbuf。所以你順勢改為使用 Nonblocking IO:

set_fl(STDIN_FILENO, O_NONBLOCK);
set_fl(socket_fd, O_NONBLOCK);
...
while (1) {
	read(STDIN_FILENO, buf, sizeof(buf));
	...
	read(socket_fd, netbuf, sizeof(netbuf));
}

確實可以同時讀取了,但你看著高漲的 CPU 使用量,陷入了沉思。在一番思索後,你選擇將讀取 stdin 與 socket 的程式分為兩個線程,不過,你並沒有處理多線程溝通的經驗,各種 Race Condition 在你眼前炸開,這個方案似乎不夠優雅。

針對這個情境,在單一進程中高雅解決這個問題的正解是使用 IO Multiplexing(IO 多路復用)。其核心思想是讓 Kernel 幫你監視多個 File Descriptor,所以他的好處有系統開銷小、不必維護多線程/進程。以上述例子,當 STDIN_FILENO 或 socket_fd 有資料可讀時,Kernel 才會喚醒你的程式,否則程式會進入睡眠(Suspended)。

IO Multiplexing 常見的使用場景包含:

  • 需要同時處理多個 File Descriptor
  • 等待任一個 File Descriptor 可以讀寫會導致阻塞

而使用 IO Multiplexing 的 Syscall 有 select、poll:

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

監聽多個 FD 的事件狀態(可讀、可寫、異常),當有 FD 準備就緒或逾時,傳入的集合中就緒的 FD 會被設為 1 並返回, FD 的最大可設置值為 FD_SETSIZE-1 。成功即回傳就緒 FD 的總數,逾時回傳 0,失敗回傳 -1 並設定 errno。

  • nfds:要檢查的 FD 數量上限,必須設為所有集合中最大的 FD 值 + 1
  • readfds:請求監聽可讀事件的 FD 集合;當核心發現有資料可讀時會保留相應 FD
  • writefds:請求監聽可寫事件的 FD 集合;當核心發現可寫入(緩衝區有空間)時會保留相應 FD
  • exceptfds:請求監聽異常事件的 FD 集合
  • timeout:等待的最大時間長度;若傳入 NULL 則無限期阻塞,直到有事件發生為止

此外,fd_set 可以用以下四個宏操作:

fd_set set;
FD_ZERO(&set);            // 清空集合
FD_SET(fd, &set);         // 將 fd 加入集合之中
if (FD_ISSET(fd, &set));  // 檢查集合中是否有 fd
FD_CLR(fd, &set);         // 將 fd 從集合中移除

簡單實作:

int main() {
	fd_set read_set, ret_set;
	FD_ZERO(&read_set);
	FD_ZERO(&ret_set);
	
	FD_SET(0, &read_set);
	int nfds = 0 + 1; // 最大 fd + 1
	
	while (true) {
		memcpy(&ret_set, &read_set, sizeof(fd_set)); // 讓 read_set 不要被覆蓋
		select(nfds, &ret_set, NULL, NULL, NULL); // 無限等待
		for (int i = 0; i < nfds; i++) {
			if (!FD_ISSET(i, &read_set) || !FD_ISSET(i, &ret_set)) continue;
			char buf[100];
			read(fd, buf, sizeof(buf)); // 對 fd 做點啥
		}
	}
}

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

監聽結構陣列中指定的多個 FD 事件狀態,改善 select 能夠指定的 FD 數量上限的問題。當有事件觸發或逾時會返回,成功回傳就緒 FD 的總數,逾時回傳 0,失敗回傳 -1 並設定 errno。

  • fds:指向 struct pollfd 陣列的指標,每個結構包含以下欄位:
    • fd:要監聽的檔案描述符
    • events:請求監聽的事件遮罩(如 POLLIN 代表可讀、POLLOUT 代表可寫)
    • revents:核心實際回傳的事件遮罩(有事件觸發時由核心寫入,如 POLLIN、POLLHUP 等)
  • nfds:fds 陣列中的元素個數
  • timeout:等待時間的毫秒數;傳入 -1 表示無限期阻塞直到事件發生,傳入 0 表示立即返回

簡單實作:

int main() {
	int nfds = 1;
    struct pollfd fds[nfds];
    fds[0].fd = STDIN_FILENO;
    fds[0].events = POLLIN;     // 請求監聽可讀事件
    fds[0].revents = 0;         // 實際發生的事件

    while (true) {
        int ret = poll(fds, nfds, -1); // 無限期等待
        if (ret < 0) break;

        for (int i = 0; i < nfds; i++) {
            if (fds[i].revents & POLLIN) {
                char buf[100];
                read(fds[i].fd, buf, sizeof(buf) - 1);
            }
        }
    }
}