什麼是檔案?
在 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 往上加。其他兩者都是整個系統共用的,不會有其他副本。
複製/繼承FD
dup(2) 是複製一個已有的 FD 到目前最小可用的 FD 數字去。當使用 dup(2),原本的 FD 與新的 FD 便都指向 Open File Table 中的同一個 File Description,兩者的檔案操作也會互相同步。fork(2) 作用是創造一個目前進程的子進程。子進程會繼承父進程的 FD ,所以兩者會有指向同一個 File Description,但是不同的 FD。下圖的 Process A 與 Process B 就有可能是父子進程。
下圖展示了上面三個數據結構的關係:
來源: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];
...
};
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。
結構儲存位置
進程的虛擬記憶體,被分為兩大塊:Kernel Space 下共用的記憶體、User Space 下的記憶體。而 User Space 下只會知道 files_struct.fdt.fd 中的陣列索引值,即先前提到的 FD;在 Kernel Space 下則存著 task_struct 、 files_struct 、 fdtable 、 file 等結構,防止有人惡意篡改導致內核大爆炸。也就是說,這些結構是每個進程都會有一個,但是存在 Kernel Space 中,且由 Kernel 維護。
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。
Page 分頁
分頁(Page)是現代作業系統與 CPU 用來管理記憶體的基本單位,通常大小為 4KB。可以把分頁想成一塊固定大小的虛擬記憶體,之所以說是虛擬記憶體,是因為分頁是由名為 MMU(Memory Management Unit) 的硬體透過頁表(Page Table)轉換到實體記憶體的地址。這樣做可以使得虛擬記憶體中連續的分頁,在實體記憶體中不需要連續,解決了記憶體碎片化的問題,且分配記憶體更有效率。
記憶體碎片化是指系統記憶體被分割成許多不連續的小塊,導致總空閒空間足夠,卻無法滿足大型記憶體配置需求的現象。
測測看以不同緩衝區大小讀取 ~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 立即刷回實體磁碟。
測時間
可以使用 shell 指令 time 來測試執行一個程式所需的時間
$ time ls
...
real 0m0.013s
user 0m0.004s
sys 0m0.009s
real 是實際花費的總時間,user 是程式在 User Space 下花的時間, sys 是程式在 Kernel Space 下花的時間。
操作檔案的 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 都關閉了才會釋放。
Warning
當該檔案的參考計數歸零時,Kernel 才會真正釋放實體資源;成功關閉並不保證 Page Cache 中的資料已被寫到磁碟上(要確保寫入搭配 fsync(2))。
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。
File With Holes
這種有洞的檔案也叫做 Sparse File(稀疏檔案),當程式需要存取大範圍的地址時,卻又不太可能用到所有潛在的磁碟區塊,稀疏檔案就非常有用。這項技術常用於虛擬機中用來儲存虛擬磁碟。假設一台虛擬機配置了 20 GB 的磁碟,但這塊區域不會立刻被資料填滿。與其直接建立實體占用 20 GB 的檔案,不如建立一個 20 GB 的稀疏檔案,它一開始只會佔用幾個磁碟區塊,之後再由虛擬機以較慢的速度並陸續寫入檔案,這樣做比較有效率。
稀疏檔案也可以在其部分區塊被清空(例如以 '\0' 填滿)時縮減實際佔用的容量。支援稀疏檔案的程式在執行此操作時,可以不必真的將資料寫入這些區塊,而是直接將它們從檔案中移除(即在檔案中「打洞」)。這樣做的效果完全相同,因為程式在讀取未分配的區塊時,系統一律會回傳零。
稀疏檔案與「預先分配(Preallocation)」的概念相反,它們實現了所謂的精簡自動配置(Thin provisioning),有時也被稱為磁碟超額承諾(Disk overcommitment)。這讓你能建立出超越實際實體硬體容量的「虛擬磁碟空間」,並只在有必要時才擴充實體磁碟來增長檔案系統。
譯自: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。
Race Condition
A race condition occurs when multiple processes are trying to do something with shared data and the final outcome depends on the order in which the processes run. (From Chap. 8.9 of the APUE text book)
如果有多個使用者同時執行一個寫入紀錄檔到同一個檔案的程式,這些紀錄就有可能互相覆蓋。為了解決這個問題, 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 參數會等待其他進程釋放鎖再設置請求的鎖。
fctnl 與 lockf 等 POSIX 檔案鎖的注意事項
如果有進程關閉它擁有的任何一個指向該檔案的 File Descriptor ,那麼系統會釋放該進程在這個檔案上擁有的所有鎖,不論這個鎖是否與關閉的 File Descriptor 有關聯。
考慮這段程式碼:
fd1 = open(filename, ...);
lockf(fd1, ...);
fd2 = open(filename, ...);
close(fd2);
在 close(fd2) 時,從 fd1 獲得的鎖便會被移除。但是由 flock 所加的鎖不會被移除,因為 flock 的鎖的擁有者是綁定在 File Description 上,而 fcntl 所加的鎖綁在 File Descriptor Table 上。簡單來說,fcntl/lockf的鎖只要有一個 fd 被關掉了,這個檔案上的全部鎖都會釋放。而 flock 的鎖會等到所有 fd 被關掉了,鎖才會不預期消失。
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 會說明操作無法完成。
要讓某個 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 值 + 1readfds:請求監聽可讀事件的 FD 集合;當核心發現有資料可讀時會保留相應 FDwritefds:請求監聽可寫事件的 FD 集合;當核心發現可寫入(緩衝區有空間)時會保留相應 FDexceptfds:請求監聽異常事件的 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);
}
}
}
}
