更新紀錄(僅初版,2026年07月29日 發布)
- 初次發布
引用本文(DOI(已登錄存檔): 10.5281/zenodo.22175333)
以下 DOI 指向先前登錄的存檔,內容可能與目前正文不同。引用目前正文時,請使用本頁網址。
Go Komura(2026)。〈Windows I/O 的深層(第 4 回) ── 快取管理員:你的 WriteFile 究竟何時送達磁碟〉。小村軟體有限公司。 https://comcomponent.com/zh-TW/blog/windows-cache-manager-writefile-disk/
- DOI(已登錄存檔)
- 10.5281/zenodo.22175333
- DOI(上次登錄版本)
- 10.5281/zenodo.22175334
WriteFile 回傳了成功。那麼,資料現在究竟在哪裡?
在一般啟用快取的寫入中,資料會先交給 OS 記憶體上的快取。WriteFile 的成功代表「已交給 OS」,而不是「已持久化到磁碟」。 寫入磁碟的動作,由 OS 在之後完成。1
知道這個差別,就能解釋「明明已經儲存卻因為斷電而消失」「檔案複製的量測值比磁碟效能還快」這類現象。資料庫之所以用 fsync 之類的呼叫明確寫出,也是為了區分「接受寫入」與「完成持久化」。
本文是系列「Windows I/O 的深層」的第 4 回。本篇以位於應用程式與磁碟之間的快取管理員為題,依序整理快取的機制、寫出的時機,以及不能遺失的資料該怎麼保存。
同時也會說明第 2 回提到的「資料在快取裡時非同步 I/O 也會同步完成」的原因,以及第 1 回提到的「不建立 IRP 的捷徑 ── 快速 I/O」。
1. 先講結論
- 預設的寫入中,複製到快取與寫入磁碟是兩回事。 Windows 採用回寫方式,由 lazy writer 在之後寫出。交給 OS 快取的資料,在只有應用程式當機時會保留下來;但斷電或 OS 當機時,尚未寫出的髒頁會遺失。1
- 對不能遺失的資料,要選擇與保存粒度相符的方法。 如果是在告一段落時確定下來,基本上用
FlushFileBuffers(.NET 中是FileStream.Flush(true));如果要消除每次寫入的延遲,基本上用FILE_FLAG_WRITE_THROUGH。FILE_FLAG_NO_BUFFERING是繞開系統快取的另一種工具,帶有對齊要求,而且仍要顧慮裝置內部快取與中繼資料。對於頻繁的持久化,官方舉出的做法是併用 NO_BUFFERING 與 WRITE_THROUGH。231 - 讀取的速度與 I/O 路徑,同樣可以從快取的機制來理解。 快取的實體就是檔案對應,讀取時預先讀取會發揮作用。對應檢視的一致性與持久化要分開考慮,快速 I/O 則理解成「可以省掉 IRP 時的捷徑」。456
可以依照自己的目的,從下面對應的章節讀起。
| 想知道的事 | 要讀的章節 |
|---|---|
| 快取的實體、可用記憶體變少的原因、預先讀取 | 第 2 章、第 3 章 |
| WriteFile 成功之後,故障會讓什麼遺失 | 第 4 章 |
| Flush、WRITE_THROUGH、NO_BUFFERING 的分別使用與對齊要求 | 第 5 章 |
| 對應檢視的一致性,以及兩階段的排清 | 第 6 章 |
| 快取命中與快速 I/O 的差別 | 第 7 章 |
圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 32 條,附依據與可信度)以及主要概念的定義,彙整在知識地圖詳細頁面(日文)。資料:JSON-LD / Turtle
2. 快取的真面目 ── 檔案被對應到記憶體
2.1. 256KB 的插槽與記憶體複製
快取的實體,是把檔案的一部分對應到記憶體上的檢視。快取管理員會把檔案以 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 並不是讀寫磁碟的單位
256KB 是檢視(對應)的粒度,並不代表磁碟 I/O 永遠都以 256KB 為單位。 插槽內的頁面會依需要讀入,實際的 I/O 量會隨要求大小與存取模式而改變。
如果是第一次讀取的區間,就會為了填滿快取而發生磁碟 I/O,第 1 回看到的 IRP 會往下走進儲存堆疊。如果資料已經在快取裡,讀取只靠記憶體複製就能完成。
即使以非同步方式發出,也可能當場就處理完
第 2 回第 5 章提到的「快取命中時即使以非同步方式發出也會同步完成」,說的就是能立刻給出結果的要求當場完成的行為。
反過來說,即使啟用了快取,如果頁面不在記憶體裡,由於分頁錯誤的處理沒有非同步的機制,非同步讀取有時也會被同步處理。快取命中帶來的立即完成,與因為頁面不在而變成同步處理,要分開理解。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。
| 存取的計畫 | Win32 的旗標 | .NET 的指定 | 給預先讀取的提示 |
|---|---|---|---|
| 依序讀完整個檔案的批次處理 | FILE_FLAG_SEQUENTIAL_SCAN |
FileOptions.SequentialScan |
積極進行預先讀取 |
| 像沿著索引跳轉那樣的不規則存取 | FILE_FLAG_RANDOM_ACCESS |
FileOptions.RandomAccess |
抑制容易白費的預先讀取 |
重要的是選擇與處理內容相符的提示,這兩個旗標都不是把預先讀取量固定下來的指定。
4. 延遲寫入 ── WriteFile 的「成功」代表什麼
4.1. lazy writer 每秒都會來報到
預設的寫入中,WriteFile 在把資料複製到快取插槽的那一刻就回傳成功,寫入磁碟則被推到後面。這種回寫式快取的方針,就稱為延遲寫入(lazy writing)。1
負責把資料寫進磁碟的,是快取管理員每秒啟動一次的 lazy writer。它會把最近未曾排清的頁面中的八分之一排進佇列,如果要寫的資料多,還會繼續追加。1
這裡說的「每秒」是處理的啟動間隔。不能把它讀成「每一次寫入都會在 1 秒內完成持久化」的保證。
暫存檔案的屬性是抑制寫回的提示
帶有 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. 發生什麼事時,會遺失到什麼程度
從 WriteFile 回傳成功到寫回結束之間,存在一段資料還留在快取裡的時間。這段期間的資料會怎麼樣,取決於只有應用程式停了,還是連 OS 一起停了。
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,只要 OS 還在運作,之後就會被寫回 |
| 斷電、OS 當機 | 尚未寫回的髒頁遺失,只剩下已經抵達磁碟的部分 |
「儲存之後應用程式馬上就當了,檔案卻安然無恙」,是因為資料複製進快取之後就由 OS 持有。另一方面,官方文件也明確寫著,突然斷電時尚未寫出的快取資料會遺失。排清的頻率,是當成效能與可靠性之間的取捨來調整的。1
設計時要先決定的是:「這份資料,在斷電的那一瞬間遺失是否可以接受」。記錄檔最近幾秒的內容或許可以接受,但訂單資料的確定紀錄大概不能遺失。對不能遺失的寫入,使用下一章的方法。
5. 打造「確實寫入了」的工具箱
本章要區分三種方法:把現有的緩衝區寫出去、消除寫入的延遲、繞開系統快取。在最後的 5.4 節,會依可靠性的要求與付得起的成本,整理選擇的順序。
5.1. FlushFileBuffers ── 現在就徹底寫完
FlushFileBuffers 會把指定檔案已緩衝的資料寫出到裝置。由於檔案系統的中繼資料一律會被快取,要把中繼資料也送到位,就需要排清,或是需要 WRITE_THROUGH。12
在 .NET 中要區分 Flush() 與 Flush(true)
| 呼叫 | 寫出的範圍 |
|---|---|
FileStream.Flush() |
把 .NET 內部的緩衝區交給 OS。不會要求排清 OS 的快取 |
FileStream.Flush(true) |
除了 .NET 內部之外,還會排清 OS 等中介的檔案緩衝區 |
在 Windows 上與 FlushFileBuffers 相當的指定,是 FileStream.Flush(true)。請留意它與只呼叫 Flush() 之間的差別。8
也要考慮每次都排清的成本
官方文件指出,在大量寫入中每次都呼叫 FlushFileBuffers 的做法沒有效率。對於寫入頻繁、而且每次都需要持久化的情況,文件舉出的做法是後面會提到的併用 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 是把 Windows 的系統快取從讀寫路徑上拿掉的指定。讀取與寫入都不經過快取,每一次都直接變成對磁碟裝置的 I/O。1
不過,裝置內部的寫入快取是另外一層。NO_BUFFERING 並不能繞到那一層,因此如果需要斷電耐受性,仍要繼續考慮併用 WRITE_THROUGH 或呼叫 FlushFileBuffers。
它適合大量資料的整批傳輸,以及自行管理緩衝區的資料庫引擎;另一方面,應用程式這一側必須滿足下面的對齊要求。3
- 讀寫的大小與檔案偏移,必須是磁碟區磁區大小的整數倍(512 位元組磁區的話就是 512、1024、1536……)。
- 緩衝區的位址也要對齊實體磁區大小(還需要顧慮實體磁區為 4096 位元組的「Advanced Format」磁碟)。
- 即使如此,中繼資料仍會持續被快取,所以要做到完整的持久化,需要併用 WRITE_THROUGH 或呼叫
FlushFileBuffers。12
把大小、偏移與位址這 3 點都對齊
只是加上旗標,並不能切換到 NO_BUFFERING。不遵守對齊要求的讀寫,會以 ERROR_INVALID_PARAMETER(87)失敗。 要確認的是下面 3 點。3
| 要對齊的對象 | 條件 | 如何滿足 |
|---|---|---|
| 讀寫的大小 | 磁碟區磁區大小的整數倍 | 取得 GetDiskFreeSpace 的 lpBytesPerSector,再取整成它的倍數 |
| 檔案偏移 | 同上(以 OVERLAPPED 的 Offset 指定時也一樣) |
每次以磁區大小的倍數前進 |
| 緩衝區的位址 | 對齊實體磁區大小 | 用 VirtualAlloc 配置(會傳回對齊頁面邊界,通常是 4096 位元組的區域) |
用最精簡的 C++ 例子確認緩衝區的對齊
最容易被忽略的是第三點,也就是緩衝區的位址。malloc、new 以及 C# 陣列所傳回的位址,並不保證會對齊磁區邊界。
下面的例子用 VirtualAlloc 配置對齊頁面邊界的區域。如果是一般的 4096 位元組頁面邊界,那麼實體磁區為 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 中也不能省掉對齊的管理
.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 -->|"可以接受<br/>(最近幾秒的記錄檔等)"| A0
Q1 -->|"不可以接受"| Q2
Q2 -->|"告一段落<br/>(交易的確定等)"| A1
Q2 -->|"每一筆"| Q3
Q3 -->|"否(一般應用程式)"| A2
Q3 -->|"是(資料庫引擎等)"| A3
圖 6:工具的選法。第一層分歧是可靠性的要求,第二層是付得起的成本。之所以沒有「每一筆都呼叫 FlushFileBuffers」這條路,是因為正如 5.1 所見,官方文件認為那樣沒有效率
落實到保存的設計與效能量測上
在實際工作中,把它與下面三種模式連起來考慮,會比較容易做選擇。
- 「寫入暫存檔案 → 排清 → 重新命名」是不留下寫壞一半的檔案的標準做法。先把內容寫完,再用檔名確定下來 ── 這種原子性的交接,在「檔案整合的互斥控制基礎知識」中有詳細討論。
- 交給資料庫也是很像樣的設計。SQLite 如何用 WAL 與排清做出耐久性,請參考「在 C# 中將 SQLite 用於業務應用程式」。「不自己寫排清策略」這個選項一直都在。
- 做效能測試時要懷疑快取。「讀取快得離譜」的量測結果,多半量到的是第二次以後的快取命中。量測的做法,整理在「在 Windows 上正確比較不同版本程式執行速度的方法」中。
把裝置內部的快取也一起考慮進來
圖 5 的最後還留著磁碟裝置內部的快取。FlushFileBuffers 要求的是把這一層也包含在內徹底寫完。
在 USB 隨身碟與外接磁碟上,還會牽涉到裝置端的寫入快取原則,也就是「快速移除」與「效能更佳」。可卸除式裝置的處理方式,另請參考「在 Windows 應用程式中處理 USB 裝置的方法」。
6. 與記憶體對應檔案的一致性
6.1. 對應檢視與快取看到的是同一份資料
既然快取的實體就是檔案對應,那麼自己用 MapViewOfFile 建立的檢視,與 ReadFile / WriteFile 所用快取的內容之間會是什麼關係呢。
一般啟用快取的 I/O 與對應檢視,共用以同一個檔案為後盾的資料。檔案對應物件的頁面被換出時,變更會被寫回檔案。多個處理程序針對同一個本機檔案,從同一個檔案對應物件建立檢視時,看到的內容也是一致的(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
6.2. 一致性與持久化要分開確認
NO_BUFFERING 的 I/O 處在這個一致性的範圍之外。 不經過快取的讀寫,與對應檢視或經由快取的內容之間不會互相比對。如果要混用,就需要由應用程式這一側自己確保一致。
另外,變更在對應檢視中已經看得見,和該變更已經持久化到磁碟,是兩回事。對應檢視的持久化,要按照下面的順序進行。5
| 順序 | 呼叫 | 作用與注意事項 |
|---|---|---|
| 1 | FlushViewOfFile |
開始寫出範圍內的髒頁。不寫中繼資料,也不等待裝置內部快取完成實際的物理寫入 |
| 2 | FlushFileBuffers |
要求把檔案的中繼資料與裝置內部快取都包含在內徹底寫完 |
把檔案對應當作共享記憶體使用的實務(具名共享、同步、踩坑模式),在「使用共享記憶體時的陷阱與最佳實踐」中有討論。
7. 快速 I/O ── 回收第 1 回的作業
快速 I/O 是對已快取檔案進行同步讀寫時,不建立 IRP 就完成處理的捷徑。IRP 指的是「I/O 要求封包」,是裝載核心交給驅動程式的要求的結構。6
7.1. 可以省掉 IRP 的情況,與折返一般路徑的情況
第 1 回 5.2 節「並非所有 I/O 都會變成 IRP」的說明,指的就是這條路徑。
如果讀寫只靠與快取之間的記憶體複製就能處理完,就不必組出 IRP 再送進裝置堆疊。在快速 I/O 中,會直接呼叫檔案系統的進入點,與快取管理員交換資料。6
不過,如果因為資料不在快取裡、牽涉到鎖定、有篩選器介入等原因而無法使用快速 I/O,就會折返一般的 IRP 路徑。
還要注意,並不是「快取命中就一定走快速 I/O」。非同步(FILE_FLAG_OVERLAPPED)控制代碼上的操作,即使能從快取當場完成,也可能是走 IRP 路徑處理的。第 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 混雜出現,原因就在這裡
7.2. 觀察時要區分同一個讀取所走的路徑
第 1 回第 7 章的 Procmon 觀察中混雜著 FASTIO_ 的行,是因為同樣是讀取,經過的路徑卻不同。FASTIO_READ 與 IRP_MJ_READ 要當成快速 I/O 與一般 IRP 路徑的差別來分別解讀。
這條捷徑也與第 6 回要談的篩選驅動程式有關。迷你篩選驅動程式不只能介入一般的 IRP 路徑,也能介入快速 I/O。
8. 總結
依機制、故障、設計判斷的順序回顧全文。
| 觀點 | 要掌握的重點 |
|---|---|
| 快取的實體 | 對應了檔案 256KB 區間的檢視。啟用快取的讀寫是與插槽之間的記憶體複製,256KB 並不是磁碟 I/O 的固定大小 |
| 讀取 | 會預先讀取下一個區間。SequentialScan / RandomAccess 是傳達存取模式的提示 |
| 寫入與故障 | 預設由 lazy writer 在之後寫出。交給 OS 快取的資料在只有應用程式當機時會保留,但斷電、OS 當機時尚未寫出的髒頁會遺失 |
| 保存方法的選擇 | 告一段落時的確定用 FlushFileBuffers,要消除每次寫入的延遲就用 WRITE_THROUGH。NO_BUFFERING 帶有對齊要求,頻繁持久化時要考慮與 WRITE_THROUGH 併用 |
| 一致性與持久化 | 對應檢視與一般的快取 I/O 共用資料。NO_BUFFERING 處在範圍之外,對應的持久化要在 FlushViewOfFile 之後再用 FlushFileBuffers |
| I/O 路徑 | 快速 I/O 是在對已快取檔案的同步 I/O 中省掉 IRP 的路徑。但快取命中並不總是走快速 I/O |
在理解回寫、預先讀取與 lazy writer 的基礎上,請先決定 「這份資料在斷電時遺失是否可以接受」。然後再把排清的成本、一律會被快取的中繼資料、裝置內部快取都考慮進去,選擇保存的方法。123
對應檢視的一致性與寫出的完成、快取命中與省掉 IRP,也都是各自獨立的判斷。這種區分,會成為排查保存問題與快得不自然的效能測試結果的線索。456
接下來是第 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 的延遲而立即寫入磁碟;檔案系統中繼資料一律會被快取,因此中繼資料的持久化需要排清或 FILE_FLAG_WRITE_THROUGH 等內容。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15
-
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. 關於檔案對應物件以磁碟上的檔案為後盾,頁面被換出時表現為把變更內容寫入檔案;多個處理程序從同一個檔案對應物件為同一個本機檔案建立檢視時,資料是一致的(與磁碟上的檔案內容相同)等內容。 ↩ ↩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 ↩4
-
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 的機制。整理 FltMgr、高度、pre/post 回呼與 fltmc 的讀法,並彙整用 Procmon 找出慢操作的步驟,以及排除設定與 Dev Drive 的注意事項。
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
常見問題
整理諮詢這個主題時常見的問題。
- WriteFile 回傳成功的那一刻,資料是否已經寫入磁碟?
- 預設情況下還沒有寫入。Windows 的檔案快取採用回寫方式,WriteFile 在把資料複製到系統檔案快取的那一刻就回傳成功。寫入磁碟的動作,則由快取管理員每秒啟動一次的延遲寫入(lazy writer)在之後完成。重要的是不同故障種類之間的差別:應用程式的處理程序當機時,只要 OS 還在運作,已經進入快取的資料之後仍會被寫入,不會遺失;而連 OS 一起停擺的故障(斷電、藍畫面)會讓尚未寫入的髒快取遺失。正確的理解不是「WriteFile 成功=已持久化」,而是「成功=已交給 OS」。
- 要確實把資料寫入磁碟,該怎麼做?
- 有三種工具。第一是 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。