JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

分頁快取、區塊 I/O,以及 fsync 承諾了什麼

當你寫入一個檔案時,那些位元組幾乎從不直接送到磁碟——它們落在 RAM 裡,而磁碟稍後才追上。這篇跟著一次寫入一路往下走,從分頁快取穿過區塊層、到達碟片,並釘死那一個把「大概存好了」變成「真的存好了」的系統呼叫。

你的寫入到不了磁碟(而這正是重點)

到現在,從這一級前面幾篇,你對儲存堆疊已經有了紮實的圖像:VFS 層給每個檔案系統一套 API、一個索引節點存著一個檔案的中繼資料、延伸區段把它的位元組對應到磁碟區塊的範圍、而一份日誌讓磁碟上的結構即使經歷一次當機也保持一致。這最後一篇把蝴蝶結繫上:它說明在你的程式呼叫 write() 那一刻、與資料真正安全地躺在碟片上那一刻之間,於時間與記憶體裡究竟發生了什麼。令人意外的標題是,那兩個時刻相隔甚遠,而它們之間的縫隙,正是速度與危險共同棲身之處。

這是幾乎每個人第一次都會被絆倒的真相。當 write() 成功回傳時,你的資料在磁碟上。它在一塊叫做分頁快取的核心 RAM 裡——那是核心對檔案內容的記憶體中副本,以 4 KiB 的分頁大小成塊地保存。核心把你的位元組複製進一個快取分頁、把那個分頁標記為髒的(dirty)(意思是它與磁碟上的內容不同)、然後立刻回到你的程式。真正的磁碟此刻還沒動。這不是臭蟲也不是抄捷徑;這是分頁快取在做它最核心的工作,也是為什麼一個寫入一百萬筆小紀錄的迴圈能在幾毫秒內完成,即使磁碟根本不可能以那個速率跟上。

同一個快取也服務讀取,而那正是它真正回本之處。你第一次讀一個檔案時,核心必須從磁碟取來它、並在分頁快取裡停一份副本。之後每一次讀那些相同的位元組,都直接從 RAM 端出,完全不碰磁碟——快上一千倍。分頁快取不是一塊小小的固定緩衝區;它貪婪地用掉你的應用程式沒在用的所有 RAM,這就是為什麼一台健康的 Linux 機器幾乎顯示不出多少「可用」記憶體。那些記憶體並沒有被浪費——它裝滿了最近碰過的檔案,隨時能即刻交還;而當某個程式真正需要那些 RAM 時,核心會立刻把它逐出。

回寫:磁碟如何悄悄追上

於是髒分頁在 RAM 裡堆積。終究必須有東西把它們搬到磁碟,而那個東西就是回寫(writeback):一項背景活動,由專責的核心執行緒執行,走過那些髒分頁、把它們排程出去寫到儲存裝置。整場回寫的遊戲,就是拖延到剛好足以獲利,又不拖延到讓你在一次當機裡損失太多。拖延買到兩個獎品。第一,成批處理:對同一個檔案的一百次小寫入,可以被合併成一次大型的循序磁碟寫入,而旋轉式磁碟、甚至 SSD,處理這種寫入都遠比一百次零散的有效率。第二,取消:如果你寫了一個暫存檔、一秒後又刪掉它,它的髒分頁可能根本不會碰到磁碟——那份工作在完成前就蒸發了。

什麼會觸發回寫?粗略地說,是兩個時鐘和一條高水位線。一個週期性的計時器沖刷那些髒了超過數十秒的分頁,所以沒有東西會永遠待在 RAM 裡。一條壓力門檻會在髒分頁長到超過 RAM 的某個比例時觸發,逼寫入在快取裝滿之前就開始。而記憶體回收也會觸發它:當核心想逐出一個分頁來騰出空間時,一個乾淨的分頁可以直接丟掉(它的資料和磁碟上的一模一樣),但一個髒的分頁必須先寫出去。這就是分頁快取為何讀起來便宜、卻又對更新誠實的深層原因——乾淨分頁可以白白丟棄,髒分頁則背著一筆回寫必須償還的債。

