兩層,不是一層
在第二篇裡,你把位元組直接寫給核心:`write(fd, buf, n)` 就是一次系統呼叫、一次跨越使用者/核心界線,每一次都是如此。那就是原始 I/O——直接、可預測,而且正如上一篇所警告的那樣昂貴,因為每一次呼叫都要付出進入核心再返回的整份成本。然而大多數程式並不是每輸出一行就呼叫一次 `write()`。它們呼叫的是友善的函式,像 `printf()`、`fputs()`、`fgetc()`、`fread()`。這些屬於第二層,疊在原始呼叫「之上」,而認清這一層的存在,正是本篇的全部重點。
那第二層就是 stdio,C 函式庫裡負責標準 I/O 的部分,它的工作是維持一條串流——一個包在原始檔案描述符外面的緩衝包裝。當你 `printf("hello\n")` 時,位元組並不會一個一個急著奔向核心。它們被複製進 libc 內部一塊叫做緩衝區的記憶體,就待在那裡。只有當緩衝區滿了,或某條規則被觸發,libc 才會做一次大的 `write()`,把整個緩衝區一口氣刷過界線。所以這幅圖是一條鏈:你的程式、接著使用者記憶體裡的 stdio 緩衝區、接著單一次原始系統呼叫、接著核心。無緩衝與有緩衝 I/O 的差別,不過就是中間那個盒子在不在。
RAW I/O BUFFERED stdio
------- --------------
putc-style, but each one printf("hi\n"); -> buffer in libc
is a real syscall: "hi\n" (still in user memory)
write(1,"h",1) -> kernel printf("there\n"); -> buffer grows
write(1,"i",1) -> kernel "hi\nthere\n" (still not sent)
write(1,"\n",1) -> kernel ...buffer fills OR program flushes...
write(1,"hi\nthere\n...",N) -> kernel
3 crossings, 3 bytes 1 crossing, N bytes緩衝區為何值得它佔的位置
為什麼要費事弄這個中間盒子?因為跨進核心是真真切切、可量測地昂貴——回想一下,系統呼叫是你能發出最貴的那種「函式呼叫」。想像把一個 10 KiB 的日誌檔一次一個字元印出來。用原始方式做,那就是一萬多次各自獨立的 `write()` 系統呼叫,一萬多次跨越界線。透過 stdio 做,那一萬個字元安靜地堆進一個緩衝區(常見是幾 KiB),libc 只需發出寥寥幾次 `write()` 就把它排空。一樣多的位元組抵達核心,但跨越的次數少了千倍。緩衝區用一點點使用者模式的記憶體與複製,換來系統呼叫次數的巨幅削減。
同一招在輸入上反過來也成立。當你呼叫 `fgetc()` 讀取一個字元時,stdio 並不會做一次一位元組的 `read()` 系統呼叫。它會做一次較大的 `read()`——比方說從核心拉 4 KiB 進它的輸入緩衝區——然後從那個緩衝區裡一次一個地把字元交給你,完全沒有跨進核心,直到緩衝區清空,它才安靜地再填滿。所以一個透過 stdio 一個字元一個字元讀檔案的迴圈,並不像看起來那麼慢:幾乎每一次 `fgetc()` 都只是一次便宜的記憶體取值,每幾千次才真的碰一次核心。這就是緩衝區在門的兩側都回報你。
三種緩衝模式,以及規則怎麼選定
如果緩衝區只在滿了才刷出,互動式提示會讓人抓狂——你打了一個問題,程式那句「請問你的名字是?」卻無形地躺在緩衝區裡,要到很久以後才抵達螢幕。所以 stdio 有三種緩衝模式,並根據串流連到的對象自動挑一種。全緩衝:只在緩衝區填滿時才刷出(用於一般檔案——重要的是速度,不是即時性)。行緩衝:每寫入一個換行 `\n` 也刷出一次(用於串流是終端機時——你希望每一行一完成就出現)。無緩衝:立刻刷出,每次寫入都直接送出去(這是標準錯誤的預設,所以當程式快要死掉時,錯誤訊息絕不會被困在緩衝區裡)。
接下來是個微妙而重要的後果。模式通常是靠問「這條串流是終端機嗎?」來選的。這意味著「同一支程式」會因為輸出去向不同而行為不同。在終端機裡跑 `./a.out`,標準輸出是行緩衝的,所以每一行 `printf` 都迅速出現。改跑 `./a.out > file.txt`,或把它接進另一支程式——這下標準輸出連到的是檔案或管線、不是終端機——於是它變成全緩衝,你那些行可能要到緩衝區填滿或程式結束時,才出現在 `file.txt` 裡。沒有任何東西壞掉;只是規則在你腳下悄悄變了。光是這一個事實,就能解釋出奇大量「我的輸出不見了」之謎。
刷出,以及「已寫入」這個陷阱
因為緩衝意味著「已寫入」不再等於「已送進核心」,stdio 給了你一個強行了結此事的辦法:`fflush(stream)`。它要 stdio 把那條串流緩衝區裡此刻所有的東西,立刻透過一次 `write()` 推出去,不管是哪種模式。在提示之後呼叫 `fflush(stdout)`,正是前面那個互動情境誠實的修法——它保證「請問你的名字是?」在你呼叫 `fgets()` 去讀答案之前就抵達螢幕。在行緩衝的終端機輸出裡你通常不需要它(換行已經刷過了),但一旦你的輸出被導向檔案或管線,一次明確的刷出,往往就是看見你的進度與盯著一個空檔案之間的差別。
現在來一句你絕不能含糊帶過的老實話,因為它是本篇最深的一點:即使一次成功的 `fflush()`,也不代表你的位元組已安穩地落在磁碟上。`fflush()` 把資料從「libc 緩衝區」搬到「核心」——它跨過一層。但核心有它自己的緩衝(page cache),你的資料可能還躺在那裡、尚未寫進實體磁碟機,再待上一會兒。要把位元組一路送到耐久儲存,是另一個更強的保證,需要另一個系統呼叫——對描述符呼叫 `fsync()`。刷出與耐久性的完整故事是它自己的題目;現在要帶走的規則是:你的 `printf` 與旋轉的碟片之間有「兩個」緩衝區,而 `fflush()` 只清空第一個。
你該伸手拿哪一層?
那麼你該用哪個,原始還是緩衝?一個好的預設:一般文字就伸手拿緩衝 stdio——讀設定檔、印人類可讀的輸出、處理一行行的內容——因為它方便、可攜,而且對於緩衝本就為之而生的那種頻繁的小量讀寫又快。當你需要精確控制時,就降到原始的 `read()`/`write()`:你本來就自己管理緩衝區的大區塊傳輸、訊息時機要緊的網路通訊端或管線、隱藏的緩衝會成為負擔的低延遲或即時程式碼,或任何你真的需要一個位元組「現在」就跨過核心界線的時刻。沒有哪一層「比較好」;它們是面對緩衝對無緩衝這個取捨的不同工具。
在這一切之下有一塊穩固的地面:你以描述符 0、1、2 認識的那些標準串流,正是 stdio 包裝成 `stdin`、`stdout`、`stderr` 的同一批串流。`printf()` 不過是 `fprintf(stdout, ...)`,而 `stdout` 是一個緩衝的 `FILE *`,它底層的描述符就是 1。這就是為什麼一切都接得起來:第二篇的原始層與這裡的緩衝層並非兩個分開的世界,而是同一道梯子上的兩個高度;而接下來的篇章——談描述符底下的檔案系統、談重導與管線——爬的都是同一道梯子。有意識地選你的層、記得「已寫入」可能意味著「已緩衝」,stdio 就會好好為你效力。