WriteFile 已經回傳成功。那麼,此刻資料究竟在哪裡?
答案幾乎可以肯定是還沒有到磁碟上。它只是被複製到了記憶體中的快取。這正是「明明已經儲存,斷電後重開機卻消失了」會發生的原因,也是檔案複製的效能測試會跑出物理上不可能的速度的原因,更是資料庫要老老實實呼叫 fsync 的原因。
系列文章「Windows I/O 的深層」的第 4 回,要談的就是站在這中間的快取管理員。第 2 回中曾寫道「只要資料已載入快取,非同步 I/O 也會同步完成」,第 1 回則把「不建立 IRP 的捷徑(快速 I/O)」留成了作業。這次要把這些伏筆全部回收。
1. 先講結論
- Windows 的檔案快取採用回寫(write-back)方式。讀取會先從系統檔案快取,寫入也會先寫進快取。反映到磁碟上的動作,由 OS 之後才進行。1
- 快取的實體是檔案映射。快取管理員會把檔案以 256KB 為單位的區間映射到系統的位址空間,讀寫就變成「與該檢視之間的記憶體複製」(第 2 章)。1
- 寫入由每秒執行一次的延遲寫入(lazy writer)後續補上反映。應用程式當機不會遺失資料,但斷電或 OS 當機會導致髒快取遺失(第 4 章)。1
- 「確實寫入」的工具有 3 個。
FlushFileBuffers(=.NET 的Flush(true))、FILE_FLAG_WRITE_THROUGH、FILE_FLAG_NO_BUFFERING。頻繁寫入時每次都呼叫 flush 並不划算,官方文件建議併用 NO_BUFFERING+WRITE_THROUGH(第 5 章)。21 - NO_BUFFERING 有對齊要求。大小・偏移必須是磁區大小的整數倍,緩衝區位址也要對齊實體磁區邊界。而且即使是 NO_BUFFERING,中繼資料仍會持續被快取(5.3 節)。31
- 映射檢視與快取共用同一份資料。記憶體映射檔案與一般的快取 I/O 是一致的,映射的永久化分為
FlushViewOfFile+FlushFileBuffers兩個階段(第 6 章)。45 - 已載入快取的同步讀寫,有時連 IRP 都不會建立。名為快速 I/O(Fast I/O)的捷徑會直接通往快取管理員──這正是第 1 回留下的作業的答案(第 7 章)。6
2. 快取的真面目 ── 檔案被映射進記憶體
2.1. 256KB 的插槽與記憶體複製
如果把 Windows 的檔案快取想成「磁碟區塊的容器」,就會有各式各樣的行為無法解釋。正確的圖像應該是這樣的──快取管理員會把檔案以 256KB 為單位的區間,映射到系統位址空間的「插槽」中,啟用快取的讀寫則是以該插槽與應用程式緩衝區之間的記憶體複製方式執行。1
flowchart TB
subgraph U["應用程式,使用者模式"]
BUF["應用程式緩衝區<br/>傳遞給 ReadFile/WriteFile 的區域"]
end
subgraph S["系統位址空間"]
SLOT["系統檔案快取<br/>映射檔案 256KB 區間的插槽"]
end
DISK[("磁碟上的檔案")]
BUF <-->|"ReadFile/WriteFile =<br/>與插槽之間的記憶體複製"| SLOT
SLOT <-->|"首次存取時的讀取,<br/>後續的寫回皆以頁面為單位"| DISK
圖 1:啟用快取 I/O 的實際樣貌。從應用程式看到的「檔案讀寫」,多數情況下只是單純的記憶體複製
有一點容易被誤解。256KB 是檢視(映射)的粒度,並不代表磁碟 I/O 永遠都是以 256KB 為單位進行的。插槽內的頁面會依需要被讀入,實際飛到磁碟的 I/O 量,會依要求大小與存取模式而改變。如果是第一次讀取的區間,為了填入內容就會發生磁碟 I/O(此時第 1 回的 IRP 會飛向下層的儲存堆疊)。如果已經在快取中,讀取只需複製即可完成。第 2 回第 5 章看到的「快取命中時,即使發出非同步要求也會同步完成」,正是這種「能立刻答覆的要求就當場完成」行為的體現。反過來,如果雖然啟用快取,但頁面卻不在記憶體中,由於分頁錯誤處理沒有非同步的機制,非同步讀取有時會被同步處理──這個陷阱同樣是第 2 回提過的。7
2.2. 「可用記憶體變少了」的真面目
是否使用快取,以及預先讀取的狀態,是依開啟方式,也就是以檔案物件為單位進行管理的1,但被快取的資料本體是以檔案(資料流)為單位共用的。同一個檔案就算被開啟多次,也不會產生各自獨立的快取,任何控制代碼看到的都是同一份快取內容(這正是第 6 章一致性的基礎)。快取會在 Windows 運作期間,持續由快取管理員統一指揮。1 複製大型檔案或進行大量讀寫時,空閒的實體記憶體會被迅速轉用為快取。工作管理員顯示的可用記憶體看起來雖然減少了,但其中大部分其實是「只要應用程式提出要求就能迅速讓出、正在做有價值運用的待命記憶體」。為了在診斷記憶體不足時不誤判這個區別,觀測的實務做法在「在 .NET 中區分 GC 延遲與記憶體洩漏」中也曾談過。
這個動作可以在畫面上實際確認。開啟工作管理員 > 效能 > 記憶體,下方的「記憶體組成」長條會分成 使用中 / 已修改 / 待命 / 可用 四個區段。檔案快取的絕大部分會落在待命這一區,右側清單則會以「已快取」的形式加總顯示。想看得更細,可以到 資源監視器 > 記憶體 分頁,同樣的區分會連同容量一起並排顯示。複製一個數 GB 的檔案後再重新查看,會看到待命增加、可用減少,但「使用中」幾乎沒有變化──也就是說,眼見為憑地確認到的不是「記憶體被吃光」,而是「原本空閒的記憶體被拿去做快取用了」。
3. 預先讀取 ── 讀取的投機行為
快取管理員會根據過去的存取模式,搶先讀入下一段可能會被讀取的區間(read-ahead,預先讀取)。如果檔案是依序被讀取,那麼在應用程式提出要求之前,接下來的資料就已經載入快取──這正是循序讀取之所以快速的祕密所在。預先讀取的量並非固定,而是會依偵測到的模式與要求大小而變動。
flowchart LR
A["應用程式讀取要求的歷程<br/>依序從頭開始讀取"]
D{"快取管理員<br/>偵測出模式"}
R["預先讀取,在被要求之前<br/>先讀入接下來的區間<br/>量會依偵測到的模式與要求大小而變動"]
H1["提示 FILE_FLAG_SEQUENTIAL_SCAN<br/>= 積極執行預先讀取"]
H2["提示 FILE_FLAG_RANDOM_ACCESS<br/>= 預先讀取會白費,故加以抑制"]
A --> D
D --> R
H1 -.-> D
H2 -.-> D
圖 2:預先讀取。除了偵測存取模式之外,也可以透過 CreateFile 的旗標給予提示
第 1 回對照表中列出的 FileOptions.SequentialScan / RandomAccess,正是給這套預先讀取引擎的提示。「全部掃過一遍」的批次處理適合用前者,像沿著索引存取那樣的模式則適合用後者──把它們想成是把只有應用程式才知道的未來,告訴 OS 的旗標,使用時機就會變得清楚。
4. 延遲寫入 ── WriteFile 的「成功」代表什麼
4.1. lazy writer 每秒都會來報到
寫入這一側採用的是回寫式快取(write-back cache)。WriteFile 在把資料複製到插槽的那一刻就會回傳成功,反映到磁碟則被延後處理。這種「延後才寫」的方針,就是延遲寫入(lazy writing)。1
負責執行反映動作的,是快取管理員每秒啟動一次的 lazy writer。它會把最近未被清出的頁面中的八分之一排進佇列寫出,如果需要寫出的資料量較多,還會再追加。另外,以 FILE_ATTRIBUTE_TEMPORARY 屬性建立的暫存檔案,會被排除在 lazy writer 的清出對象之外──因為對一個預期會馬上被刪除的東西進行寫出,只是白費工夫。1 不過這只是由屬性所給的提示,一旦記憶體吃緊,仍然有可能被寫回,而且這不適用於「名字看起來像暫存檔」但實際上沒有這個屬性的檔案。
sequenceDiagram
participant App as 應用程式
participant C as 系統快取
participant LW as lazy writer(每秒啟動一次)
participant D as 磁碟
App->>C: WriteFile(資料)
Note over C: 複製到插槽,<br/>將頁面標記為髒(尚未寫入)
C-->>App: 立刻回傳 TRUE
Note over App,C: 從這裡到寫回為止是「危險空窗期」<br/>若發生斷電或 OS 當機,這份資料就會消失
LW->>C: 挑出髒頁的 1/8
LW->>D: 集中寫回
Note over D: 到這一刻才真正被永久化
圖 3:延遲寫入。WriteFile 的成功代表的是「已交給 OS」,而不是「已被永久化」
4.2. 發生什麼事,會消失到什麼程度
先把「危險空窗期」的意義說清楚。命運會依故障的種類而分歧。
flowchart TB
W["WriteFile 成功後的資料<br/>快取上的髒頁"]
Q{"發生了什麼事"}
A1["應用程式的行程<br/>當機/強制終止"]
A2["連 OS 一起停止<br/>斷電・藍畫面"]
S["資料保留下來<br/>因為快取屬於 OS,<br/>lazy writer 會依原訂計畫寫回"]
L["髒頁遺失<br/>只留下已抵達磁碟的部分"]
W --> Q
Q --> A1
Q --> A2
A1 --> S
A2 --> L
圖 4:故障種類決定的生死分歧點。快取「不屬於行程」,而是「屬於 OS」
- 應用程式即使當機,資料也不會消失。一旦複製到快取完成,資料的所有權就轉移給了 OS。「儲存後應用程式立刻當機,但檔案依然安然無恙」正是拜此所賜。
- 連 OS 一起停擺,髒的部分就會消失。沖清(flush)的頻率是在效能與可靠度之間做取捨後調整出來的,官方文件也明確寫著:「一旦發生突然的電源喪失,已被快取的資料就會遺失」。1
也就是說,業務應用程式設計時要問的問題是:「這份資料,在斷電那一瞬間遺失是否可以接受?」如果只是幾秒鐘份的記錄檔,或許還可以接受。但如果是訂單資料的確定紀錄,恐怕就無法接受了。只對無法接受遺失的部分,才使用下一章介紹的工具。
5. 打造「確實已寫入」的工具箱
5.1. FlushFileBuffers ── 立刻徹底寫出
FlushFileBuffers 會把指定檔案已緩衝的資料徹底寫到裝置上。檔案系統的中繼資料一律會被快取,因此要確保連中繼資料都確實送達,就需要 flush(或 WRITE_THROUGH),這一點也是需要掌握的重點。12 在 .NET 中,FileStream.Flush(true) 相當於此(單純呼叫 Flush() 只是把 .NET 內部的緩衝區交給 OS,OS 的快取仍維持原狀)。8
不過官方文件也明確給出警告──每次寫入都呼叫一次是低效的做法。如果大量寫入中每一次都需要永久化,就應該改用後面會提到的 NO_BUFFERING+WRITE_THROUGH。2
5.2. FILE_FLAG_WRITE_THROUGH ── 只去除延遲
以 FILE_FLAG_WRITE_THROUGH 開啟時,寫入會一邊寫進快取,一邊不等待 lazy writer、立即也寫進磁碟。1 重點在於讀取仍能繼續享有快取帶來的好處,這正是對「希望讀取依然快速,只想去掉寫入的延遲」這個需求的直接答案。
5.3. FILE_FLAG_NO_BUFFERING ── 不經過快取
FILE_FLAG_NO_BUFFERING 會把讀寫從系統快取本身排除在外。所有的讀寫都不經過快取,每一次都變成對磁碟裝置的 I/O。1 但能繞過的僅止於 Windows 的系統快取,正如圖 5 所示,裝置內部的寫入快取是另外一道關卡。若還要求斷電耐受性,仍需併用 WRITE_THROUGH 或 FlushFileBuffers。這是為大量資料的批次傳輸,以及自行管理緩衝區的資料庫引擎所準備的工具,但附帶著嚴格的約束。3
- 讀寫的大小與檔案偏移,必須是磁碟區磁區大小的整數倍(512 位元組磁區的話,就是 512・1024・1536……)。
- 緩衝區的位址也要對齊實體磁區大小(對於實體磁區 4096 位元組的「Advanced Format」磁碟,也需要留意)。
- 即便如此,中繼資料仍會持續被快取,所以要完全永久化,還是需要併用 WRITE_THROUGH,或呼叫
FlushFileBuffers。12
這項「約束」,正是想單靠加個旗標就打發過去的人第一個會踩到的坑。若不遵守對齊規則就進行讀寫,會以 ERROR_INVALID_PARAMETER(87)失敗。以下整理出應該遵守的 3 個要點。3
| 要對齊的項目 | 條件 | 如何滿足 |
|---|---|---|
| 讀寫的大小 | 磁碟區磁區大小的整數倍 | 取得 GetDiskFreeSpace 的 lpBytesPerSector,並取其倍數 |
| 檔案偏移 | 同上(以 OVERLAPPED 的 Offset 指定時也一樣) |
以磁區大小的倍數逐步推進 |
| 緩衝區的位址 | 對齊實體磁區大小 | 用 VirtualAlloc 配置(會傳回對齊頁面邊界,通常為 4096 位元組的區域) |
第三點特別容易被忽略。malloc、new,以及 C# 陣列所回傳的位址,並不保證會對齊磁區邊界。只要用能配置在頁面邊界的 VirtualAlloc,就能同時滿足實體磁區 4096 位元組的「Advanced Format」磁碟的要求。最精簡的寫法如下所示。
// C++ / Win32。錯誤處理已精簡到最低限度
DWORD sectorsPerCluster = 0, bytesPerSector = 0, freeClusters = 0, totalClusters = 0;
if (!GetDiskFreeSpaceW(L"C:\\", §orsPerCluster, &bytesPerSector,
&freeClusters, &totalClusters))
{
return GetLastError();
}
// 把讀寫的單位設為磁區大小的整數倍(這裡相當於 1MiB)
const DWORD chunk = (1024 * 1024 / bytesPerSector) * bytesPerSector;
// 緩衝區要取得對齊頁面邊界的區域(malloc/new 無法保證)
BYTE* buffer = static_cast<BYTE*>(
VirtualAlloc(nullptr, chunk, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE));
if (buffer == nullptr) { return GetLastError(); }
HANDLE h = CreateFileW(L"C:\\temp\\big.bin", GENERIC_READ, FILE_SHARE_READ, nullptr,
OPEN_EXISTING, FILE_FLAG_NO_BUFFERING, nullptr);
if (h == INVALID_HANDLE_VALUE)
{
// GetLastError 傳回的是「上一個 Win32 呼叫」的結果。若先呼叫 VirtualFree,
// CreateFileW 的失敗原因(存取被拒・路徑不存在等)就會被善後動作的結果覆蓋,
// 只會傳回一個查不出原因的錯誤碼
const DWORD err = GetLastError();
VirtualFree(buffer, 0, MEM_RELEASE);
return err;
}
// 每次都以 chunk 位元組為單位推進,所以大小與偏移都能維持對齊
DWORD read = 0;
while (ReadFile(h, buffer, chunk, &read, nullptr) && read > 0)
{
// 處理 buffer 開頭的 read 個位元組
// (在檔案結尾處 read 會小於 chunk。這是正常現象)
}
CloseHandle(h);
VirtualFree(buffer, 0, MEM_RELEASE);
另外,.NET 的 FileOptions 並沒有對應 FILE_FLAG_NO_BUFFERING 的值。如果無論如何都需要,就得直接呼叫 CreateFile,而這種情況下上述的對齊要求同樣得自己遵守。請按照「因為要自行管理緩衝區,所以用 NO_BUFFERING」而不是「因為想加快速度,所以用 NO_BUFFERING」這樣的順序來考量。
5.4. 用法整理
flowchart TB
A["應用程式緩衝區"]
B["系統檔案快取<br/>髒頁"]
C["磁碟裝置內的快取"]
D[("非揮發性記錄媒體")]
A -->|"預設的 WriteFile,到這裡就回傳成功"| B
B -->|"lazy writer,每秒 / WRITE_THROUGH,即時"| C
C -->|"依裝置的時機 /<br/>FlushFileBuffers 要求徹底寫出"| D
A -.->|"NO_BUFFERING 跳過快取直接前往"| C
圖 5:資料的層級,以及各種工具能推送到哪一層。也請留意「磁碟裝置內的快取」這最後一道關卡
| 方法 | 會發生什麼 | 適合的場景 |
|---|---|---|
| 預設,啟用快取 | 以快取複製完成寫入,反映交由 lazy writer | 絕大多數的檔案 I/O |
FlushFileBuffers / Flush(true) |
把當下的資料+中繼資料徹底寫出 | 節點處的確定(交易提交等) |
FILE_FLAG_WRITE_THROUGH |
每次寫入立即送到磁碟,讀取仍走快取 | 不容遺失、持續寫入的記錄檔・日誌 |
FILE_FLAG_NO_BUFFERING(+WRITE_THROUGH) |
不經過快取,附帶對齊要求 | 自行管理緩衝區・大量批次 I/O |
選擇的順序分兩步:先決定「斷電時最多可以容許遺失到什麼程度」,再確認「為此可以接受多慢」。請不要只是由上而下看這張表,而是沿著下面這個分歧來判斷。
flowchart TB
S["準備寫入這份資料"]
Q1{"斷電・藍畫面那一瞬間<br/>遺失是否可以接受"}
A0["維持預設,啟用快取<br/>速度最快,絕大多數 I/O 都在這裡"]
Q2{"不能遺失的是<br/>節點,還是每一筆"}
A1["在節點呼叫 FlushFileBuffers<br/>.NET 則用 Flush(true)<br/>成本,只有節點處的等待"]
Q3{"是否自行管理緩衝區,<br/>並能滿足 5.3 的對齊要求"}
A2["FILE_FLAG_WRITE_THROUGH<br/>每次寫入立即送到磁碟<br/>讀取仍走快取,維持速度"]
A3["FILE_FLAG_NO_BUFFERING<br/>+ FILE_FLAG_WRITE_THROUGH<br/>官方文件提出的高頻永久化做法"]
S --> Q1
Q1 -->|"允許,例如最近幾秒的記錄檔"| A0
Q1 -->|"不允許"| Q2
Q2 -->|"節點,例如交易確定"| A1
Q2 -->|"每一筆"| Q3
Q3 -->|"否,一般應用程式"| A2
Q3 -->|"是,資料庫引擎等"| A3
圖 6:工具的選擇方式。第一層分歧是可靠性要求,第二層是能承受的成本。之所以沒有「每一筆都呼叫 FlushFileBuffers」這條路,是因為 5.1 提到官方文件已將其列為低效做法
這裡也只列出 3 個實務上的典型模式。
- 「寫入暫存檔案 → flush → 重新命名」是不留下寫壞檔案的定石做法。先把內容徹底寫完,再用檔名去確定──這種原子性的交接方式,已經在《檔案整合的互斥控制基礎 - 檔案鎖與原子性 claim 的最佳實務》中詳細討論過。
- 把這件事交給資料庫,也是一種出色的設計。SQLite 如何運用 WAL 與 flush 打造耐久性,請參考《在 C# 中將 SQLite 用於業務應用程式》。「不自己寫 flush 策略」永遠是一個可以選擇的選項。
- 效能測試要懷疑快取。「讀取速度快得離譜」的量測,多半量到的是第二次以後的快取命中。量測的做法整理在《在 Windows 上如何比較不同版本程式的執行速度》中。
另外,別忘了圖 5 最後一道關卡──磁碟裝置內部的快取。FlushFileBuffers 要求的是連這一層都徹底寫出,但在 USB 隨身碟或外接磁碟上,還牽涉到裝置端的寫入快取原則(「快速移除」與「高效能」)。可卸除式裝置的處理方式,也請參考《在 Windows 應用程式中處理 USB 裝置的方法》。
6. 與記憶體映射檔案的一致性
在第 1 回聽到「快取的實體就是檔案映射」時,想必有人會這麼想──那麼自己用 MapViewOfFile 建立的檢視,和 ReadFile/WriteFile 的快取之間,不會互相打架嗎?
不會。因為兩者建立在同一套機制之上。檔案映射物件是以檔案為後盾的,頁面被清出時,會以寫回檔案的形式進行。即使多個行程針對同一個本機檔案各自建立檢視,看到的內容也是一致的(coherent)。4
flowchart TB
subgraph P1["行程 A 的位址空間"]
V1["MapViewOfFile 的檢視"]
end
subgraph SYS["系統位址空間"]
SC["快取管理員的檢視<br/>ReadFile/WriteFile 使用的插槽"]
end
PAGES["同一批實體頁面<br/>以檔案為後盾的記憶體"]
DISK[("磁碟上的檔案")]
V1 --> PAGES
SC --> PAGES
PAGES --> DISK
NB["FILE_FLAG_NO_BUFFERING 的 I/O<br/>在這個共用範圍之外,直接前往磁碟"]
NB -.-> DISK
圖 7:無論是映射檢視或快取,看到的都是同一批「以檔案為後盾的頁面」。唯一在範圍之外的只有 NO_BUFFERING
有兩點需要注意。
FILE_FLAG_NO_BUFFERING的 I/O 在這個一致性範圍之外。不經過快取的讀寫,與透過映射檢視/快取的內容不會互相比對。如果要混用,就必須自己確保一致性。- 映射檢視的永久化分為兩個階段。
FlushViewOfFile只會開始寫出範圍內的髒頁,不會寫出中繼資料,也不會等待磁碟裝置快取的實際物理寫入。要確保確實送達,必須在FlushViewOfFile之後再呼叫FlushFileBuffers。5
關於作為共享記憶體使用的檔案映射實務(具名共享、同步、事故模式),已經在《使用共享記憶體時的陷阱與最佳實踐》中討論過。
7. 快速 I/O ── 回收第 1 回的作業
先用兩行說明。快速 I/O(Fast I/O)是為了對已載入快取的檔案進行同步讀寫而準備的捷徑,它不建立 IRP(I/O Request Packet,核心傳遞給驅動程式的要求容器),而是直接與快取交換資料。Procmon 的 Operation 欄之所以會混雜出現 IRP_MJ_READ 與 FASTIO_READ,是因為同樣是「讀取」,走的卻分別是一般路徑或這條捷徑。
第 1 回 5.2 節曾寫道「並非所有 I/O 都會變成 IRP」。這裡就是核對答案的地方。
已經知道,對已載入快取的檔案進行讀寫,根本不必組出 IRP、流過裝置堆疊,只用與快取之間的記憶體複製就能完成。因此 Windows 為已快取檔案的同步 I/O準備了名為快速 I/O的捷徑──不建立 IRP,直接呼叫檔案系統的「快速 I/O 進入點」,走一條從快取管理員直接複製的路徑。6 當無法用快速 I/O 處理時(不在快取中、牽涉到鎖定、有篩選器介入等),就會折返到一般的 IRP 路徑。另外要注意,這是針對同步要求的高速路徑,並不是「快取命中=永遠都是快速 I/O」。非同步(FILE_FLAG_OVERLAPPED)控制代碼上的操作,即使能從快取當場完成(第 2 回第 5 章),仍有可能是透過 IRP 路徑處理的。
flowchart TB
REQ["對啟用快取控制代碼的同步讀寫"]
Q{"是否能用快速 I/O 處理<br/>例如已載入快取"}
FAST["快速 I/O<br/>不建立 IRP,直接與快取複製<br/>在 Procmon 中顯示為 FASTIO_"]
IRP["一般路徑<br/>組出 IRP 送進裝置堆疊<br/>第 1 回圖 6 的世界"]
REQ --> Q
Q -->|可以| FAST
Q -->|不可以| IRP
圖 8:快速 I/O 的分歧。這就是為何在 Procmon 中會看到 FASTIO_READ 與 IRP_MJ_READ 混雜出現
到這裡,就能解釋第 1 回第 7 章觀察 Procmon 時,為什麼會混雜出現 FASTIO_ 這一行了。快取命中的讀取,連 IRP 都算是奢侈品。這條路徑的存在,也會影響到第 6 回會談到的篩選驅動程式(迷你篩選驅動程式已經可以介入快速 I/O)。
8. 總結
- Windows 的檔案快取採用回寫方式,實體是檔案 256KB 區間的映射。啟用快取的讀寫,會變成與插槽之間的記憶體複製。1
- 讀取由預先讀取進行投機,
SequentialScan/RandomAccess則是給它的提示。1 - 寫入則由每秒執行的 lazy writer 後續補上。應用程式當機資料仍會保留,連 OS 一起停擺時只有髒的部分會消失。設計時要問的是:「這份資料,在斷電那一瞬間遺失是否可以接受?」1
- 確實寫入的工具是
FlushFileBuffers(節點的確定)/WRITE_THROUGH(每次寫入)/NO_BUFFERING(不經過快取+對齊要求)。每次都 flush 並不划算,官方對於高頻永久化的建議是併用 NO_BUFFERING+WRITE_THROUGH。也請留意中繼資料一律會被快取這一點。231 - 映射檢視與快取共享同一批頁面,彼此一致。唯一在範圍外的只有 NO_BUFFERING。映射的永久化分為
FlushViewOfFile+FlushFileBuffers兩個階段。45 - 快取命中的同步讀寫,靠快速 I/O 連 IRP 都省略掉了。這就是第 1 回在 Procmon 中看到的
FASTIO_的真面目。6
接下來是第 5 回,《NTFS 的內部結構 ── 從 MFT 理解檔案系統》。到目前為止,我們都是把檔案當作「偏移與位元組序列」來處理,而這次要深入到檔案背後,NTFS 究竟是如何配置資料的──MFT、多重資料流、日誌、硬連結──下探到磁碟上的靜態結構。
相關文章
- Windows I/O 的深層(第 1 回) ── 所有讀寫都會變成 IRP:I/O 系統全貌
- Windows I/O 的深層(第 2 回) ── 同步 I/O 與非同步 I/O:OVERLAPPED 的真正含義
- Windows I/O 的深層(第 3 回) ── I/O 完成埠(IOCP)與 .NET 執行緒集區:async/await 的地下室
- 檔案整合的互斥控制基礎 - 檔案鎖與原子性 claim 的最佳實務
- 使用共享記憶體時的陷阱與最佳實踐 - 先整理同步、可見性、壽命、ABI、安全性
- 在 C# 中將 SQLite 用於業務應用程式 ── WAL 模式・排他控制・損毀對策・與 EF Core 的取捨
- 在 Windows 上如何比較不同版本程式的執行速度 - 從電源模式等環境的對齊到可到的極限
- 在 Windows 應用程式中處理 USB 裝置的方法 ── 虛擬 COM・HID・WinUSB・專用 SDK 的選擇方式
相關諮詢領域
合同會社小村軟體處理「明明已經儲存的資料卻消失了」「檔案寫入太慢/快得可疑」等 Windows 業務應用程式檔案 I/O 的設計與缺陷調查。
參考連結
-
Microsoft Learn, File Caching. 關於 Windows 預設會快取檔案資料、讀取會從系統檔案快取進行、寫入也會寫進快取,是一種回寫式快取、快取以檔案物件為單位進行管理並由快取管理員統一指揮、把磁碟寫入延後並保留在快取中的方針稱為延遲寫入(lazy writing)、檔案讀取時 256KB 的區間會被讀入系統位址空間的 256KB 插槽、使用者行程會與該插槽之間複製資料、快取管理員每秒啟動一次 lazy writer,把最近未清出的頁面的八分之一排入磁碟寫入佇列,必要時還會再追加、暫存檔案不會被清出、發生電源喪失等突發的系統故障時,尚未寫入的快取資料會遺失、即使用 FILE_FLAG_NO_BUFFERING 停用快取,檔案中繼資料仍可能被快取、FILE_FLAG_WRITE_THROUGH 會讓資料同時寫進快取,並且不等待 lazy writer 的延遲、立即寫入磁碟、檔案系統中繼資料一律會被快取,因此中繼資料的永久化需要 flush 或 FILE_FLAG_WRITE_THROUGH 等內容。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 ↩16 ↩17 ↩18 ↩19
-
Microsoft Learn, FlushFileBuffers function. 關於 WriteFile 通常會寫入內部緩衝區、OS 會定期將其寫出到磁碟、FlushFileBuffers 會把指定檔案所有已緩衝的資訊寫出到裝置、每次多次寫入都呼叫一次是低效的做法,需要頻繁寫入並確保重要資料永久化的應用程式,應該使用 FILE_FLAG_NO_BUFFERING 與 FILE_FLAG_WRITE_THROUGH 的非緩衝 I/O、對磁碟區控制代碼呼叫(在具備系統管理員權限的情況下)可以清出該磁碟區上所有開啟的檔案等內容。 ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, File Buffering. 關於以 FILE_FLAG_NO_BUFFERING 開啟的檔案存取要求,讀寫的大小與檔案偏移(包含以 OVERLAPPED 指定的情況)必須是磁碟區磁區大小的整數倍、讀寫緩衝區的位址應對齊實體磁區大小、需要考量實體磁區 4,096 位元組的 Advanced Format 裝置等內容。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, File Mapping. 關於檔案映射物件是以磁碟上的檔案為後盾、頁面的換出會以把變更內容寫回檔案的形式進行、多個行程從同一個檔案映射物件建立本機檔案的檢視時,資料會保持一致(coherent,與磁碟上的檔案內容相同)等內容。 ↩ ↩2 ↩3
-
Microsoft Learn, FlushViewOfFile function. 關於 FlushViewOfFile 會開始把映射檢視範圍內的髒頁寫出到磁碟、此函式不會清出檔案中繼資料,也不會等待硬體磁碟快取的實際物理寫入完成、要把所有髒頁與中繼資料徹底寫出,應在 FlushViewOfFile 之後呼叫 FlushFileBuffers 等內容。 ↩ ↩2 ↩3
-
Microsoft Learn, IRPs Are Different From Fast I/O. 關於快速 I/O 不會產生 IRP,而是直接呼叫檔案系統或快取管理員的進入點,是給已快取檔案使用的同步 I/O 高速路徑、資料會直接在快取與使用者緩衝區之間傳輸(或反向傳輸)、無法用快速 I/O 處理時會改用以 IRP 為基礎的一般路徑等內容。 ↩ ↩2 ↩3
-
Microsoft Learn, Asynchronous disk I/O appears as synchronous on Windows. 關於資料已在快取中時,要求會當場完成並回傳 TRUE、Windows 的快取是以檔案映射方式實作,由於頁面不存在時沒有非同步分頁錯誤機制,因此啟用快取的非同步讀取有時會被同步處理等內容。 ↩
-
Microsoft Learn, FileStream.Flush method (.NET). 關於 Flush() 會把資料流的內部緩衝區寫出到 OS、指定 Flush(true) 時,除此之外還會一併清出所有中介檔案緩衝區(OS 的緩衝區)等內容。 ↩
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
Windows I/O 的深層(第 2 回) ── 同步 I/O 與非同步 I/O:OVERLAPPED 的真正含義
本文為以圖解說明 Windows 同步 I/O 與非同步 I/O(重疊 I/O)的系列文章第 2 回,將完整整理 FILE_FLAG_OVERLAPPED 的意義、完成通知的 4 種方式、明明是非同步卻同步完成的條件、取消的作法,直到與 .NET 的對應關係。
Windows I/O 的深層(第 5 回) ── NTFS 的內部結構:從 MFT 理解檔案系統
本文是透過圖解說明 NTFS 內部結構的系列第 5 回。從開發者視角整理 MFT 與檔案記錄、多重資料流(Zone.Identifier)、硬連結與 8.3 短檔名、重新解析點、兩種日誌,直到稀疏與壓縮等內容。
Windows I/O 的深層(第 3 回) ── I/O 完成埠(IOCP)與 .NET 執行緒集區:async/await 的地下室
本文是以圖解說明 I/O 完成埠(IOCP)的系列文章第 3 回。將完成佇列與執行緒數控制合而為一的設計、並行值與 LIFO 釋放、.NET 執行緒集區,直到 async/await 接續實際執行的執行緒為止,逐一整理。
Windows I/O 的深層(第 1 回) ── 所有讀寫都會變成 IRP:I/O 系統全貌
本文是從根本解說 Windows I/O 系統的系列文章第 1 回,透過圖解整理物件管理員的名稱空間、驅動程式・裝置・檔案這三種物件、IRP 的生命週期,直到 CloseHandle 幕後的機制。
Windows I/O 的深層(第 6 回・最終回) ── 篩選驅動程式與迷你篩選驅動程式:Procmon 與病毒掃描為何能夠介入 I/O
本文是透過圖解說明 Windows 篩選驅動程式與迷你篩選驅動程式的系列最終回。整理了篩選管理員與海拔、pre/post 回呼、Procmon 與防毒軟體能夠檢查全部 I/O 的機制,直到「唯獨那個環境慢」的調查步驟。
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
常見問題
整理諮詢這個主題時常見的問題。
- WriteFile 回傳成功的那一刻,資料是否已經寫入磁碟?
- 在預設情況下並沒有。Windows 的檔案快取採用回寫方式,WriteFile 在把資料複製到系統檔案快取的那一刻就會回傳成功。寫入磁碟的動作,則是由快取管理員每秒啟動一次的延遲寫入(lazy writer)之後才進行。重要的是依故障種類而產生的差異。即使應用程式的行程當機,已進入快取的資料只要 OS 還活著,之後就會被寫入,因此不會遺失。另一方面,在連 OS 一起停擺的故障(斷電、藍畫面)中,尚未寫入的髒快取就會遺失。正確的理解不是「WriteFile 成功=已永久化」,而是「成功=已交給 OS」。
- 要確實把資料寫入磁碟,該怎麼做?
- 有 3 個工具。第一個是 FlushFileBuffers,會把該檔案已緩衝的資料與中繼資料徹底寫到裝置上(在 .NET 中,FileStream.Flush(true) 相當於此)。第二個是 FILE_FLAG_WRITE_THROUGH,每次寫入都會寫進快取,同時也立即寫進磁碟。第三個是 FILE_FLAG_NO_BUFFERING,完全不經過快取本身。微軟的文件指出,每次寫入都呼叫 FlushFileBuffers 是低效的做法,如果是頻繁寫入且需要確實永久化,就應該併用 FILE_FLAG_NO_BUFFERING 與 FILE_FLAG_WRITE_THROUGH。這些工具都是以放棄快取帶來的好處為代價換取確實性,因此實務上的訣竅不是「全部都套用」,而是把使用範圍限定在不容遺失的資料寫入上。
- FILE_FLAG_WRITE_THROUGH 與 FILE_FLAG_NO_BUFFERING 有什麼不同?
- WRITE_THROUGH 是「寫進快取,但在完成之前也一併寫進磁碟」。讀取仍然可以繼續享受快取帶來的好處,只是去除了延遲寫入的延遲。NO_BUFFERING 則是「讀寫不經過系統快取」,不論讀或寫,每一次都會變成對磁碟裝置的 I/O(不過所繞過的僅止於 Windows 的快取,並不會跳過裝置內部的寫入快取)。作為交換,它附帶著嚴格的限制。讀寫的大小與檔案偏移必須是磁碟區磁區大小的整數倍,緩衝區的位址也必須對齊實體磁區邊界。此外,即使是 NO_BUFFERING,檔案系統的中繼資料仍會持續被快取,因此要確實寫出中繼資料,需要併用 FlushFileBuffers 或 WRITE_THROUGH。典型的使用者是像資料庫引擎這樣自行管理緩衝區的軟體,一般應用程式則應該優先考慮 WRITE_THROUGH 或 FlushFileBuffers,這才是穩妥的做法。
- 工作管理員顯示的可用記憶體很少,是檔案快取造成的嗎?
- 多數情況下確實如此,而且這是正常的行為。Windows 會積極把空閒的實體記憶體用作檔案快取,只要複製大型檔案或進行大量讀寫,快取就會相應膨脹,看起來記憶體使用量增加了。不過,快取所使用的頁面大多屬於一旦應用程式提出要求就能相對迅速被轉用的類型,這與「記憶體被吃光而不足」的狀態需要區分開來。要懷疑記憶體不足時,不能只看表面上的可用容量,實務上應該一併觀察已認可(commit)記憶體與 hard fault 發生頻率之類的指標。
- 用記憶體映射檔案和 ReadFile/WriteFile 去碰觸同一個檔案,內容會不會對不上?
- 與一般啟用快取的 I/O 之間不會對不上。Windows 的快取本身就是以檔案映射方式實作的,對同一個本機檔案而言,映射檢視與快取共用同一份資料,因此其中一邊的變更,另一邊也看得見。即使多個行程從同一個檔案映射物件建立檢視,資料也是一致的(coherent)。不過,以 FILE_FLAG_NO_BUFFERING 開啟的控制代碼上的讀寫並不經過快取,因此在這個一致性的範圍之外。另外,要確保映射檢視的變更確實寫入磁碟,單靠 FlushViewOfFile 並不會寫出中繼資料,也不會等待硬體快取,因此必須在 FlushViewOfFile 之後再呼叫 FlushFileBuffers。