讀取得到鏡像對稱的最佳化,叫做預讀(read-ahead)。當核心注意到你正循序地讀一個檔案——區塊 10、然後 11、然後 12——它就賭你接下來會要 13、14、15,並在你開口之前把它們取進分頁快取。等你下一個 read() 執行時,資料已經在 RAM 裡,於是那個呼叫立刻回傳,而不是卡在磁碟上。預讀純粹是預測:它對於把一個檔案從頭讀到尾大有回報,而當你的存取樣式看起來是隨機的(在整個檔案裡到處跳)時,它會安靜地退讓——在那種情況下,預測未來只會把磁碟頻寬浪費在你永遠不會讀的區塊上。

穿過區塊層、下到裝置

當回寫決定一個髒分頁必須去磁碟時,它不直接和硬體說話。它把工作交給區塊 I/O 層——那個坐落在檔案系統與儲存裝置之間、以固定大小的區塊而非個別位元組來溝通的核心子系統。檔案系統說「把這些分頁內容寫到邏輯區塊 4096 到 4103」;區塊層把它包裝成一個請求、為正確的裝置排入佇列,而最終一個區塊裝置驅動程式把它轉成 SSD 或磁碟聽得懂的真正命令。這正是你在驅動程式那一級遇過的字元/區塊/網路三分法:檔案系統之所以正好住在區塊裝置之上,就是因為區塊裝置提供對固定大小、可定址成塊的隨機存取。

在區塊層裡面坐著I/O 排程器,它的工作是決定排入佇列的請求送到裝置的順序。順序對旋轉式磁碟關係重大:讀寫頭會實際移動,而從區塊 9000 跳到區塊 12 再跳回來是慢的,所以排程器可以重新排序並合併請求、以最小化磁頭的移動——這是儲存版的電梯,朝一個方向掃過樓層、而不是來回曲折。對於沒有磁頭、存取時間大致一致的 SSD,I/O 排程器多半只是合併相鄰的請求、並試著讓延遲在各行程之間公平,因為為了尋道距離而重新排序什麼也買不到。這個教訓很誠實:排程器的價值,取決於它底下那個裝置的物理特性。

fsync:把「大概」變成「一定」

現在是問題的核心。上面的一切都建立在拖延之上——你的寫入待在分頁快取裡、髒著、等回寫騰出空來處理它。多數時候那樣很好。但如果你是一個正在提交交易的資料庫,或是一個正在儲存使用者絕不能遺失的文件的文字編輯器呢?你需要一個方法說:停,現在就把我的髒資料一路寫到穩定儲存,而且不到它真的在那裡之前別回傳。那正是 fsync() 做的事。你對一個開啟的檔案描述符呼叫 fsync(fd),核心就把那個檔案的每一個髒分頁推過區塊層、送到裝置,然後阻擋你的執行緒,直到裝置確認資料是持久的。

對這個承諾要精確,因為精確正是全部的重點。fsync()保證一旦它成功回傳,這個檔案的資料以及找到那份資料所需的中繼資料(檔案的大小、它在索引節點裡的延伸區段對應)都已抵達持久儲存——所以斷電之後,重新開機會找到你的位元組完好如初。它有一個更精瘦的表親 fdatasync(),只沖刷資料、和嚴格說來讀回它所必需的那部分中繼資料,跳過像修改時間戳這種純中繼資料的更新;當你不在乎那個時間戳時,它可以稍微快一點。還有一個尖銳的告誡:一次成功的 fsync() 承諾的是你的檔案持久,而不是指向一個全新檔案的目錄項持久——那需要對目錄自己做一次 fsync,這個微妙之處我們在下一段就會看到。

即使是 fsync 也還有一層必須擊敗的拖延,值得替它命名。許多儲存裝置保有它們自己的揮發性寫入快取——磁碟上一小塊 RAM,在位元實際落到碟片或快閃之前就先確認一次寫入。因此,一個正確的 fsync() 還必須送一個快取沖刷命令,逼裝置在回報成功之前,把那個內部快取提交到永久媒介。當這件事被停用或有臭蟲時——歷史上有些消費級磁碟確實對此撒過謊——fsync() 回傳了、但資料其實不持久,於是在錯誤時刻的一次斷電終究會把它弄丟。所以這條信任鏈一路通到底:分頁快取、然後區塊層、然後裝置自己的快取,而持久性的意思是每一個環節都被逼到了底。

