一直矗立在記憶體與儲存之間的那道牆
退後一步,注意一個深到你從沒質疑過的假設。你程式裡的每一樣東西,都住在兩個世界其中之一。在易失(volatile)的世界裡,你用指標抵達資料:你寫 *p = 42,CPU 就把一個值以全速 store 進可按位元組定址的記憶體,但電源一閃,那個值就沒了。在持久(durable)的世界裡,你透過檔案描述符抵達資料:你呼叫 write() 與 read(),位元組能熬過一次重開機,但你得付出一次系統呼叫的跨越,而且資料是以 512 或 4096 位元組的區塊搬動,從來不是一次一個位元組。兩種定址方式、兩種速度等級、兩種完全不同的程式設計風格——而它們之間那道牆,你一直就只是默默接受了。
那道牆不是意外;它直接從你先前見過的記憶體階層裡掉出來。越靠近 CPU,儲存就越小、越快、越易失(暫存器、快取、DRAM);越遠就越大、越慢、越持久(SSD、磁碟)。每一層都是速度與永久性之間的權衡,而易失與持久之間的那條分界線,數十年來,恰恰就坐落在 DRAM 與磁碟之間。持久記憶體(persistent memory,常寫作 pmem,並以 Intel Optane DC 之類的裝置販售)是真正新出現的一層,它落在那道縫隙裡:它像 DRAM 一樣插上記憶體匯流排,CPU 用普通的 load 與 store 來定址它,它只比 DRAM 慢幾倍——而它在電源消失時保留住自己的內容。這是頭一次,持久的儲存出現在牆錯誤的那一側。
當一條 store 能熬過當機,什麼改變了
想像最簡單的用法。你拿一塊持久記憶體區域,把它交給 mmap(),好讓它出現在你行程的位址空間裡,於是你現在握著一個指向持久儲存的普通指標。從前更新磁碟上的一筆記錄,意味著 open()、seek 到某個位移、write() 一個區塊、還要操心緩衝;如今它是一行——node->next = newp——一條 store 指令就地更新了一個持久的資料結構。沒有序列化、沒有把一個結構體編排進位元組緩衝區、沒有透過核心來回複製。你可以直接在持久記憶體裡蓋一個鏈結串列、一棵 B 樹、一張雜湊表,用普通的指標追逐來走訪它,而在你拔掉插頭之後,它還在那裡。那就是那個夢想,而且它是真的。
現在來談那個讓這件事成為前沿、而非免費午餐的硬道理。你的 store 指令並不會直接寫進持久媒介。它寫進快取行(cache line),可能在易失的 CPU 快取裡待上無限長的時間,才被逐出到 pmem。所以在「store 執行了」與「資料真的持久了」之間,存在一道縫隙——而如果電源在那道縫隙裡失效,那個值就丟了,跟它從沒被寫過一模一樣。更糟的是,快取與記憶體控制器可以用它們高興的任何順序把髒的快取行寫回,這正是你在同步那一階遇過的同一種重排序自由。所以一次當機可能讓你落得一次更新的後半被持久了、而前半還在易失狀態、如今丟了——一個被撕裂、做了一半的結構,帶著一根懸空指標永久地烙進持久記憶體裡。
把清空與柵欄之舞,一步步拼出來
因為 CPU 不會自己把資料推向媒介,把一條 store 變持久,是一段你親手執行的、明確的三步序列。第一步,做那條 store,把你的位元組寫進快取。第二步,用像 clwb(cache-line write-back,快取行寫回)這樣的指令,把那條快取行朝持久性清空——在 x86 上,它告訴硬體把那條髒的快取行往記憶體控制器送下去。第三步,發出一道 store 柵欄(sfence),好讓所有那些清空在程式繼續之前都被保證完成;沒有它,你做的下一條 store 可能會搶在你剛清空的那條之前抵達持久。把順序弄錯、跳過柵欄,你就寫出了在每一次測試執行上都正確、卻在那唯一一次真正要緊的當機上悄悄損毀的程式碼。
Making one store durable on persistent memory (x86 sketch):
node->value = 42; // 1. store: lands in the volatile cache line
_mm_clwb(&node->value); // 2. flush that line toward the pmem media
_mm_sfence(); // 3. fence: don't proceed until the flush retires
// only NOW is node->value guaranteed durable across a power cut
Contrast with the classic durable-write path through a file:
write(fd, buf, n); // bytes copied into the kernel page cache
fsync(fd); // force page cache + device cache to the disk
// fsync() is the heavyweight syscall equivalent of clwb + sfence值得把這看成你早已認得的某樣東西在指令層級的直系表親。當你對一個檔案描述符呼叫 fsync(),你是在請求核心把分頁快取與裝置自己的快取一路逼到持久媒介——那是「讓我的寫入熬過當機」這件事的重量級、付出系統呼叫代價的版本。clwb 加 sfence 之舞,正是同一個概念被往下推到裸機上:同樣的目標、沒有核心的跨越、單一快取行的粒度。理解 pmem 在很大程度上就是理解:為持久性而清空如今是你以一條指令的速度去做的、明確的工作,而不是某個檔案系統呼叫默默地、以 4 KiB 的區塊替你打理的事。
當機一致性:讓一個結構保持完整
讓一條 store 變持久是容易的部分。真正的難題是把一次多條 store 的更新當成一個整體弄成持久——因為一次斷電可能落在你任何兩條 store 之間。假設你往一個有序串列裡插入:你寫下新節點的各個欄位,然後翻轉前驅的 next 指標、讓它指向那個新節點。如果當機落在指標翻轉之後、但在新節點內容被清空之前,持久記憶體裡如今就握著一根指向裝滿垃圾的節點的指標。這正是檔案系統一直面對的同一個當機一致性問題,只不過檔案系統替你在 fsync() 背後解決了它;在原始的 pmem 上,這個重擔回到了程式設計師身上,而且是在個別快取行的粒度上。
- 決定這次更新的不變式:那個即使在當機後也必須成立的單一性質——這裡是「任何可達的 next 指標都指向一個完全初始化好的節點」。
- 先把新節點的所有欄位寫好,然後清空並柵欄它們,好讓那個節點的內容在任何東西指向它之前,都被保證持久。
- 到此刻才執行那單一一條連結用的 store(前驅的 next 指標),使那個節點變得可達——這一條 store 就是那個原子的提交點。
- 也把那條連結的 store 清空並柵欄,好讓提交本身持久;在它之前當機,會留下完好如初的舊串列,在它之後當機,會留下完整在場的新節點。
那套紀律——把你的 store 排序,好讓單一、最後、持久的那條 store 成為提交的時刻——正是預寫日誌(write-ahead log)背後、以及每一個日誌式檔案系統背後的那個概念,如今逐位元組地施加。當連單一一條提交 store 都大到無法原子化時(比方說它橫跨兩條快取行),真實的系統會退回去用記錄:把打算做的變更寫進一個小小的持久重做日誌、清空它、然後才就地套用,這樣一來,一次套用到一半的當機,可以靠重開機時重播那份日誌來完成。這就是為什麼沒有一個神智清楚的人會為任何正經的東西親手寫原始的 clwb 加 sfence 程式碼。像 Intel PMDK 這樣的函式庫給你 pmem 上的交易——你把一組 store 標記成一個原子單元,函式庫就替你處理記錄、清空、柵欄、與當機時的復原——把這片前沿重新變回一個普通程式設計師能安全使用的東西。
其他幾種新的儲存模型,以及這一切的去向
持久記憶體是最激進的轉變,但它只是舊儲存假設正在龜裂的好幾個地方之一。今天的 SSD 已經在對你說謊:它假裝自己是一個由固定區塊組成的單純陣列,而底下藏著一層 Flash 轉換層,把每一次寫入重新對映、好把磨損攤開,並在底下跑垃圾回收——這正是為什麼一顆 SSD 需要 TRIM 指令來被告知哪些區塊真的空著。較新的介面把那層虛構剝掉——分區命名空間(zoned namespaces)與開放通道 SSD 把真實的 flash 幾何結構暴露出來,好讓檔案系統、而非韌體,來決定放置。而 CXL,一種快取一致的互連,讓一台機器透過一條快速連結接附整池的記憶體(包括持久記憶體)、甚至跨伺服器共享它們,把「我的 RAM」與「儲存層」之間的界線模糊得更加厲害。
把這些線索拉到一起,能留下來的教訓,是關於一個會漏的抽象終於被撕開。數十年來,那個乾淨的切分——指標給快速易失的記憶體、檔案描述符給緩慢持久的儲存——是一個有用的簡化,而把一個檔案對映進記憶體的 mmap() 把戲,是舊世界最接近搭起那座橋的做法(但一次分頁錯誤仍會在底下從磁碟把資料拉上來)。持久記憶體把底下那顆磁碟整個移除,而那個讓人安心的謊言——持久性是某種遙遠、粗粒度、由核心替你打理的東西——也跟著一起走了。留下來的,是一個更鋒利、更誠實的模型:持久性是你逐條 store 去推理的一個性質,你用明確的清空與柵欄為它付帳,而你用資料庫與檔案系統一直以來所用的同一套記錄紀律,讓你的結構保持一致——只是如今是在你自己的位址空間裡。