Block I/O Fundamentals
Block Device Abstraction
每次你按下 Ctrl+S 存檔、打開一個 app、或是載入遊戲的關卡,背後都有一連串的 I/O 操作在你看不到的地方發生。你的程式發出讀寫請求,作業系統把它轉成 block device 的操作,再交給底層硬體執行。同樣一個遊戲,在一台電腦上 2 秒就載入完成,在另一台卻要 30 秒,差異不只是「硬體比較快」這麼簡單,而是整條 I/O 路徑的效率問題。要理解這些差異,我們得先從最底層的抽象開始。
作業系統把所有儲存裝置(不管是 HDD、SSD、還是 NVMe)都抽象成一個「由固定大小 block 排成的陣列」。每個 block 通常是 512 bytes 或 4KB。你不需要去想「我要把資料寫到 SSD 裡面第 3 顆 NAND chip 的第 17 個 block 的第 42 個 page」,你只需要指定 block 編號,告訴 OS 你要讀或寫,剩下的事情由 device driver 和裝置本身去處理。
這個 abstraction layer 是所有儲存系統的共同基礎。不管你後面學的是 file system、database、還是 key-value store,它們最底層都是在對 block device 發出 read 和 write 請求。搞清楚這層抽象的性質和限制,後面的東西會順很多。
IOPS 與 Bandwidth
衡量儲存效能有兩個關鍵指標,很多人會搞混,所以這裡要講清楚。
IOPS(I/O Operations Per Second)衡量的是「每秒能完成幾次 I/O 操作」。Bandwidth(也叫 throughput)衡量的是「每秒能傳多少資料量」,單位通常是 MB/s 或 GB/s。
回到前面的例子:遊戲載入關卡的時候,需要讀取大量小檔案(texture、model、shader),每個幾 KB 到幾十 KB,散落在 SSD 的各個位置,這是 random small I/O,瓶頸在 IOPS。但你把一個 10GB 的影片從一個資料夾複製到另一個資料夾,這是 sequential large I/O,瓶頸在 bandwidth。同一顆 SSD,兩種 workload 卡在完全不同的地方。
假設你有一顆 NVMe SSD,random 4KB read 能做到 500K IOPS,sequential read bandwidth 是 3.5 GB/s。一個資料庫在做大量 random point lookup 的時候,每次只讀 4KB,bottleneck 在 IOPS, bandwidth 可能只用到 500K x 4KB = 2 GB/s,離 3.5 GB/s 還很遠,但 IOPS 已經滿了。反過來,一個影片串流應用在做 sequential read,每次讀 256KB 的大 block,這時候 IOPS 需求不高(3.5 GB/s / 256KB = 大約 14K IOPS),但 bandwidth 已經到頂了。
所以當你在評估儲存系統效能的時候,第一個問題永遠是:「你的 workload 是 random small I/O 還是 sequential large I/O?」答案不同,bottleneck 就不同,優化方向也完全不同。
Synchronous I/O 與 Asynchronous I/O
Also see OS 2026: I/O
當你的程式呼叫 read() system call 的時候,有兩種可能的行為模式。
Synchronous I/O:呼叫 read() 之後,你的 thread 就被 block 住了,一直等到資料從裝置搬到記憶體、kernel 把結果複製到你的 user space buffer 之後才會返回。在等待的這段時間,這個 thread 什麼事都不能做。如果你要同時發 100 個 I/O 請求,你就需要 100 個 thread,每個 thread block 在自己的 read() 上。
Asynchronous I/O:你提交一個 I/O 請求之後,呼叫立刻返回。你的 thread 可以繼續做其他事情,甚至繼續提交更多 I/O 請求。等 I/O 完成之後,你再透過某種機制(例如 polling、callback、或檢查 completion queue)拿到結果。
想像一個 web server 同時服務一千個使用者,每個使用者的請求都需要讀取一小段資料。如果用 synchronous I/O,你就需要一千個 thread 各自 block 在 read() 上面。在 HDD 時代,一次 random I/O 要花大約 10ms,synchronous I/O 的問題不算太嚴重,反正 CPU 等一下就好。但在 NVMe SSD 時代,一次 random read 只要大約 100 microseconds,而一次 context switch 的成本大約是 1-5 microseconds。如果你用 synchronous I/O 加上大量 thread 來達到高 IOPS,光是 thread scheduling 和 context switch 的開銷就會吃掉很大比例的 CPU。這就是為什麼現代高效能 I/O 框架(像 io_uring)都走 asynchronous 的路線。
Interrupt 與 Polling
I/O 完成之後,裝置要通知 CPU「你要的資料好了」。這件事有兩種做法。
Interrupt(中斷):裝置完成 I/O 之後,主動送一個 hardware interrupt 給 CPU。CPU 收到 interrupt 之後暫停手上的工作,跳去執行 interrupt handler 來處理完成的 I/O。這是裝置主動通知 CPU 的方式。
好處是 CPU 在等待期間可以做別的事情,不浪費運算資源。壞處是每次 interrupt 都有 overhead:CPU 要儲存目前的 register 狀態、跳到 handler、處理完再跳回來,整個流程大約需要幾個 microseconds。如果 I/O 很少,這個 overhead 不算什麼。但如果你每秒有幾十萬次 I/O completion(在高效能 NVMe 場景下很正常),那每秒就要處理幾十萬次 interrupt,CPU 光是在處理 interrupt 的 overhead 上就會花掉大量時間。這個問題叫做 interrupt storm。
Polling(輪詢):CPU 不等裝置通知,而是自己不斷去檢查「I/O 好了沒?好了沒?賀啊沒啦?賀啊沒啦?」。具體來說就是反覆讀取裝置的某個 status register 或 completion queue,直到發現有新的完成結果。
Polling 看起來很浪費 CPU,因為在等待期間 CPU 什麼有用的事都沒做,一直在空轉。但 polling 的好處是 latency 比較低:I/O 一完成,下一次 poll 就能發現,不需要等 interrupt delivery 和 handler 的 overhead。當 I/O latency 已經低到微秒等級(NVMe SSD),而且 I/O 密度很高的時候,polling 反而比 interrupt 更有效率,因為你省掉了大量 interrupt handling 的成本。
Also see: When poll is more energy efficient than interrupt
實務上,現代系統通常會混合使用:低負載的時候用 interrupt 節省 CPU,高負載的時候切換到 polling 降低延遲和避免 interrupt storm。Linux 的 NAPI(Network API)就是這樣的設計,NVMe 的 io_uring IOPOLL 模式也是同樣的思路。
自問自答
試著用自己的話回答以下問題。如果卡住了,回去重讀對應的段落。
-
為什麼 block device abstraction 要把儲存裝置抽象成「固定大小的 block 陣列」,而不是讓應用程式直接以 byte 為單位存取?
-
一顆 SSD 標示 random 4KB read IOPS 是 500K,sequential read bandwidth 是 3.5 GB/s。如果你的 workload 是 random 4KB read,你的實際 bandwidth 上限是多少?
-
在 NVMe SSD 環境下,為什麼大量 thread 搭配 synchronous I/O 的做法會變得很沒效率?
-
描述一個 interrupt 比 polling 好的場景,和一個 polling 比 interrupt 好的場景。