原子改名模式,以及崩潰一致性究竟買到了什麼

知道 fsync() 存在只是課程的一半;正確地用它來安全更新一個檔案,是另一半,而這裡有一個值得背下來的單一模式。想像你有一個設定檔,想用新的內容覆寫它。天真的做法——開啟既有的檔案、把它截斷、寫入新的位元組——是個陷阱:如果在寫到一半時斷電,你就剩下一個寫了一半的檔案,它既不是舊版本也不是新版本,只是損毀。解法是先寫新檔再改名這套舞步,而它倚靠檔案系統給你的一個保證:把一個既有名字 rename() 蓋過另一個,是原子的。在任何瞬間,一個讀者看到的要嘛是完整的舊檔、要嘛是完整的新檔,絕不會是撕裂的混合物。

  1. 把完整的新內容寫進同一個目錄裡一個全新的暫存檔(同一個目錄,這樣改名留在同一個檔案系統內、因而是一次便宜的中繼資料操作,而不是一次複製)。
  2. 對暫存檔的描述符呼叫 fsync()、並檢查它回傳 0,這樣在任何人能以真正的名字看到它之前,新資料已持久地在磁碟上。
  3. 呼叫 rename("config.tmp", "config"),原子地把新檔換到位、蓋過舊檔;讀者看到的是其中一個,絕不是一個殘缺的檔案。
  4. 也對所在的目錄做 fsync(),讓改名本身(一次目錄項的更動)是持久的;否則一次當機可能讓改名遺失、留下原來那個舊檔。

退一步,注意這個模式如何倚靠這一級稍早的一切。rename() 的原子性不是魔法——那是第三篇那份日誌在做它的工作,把目錄項的更動記錄成單一一次全有或全無的交易,讓一次當機永遠無法把它撕開。第四篇那個寫入時複製檔案系統用另一條路徑達成同樣的原子性,根本從不就地覆寫活著的資料。無論哪一種,崩潰一致性都是讓整套方案值得信任的那個性質:在任何斷電之後,檔案系統都回到一個有效的狀態——某個一致的時間點,絕不會是一團套用到一半的更動所組成的損毀爛泥。fsync() 接著讓你釘住你會落在哪一個有效狀態上。

把整個堆疊組合起來——以及一句誠實的結語

最後一次追蹤單一一個位元組,從頂到底。你的程式呼叫 write(),跨過系統呼叫邊界進入核心;那個位元組被複製進分頁快取裡一個髒分頁,而 write() 回傳——很快,但還不持久。稍後,回寫把那個分頁交給區塊層,在那裡 I/O 排程器把它在其他請求之間排序、而一個區塊裝置驅動程式發出命令;在 SSD 上,FTL 再把它重新對應一次。唯有當你呼叫 fsync() 時——沖刷那個分頁、那些中繼資料、以及裝置自己的快取——那個位元組才真正對斷電變得安全。那條鏈裡的每一層,都是為了用一點點風險換來大量的速度而存在,而 fsync 就是當你真的需要時、把那份風險買回來的那根槓桿。

走之前給你兩個誠實的警告,因為這裡正是謹慎的系統程式設計者與草率的分道揚鑣之處。第一,fsync() 會失敗——磁碟可能滿了或快壞了——而你必須像對待任何其他系統呼叫一樣檢查它的回傳值;一個忽略 fsync 錯誤的資料庫,可能悄悄回報一筆其實沒提交成功的交易已提交。第二,fsync() 是刻意昂貴的:它會擋住你的執行緒,直到真實硬體確認,常常要好幾毫秒,相較於一次有快取的寫入簡直是一個永恆。所以這門手藝在於刻意地呼叫它——在持久性真正要緊的點上,像是一次交易提交或一次儲存——而絕不在每一次小寫入後反射性地呼叫,那會把分頁快取存在的目的、它所贈與的整份效能禮物,全部扔掉。