為什麼核心做的不只是把位元組轉交出去
讀到這裡,你已能憑這個階段前幾篇描繪出一次輸入輸出請求完整的下降過程:程式發出呼叫、跨進核心、一支裝置驅動程式去戳控制器的暫存器,最後由 DMA 或一個中斷通知位元組已經搬好。這套機械足以讀一個磁區、或送出一次按鍵。但若直接這樣用會很痛苦。磁碟以 4 KB 區塊交付資料,而你的程式只想要一個位元組;印表機一分鐘才收一頁,而你的程式一毫秒就生出一頁;網路以無法預測的爆發把封包丟給你。硬體那一層只搬位元組——它對這些落差什麼也沒做。
於是核心在原始傳輸機制之上疊了一組輸入輸出服務,它們就坐落在你上一篇認識的那個與裝置無關的層裡。四個重頭戲是:緩衝、快取、排存,以及阻塞、非阻塞、非同步輸入輸出之間的抉擇。每一項都是為了解決某種速度或共享的落差而存在,而理解它們最簡單的辦法,就是逐一去問:它究竟修補了哪一種落差。
緩衝:兩種速度之間的一座暫存欄
緩衝區只是一塊記憶體區域,用來暫存在生產者與消費者之間流動、但兩者並非同步前進的資料。把它想成桌上那個收件匣:郵差(裝置)一有信件到就丟進去,而你(程式)一有空就把它們拿走;誰都不必站著等對方。緩衝一次解決三個各自獨立的問題,把它們在心裡分開記會很有幫助。
第一,速度落差:只要緩衝區吸收掉差額,快的生產者與慢的消費者(或反過來)就能各按自己的步調工作,正如你在同步主題下看到的生產者消費者問題裡那個有界緩衝區。第二,傳輸大小落差:磁碟堅持要整個 4 KB 區塊,但你的程式呼叫 read 只要 50 個位元組——核心便把整塊一次讀進緩衝區,之後每次呼叫都從裡面交給你一些位元組,這樣它就不必把磁碟重讀五十次。第三,複製語意:當你寫出資料時,核心會立刻把它複製進一塊核心緩衝區,好讓你的程式馬上就能重用自己的記憶體,即使那些位元組其實還沒真的抵達磁碟。
這裡有個值得誠實點名的代價。上面那個複製語意意味著:當 read(fd, buf, n) 回傳時,你的位元組是在一塊核心緩衝區裡,不一定已在碟片上。這就是為什麼突然斷電可能丟失最近的寫入,也是為什麼資料庫會呼叫 fsync 來強迫把緩衝區刷進穩定儲存。緩衝買來了順暢,卻悄悄塞進一段資料只存在於揮發性記憶體的空窗——這是個真實的取捨,不是免費的午餐。
雙緩衝:永遠不讓裝置閒著
單一緩衝區有個惱人的空檔。當你的程式忙著消費緩衝區的內容時,裝置只能枯坐——它不能去填你正在讀的那個緩衝區,否則會覆蓋掉你還沒消費的資料。於是裝置工作、然後在你工作時等待、再工作:兩者輪流,誰都不曾全力忙碌。雙緩衝用最古老的把戲解決了它——用兩個緩衝區,並交換。
想像兩位侍者與兩個托盤。當你吃著 A 盤時,廚房已經在裝 B 盤。你一吃完 A,侍者就交換:你開始吃滿的 B 盤,廚房則開始重填現在空了的 A 盤。廚房與你都不曾停下來等對方。套用到輸入輸出上:裝置以 DMA 填充緩衝區 B,同時 CPU 處理緩衝區 A;兩者都完成後,便交換角色。如果裝置與 CPU 花的時間差不多,雙緩衝幾乎能讓吞吐量加倍,因為兩者如今是重疊而非交替。
single buffer (serial): device fills B ---- CPU reads B ---- device fills B ---- CPU reads B ---- (device idle while CPU works, CPU idle while device works) double buffer (overlapped): device: fill A | fill B | fill A | fill B | ... CPU: read A | read B | read A | ... (the two now run at the same time, on different buffers)
快取:記住你已經付過代價的東西
緩衝區暫存正前往某處的資料;快取則保存一份你可能會再用到的資料副本,好讓你略過那趟取得它的慢路。這個分別微妙卻真實:同一塊記憶體可以同時扮演兩種角色,但意圖不同。緩衝區為了傳輸而存在;快取為了重用而存在。核心會在 RAM 裡保留一份最近用過之磁碟區塊的緩衝快取,賭的是參考局部性——這正是你在分頁那幾篇看到的工作集與 TLB 背後同一個想法。一個區塊讀過一次,下一次再讀它就由 RAM 在數微秒內回應,而不必由磁碟花上數毫秒。
這就是為什麼同一道命令跑第二次常常感覺瞬間完成,也是為什麼剛開機的機器要等到快取暖起來之前都感覺遲鈍。快取是有限的,所以一旦填滿,核心就必須逐出某些東西——它用的置換策略,是分頁置換演算法的近親(一種帶 LRU 味道的選擇,逐出最久沒被用到的)。不過要對快取的一個微妙之處誠實:快取只有在資料真的被重用時才有用,而寫入終究得抵達磁碟。回寫式快取為了速度延後那次寫入,卻冒著當機時丟失的風險;直寫式快取較安全但較慢。這跟緩衝是同一個取捨,只是戴了不同的帽子。
排存:在一台無法共享的裝置前輪流
有些裝置就是無法把好幾支程式的工作交錯進行。印表機是經典例子:如果兩支程式同時把文字行送給它,你的履歷和別人的購物清單就會在同一頁上交錯混印出來。排存(名稱源自 Simultaneous Peripheral Operation On-Line,即線上同時周邊運作)的解法,是乾脆永遠不讓程式直接和裝置對話。取而代之,每支程式的輸出都被寫進排存目錄裡一個各自獨立的檔案,再由一個專屬的常駐程式,一次餵一整份完整的工作給裝置。
這簡直就是一個帶取餐號的外帶櫃台。每個人點完整份餐(寫出一個完整的排存檔案)就自在離開;唯一的廚師(排存常駐程式)按佇列一次處理一份訂單、從頭到尾做完,所以訂單永不混淆。注意排存真正換來了什麼:它把一台不可共享的裝置,變成許多程式都能使用、卻不必彼此協調的裝置,並且把慢的裝置和快的程式解耦——你的檔案一旦排存完成,你的程式就算完工、可以繼續往下走,即使前頭排了四十份工作。
排存與緩衝是表親而非雙胞胎,初學者常把它們混為一談。緩衝區通常是在一個生產者與一個消費者之間,撫平一條串流。排存則是把來自許多程式的許多份獨立工作,串列化到一台裝置上,並把每一整份工作(常存在磁碟上,而非僅在 RAM 裡)留住,直到輪到它。列印佇列、郵件佇列,以及一批等待磁帶機的工作,全都是排存。它的定義特徵,是把整份工作串列化到一台不能在工作進行中被共享的裝置上。
阻塞、非阻塞與非同步輸入輸出
最後一項服務無關搬資料,而是關於你的程式如何等待它。共有三種風格,把它們搞混是程式出現神秘行為最常見的根源之一。在阻塞式輸入輸出(預設)下,當你呼叫 read 而資料尚未就緒時,核心會把你的行程睡著,把它移出 CPU、放上等待佇列,直到資料抵達再喚醒它。容易推理(呼叫只在完成時才回傳),但你的執行緒在這期間什麼都做不了。這就是那只有禮貌的餐廳呼叫器:你點完餐就坐著、什麼也不做,直到它亮起。
在非阻塞式輸入輸出下,不管怎樣呼叫都立刻回傳:它把此刻已就緒的資料給你,或給你一個「會阻塞、目前還沒有」的答覆,而絕不把你睡著。你的程式保持掌控、能去做別的工作,但現在換你負責回頭查看。若天真地做,這就變成輪詢——在迴圈裡一直問「好了沒?好了沒?」,正是你學過要避免的那種忙碌等待的浪費。成熟的版本會用一個事件通知介面(select、poll、epoll、kqueue):你把一整組檔案描述符交給核心,只阻塞一次,直到其中任何一個就緒,再只去處理那些。單一執行緒就是這樣同時服務數千條網路連線的。
非同步輸入輸出走得更遠。你發出請求,它立刻回傳——但與非阻塞不同的是,傳輸真的在背景進行(由核心與 DMA 執行),而核心會在整件事真正完成時,透過訊號、回呼或一個完成事件,稍後通知你。關鍵對比:非阻塞問的是「可以讓我開始了嗎?」,而傳輸仍由你自己做;非同步說的是「去把整個傳輸做完,做好了告訴我」。它是那種你的餐一裝好就傳簡訊給你的呼叫器——你去做了別的事,而完成的消息自己找上門來。最後一句誠實的提醒,好終結一個常見的誤解:以上這些沒有一項能讓單一一次請求變快——磁碟仍要花同樣的那幾毫秒。改變的是:你的執行緒在那幾毫秒裡是自由的,所以整個程式整體上能多做事。一如這篇裡的一切,贏的是重疊與吞吐量,而非任何單一傳輸的速度,而且這也不是硬體平行——只是一顆 CPU 聰明地拒絕把時間浪費在等待上。
- 阻塞式 read:呼叫 read(fd, buf, n);核心把你的行程睡在等待佇列上;資料抵達(中斷觸發);核心喚醒你;read 帶著位元組回傳。等待期間你什麼也沒做。
- 非阻塞式 read:呼叫 read;它立刻帶著一些位元組、或「會阻塞」回傳;你去做別的工作;稍後再問一次(理想上是透過 epoll 對一整組只阻塞一次)。傳輸由你來推動。
- 非同步式 read:提交請求;它立刻回傳;核心在背景執行整個傳輸;你去做不相干的工作;當它真正完成時,核心把一個完成事件送到你手上。傳輸自己推動了自己。