更新紀錄(僅初版,2026年08月20日 發布)
- 初次發布
引用本文(DOI(已登錄存檔): 10.5281/zenodo.22176200)
以下 DOI 指向先前登錄的存檔,內容可能與目前正文不同。引用目前正文時,請使用本頁網址。
Go Komura(2026)。〈Windows 記憶體的深層(第3回)── 區段物件與寫入時複製:DLL 與檔案對應的真面目〉。小村軟體有限公司。 https://comcomponent.com/zh-TW/blog/windows-memory-internals-section-copy-on-write/
- DOI(已登錄存檔)
- 10.5281/zenodo.22176200
- DOI(上次登錄版本)
- 10.5281/zenodo.22176201
「如果有 100 個處理程序都使用同一份 kernel32.dll,程式碼分頁是不是也要 100 組?」第 3 回的起點,正是多個處理程序使用相同內容時冒出的疑問。兩個處理程序把同一個檔案對應到記憶體時,其中一方讀過的分頁,另一方能不能拿來用?
答案就在這個機制裡:從不同的虛擬位址,對應到同一個實體分頁。共用單位的核心是區段物件。而在寫入時只把必要的分頁私有化的,就是寫入時複製(Copy-on-Write,CoW)。
上一回的「Windows 記憶體的深層(第2回)── 實體分頁的一生」追蹤了離開 Working Set 的分頁如何在 Modified、Standby、Free、Zeroed 之間移動。Standby 上也會留著 DLL、EXE、對應檔案與檔案快取的分頁。這一回要在那之上,再加進來自多個處理程序的共用這個視角。
本文依序看過 EXE 與 DLL、資料檔、分頁檔後盾的共用記憶體與檔案快取,再區分同一檔案串流上的快取、資料、映像三條路徑。數字本身該怎麼讀,以入門篇「Windows 的「記憶體使用量」究竟代表什麼」為前提。
「Windows 記憶體的深層」全 3 回
本系列依照取得實體分頁 → 追蹤常駐與回收的流程 → 理解共用與私有化的順序推進。
| 回次 | 主題 | 這一回要追蹤的內容 |
|---|---|---|
| 第1回 | 虛擬位址與分頁錯誤 | 用 VirtualAlloc 配置的區域,何時取得實體 RAM |
| 第2回 | 實體分頁的一生 | 離開 Working Set 的分頁的狀態轉移,以及分頁檔的角色 |
| 第3回(本文) | 區段物件與寫入時複製 | DLL、檔案對應與共用記憶體如何共用實體分頁 |
第 3 回要回答的疑問只有一個。
為什麼多個處理程序可以把同一份 DLL 或檔案,當成一組實體分頁來使用?
| 開始讀之前 | 內容 |
|---|---|
| 目標讀者 | 想從內部結構理解 DLL 共用、CreateFileMapping、MapViewOfFile、共用記憶體、CoW 與檔案快取之間關係的開發者與維運人員 |
| 前提環境 | Windows 10/11 或現行的 Windows Server |
| 前提知識 | 虛擬位址、分頁錯誤、Working Set 的基礎 |
| 難度 | 中級。也會用到 Control Area、Prototype PTE 這類內部術語 |
觀測會用到 VMMap、Process Explorer 與 QueryWorkingSetEx。
圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 19 條,附依據與可信度)以及主要概念的定義,彙整在知識地圖詳細頁面(日文)。資料:JSON-LD / Turtle
1. 先講結論
整體樣貌可以歸納成以下三點。
- 把共用的內容,和各處理程序看到的位址分開。 區段物件代表一段可共用的記憶體範圍,各處理程序把其中一部分當成檢視對應進來。就算用的是同一個區段位移,檢視的虛擬位址也不必一致。12
- 把內容由什麼撐住,和走哪條路徑使用分開。 區段分成由實際檔案撐住的,和由分頁檔撐住的。EXE/DLL 是映像區段,一般的檔案對應是資料區段。
SEC_IMAGE的分頁保護由 PE 內部的屬性決定。快取、資料、映像這三條路徑,也不能揉成一條。234 - CoW 的私有化,要以分頁為單位看共用狀態來確認。 讀取期間共用,只複製被寫入的那一頁,並抽換該處理程序的 PTE。
FILE_MAP_COPY會先對整個檢視收取 Commit,所以光看 Private Bytes 無法斷定它是否發生。確認時請用QueryWorkingSetEx的 Shared 位元。567
用一句話總結,被共用的不是虛擬位址,而是區段內的內容,以及當下對應到的實體分頁。
| 想知道的事 | 該讀的章節 |
|---|---|
| 區段、檢視與後盾儲存的關係 | 第 2~3 節 |
| DLL 的共用,以及寫入時的 CoW | 第 4~6 節 |
| 與 Cache Manager 的差異,以及關掉之後仍留著的原因 | 第 7~8 節 |
| 想用兩個處理程序確認共用與私有化 | 第 9~10 節 |
2. 區段物件與檢視
依 Microsoft 的定義,區段物件代表一段可共用的記憶體區塊,同時也是把檔案對應到處理程序位址空間的機制。1
理解的訣竅,是把區段本身和各處理程序看到的檢視分開來想。
| 概念 | 角色 |
|---|---|
| 區段物件 | 代表要共用的內容、大小、後盾儲存,以及保護的上限 |
| 檢視 | 把區段的一部分,顯示成某個處理程序的虛擬位址範圍 |
| PTE | 把檢視內的每個虛擬分頁,接到目前的實體分頁或尚未具體化的狀態 |
| PFN | 代表實際存在於 RAM 中的實體分頁 |
Win32 API 也要順著這個角色差異分開來讀。3
| 階段 | 得到的東西 |
|---|---|
CreateFileMapping |
檔案對應物件的控制代碼 |
MapViewOfFile |
放進處理程序虛擬空間裡的檢視 |
| 第一次存取檢視 | 透過分頁錯誤,把被存取的分頁對應到實體記憶體 |
先看把唯讀檔案對應進來的例子。
HANDLE mapping = CreateFileMappingW(
file,
nullptr,
PAGE_READONLY,
0,
0,
nullptr);
void* view = MapViewOfFile(
mapping,
FILE_MAP_READ,
0,
0,
0);
只呼叫 CreateFileMapping,還拿不到處理程序讀得到的位址。而且就算建立了檢視,也不代表所有分頁立刻進入 RAM。從第一個被碰到的分頁開始,分頁錯誤會讀入檔案內容,並把實體分頁接到 PTE。8 也就是說,第1回看過的需求分頁(demand paging),同樣原封不動地適用於區段檢視。
2.1. 同一區段,不同的虛擬位址
就算處理程序 A 與 B 對應的是同一區段的同一個位移,檢視的起始位址仍可能不同。
flowchart LR
accTitle: 從不同虛擬位址對應到同一個實體分頁
accDescr: 處理程序 A 與處理程序 B 各自在不同的虛擬位址上持有檢視,但透過同一個區段位移到達同一個實體分頁 PFN X
viewA["Process A: 0x000001A00000 + 0x3000"] --> offset["區段位移 0x3000"]
viewB["Process B: 0x000002700000 + 0x3000"] --> offset
offset --> pfnX["同一個實體分頁 PFN X"]
圖 1:被共用的是區段內的內容與實體分頁,不是虛擬位址。
不該把原始指標存進共用記憶體的理由就在這裡。處理程序 A 的指標值,在處理程序 B 裡可能是一個毫不相干的位址。
共用結構請使用從檢視開頭起算的位移、固定寬度整數,以及明確標示的版本與對齊方式。Microsoft 的 MapViewOfFileEx 文件也建議存放相對於基底的位移而不是指標,因為無法保證同一個位址將來仍可使用。6
3. 檔案後盾與分頁檔後盾
區段依照「內容從哪裡復原」,大致分成兩種。
flowchart TB
accTitle: 檔案後盾與分頁檔後盾的分法
accDescr: 把實際檔案的控制代碼交給 CreateFileMapping 就會得到檔案後盾區段,clean 的分頁可以從原始檔重新讀入。傳入 INVALID_HANDLE_VALUE 則得到分頁檔後盾區段,內容由分頁檔撐住,物件銷毀後就消失
create["CreateFileMapping"] -->|傳入實際檔案的控制代碼| fileBacked["檔案後盾區段"]
create -->|傳入 INVALID_HANDLE_VALUE| pfBacked["分頁檔後盾區段"]
fileBacked --> restore1["clean 的分頁可以從原始檔重新讀入"]
pfBacked --> restore2["內容由分頁檔撐住,銷毀後就消失"]
圖 2:後盾儲存的差異,決定了內容能從哪裡復原,以及它的壽命。
3.1. 檔案後盾區段
把實際的檔案交給 CreateFileMapping,得到的就是檔案後盾區段。
- 唯讀檢視會從檔案讀取需要的分頁。
- 讀寫檢視的變更,會被當成那個檔案的資料。
- CoW 檢視的變更不會寫進原始檔,而是變成私有分頁。
檔案後盾的分頁只要是 clean 的,就算丟掉實體分頁也能從原始檔重新讀入。這個性質,正撐起了第2回看過的 Standby 與檔案快取的效率。
3.2. 分頁檔後盾區段
把 INVALID_HANDLE_VALUE 傳給 CreateFileMapping 的 hFile 並指定大小,得到的就是分頁檔後盾區段。
HANDLE mapping = CreateFileMappingW(
INVALID_HANDLE_VALUE,
nullptr,
PAGE_READWRITE,
0,
64 * 1024,
L"Local\\KomuraMemoryDemo");
這種區段沒有明確的資料檔,內容由分頁檔撐住。初始內容是零。要讓多個處理程序使用同一個物件,可以用名稱、控制代碼繼承或 DuplicateHandle 等方式。93
對共用分頁的變更看得到,但那不是永久保存。 對應同一份共用分頁的處理程序都能看到變更。另一方面,區段物件一旦銷毀,內容也不會留下,因此不適合拿來取代永久檔案。2
要注意的是,共用記憶體並不會自動附帶互斥控制。mutex、semaphore、event 或 lock-free 協定等,都要另外設計。9
4. 映像對應與資料對應
說「EXE 與 DLL 也是檔案對應」的時候,還是得把它和一般資料檔的差異保留下來。
| 項目 | 映像對應 | 資料對應 |
|---|---|---|
| 主要用途 | 載入 EXE、DLL | 一般檔案、共用資料 |
| 建立時的屬性 | SEC_IMAGE |
PAGE_READONLY、PAGE_READWRITE 等 |
| 分頁保護 | 由 PE 映像內的屬性決定 | 由對應與檢視的指定決定 |
| 寫入 | 可能因 writable section 或 CoW 而私有化 | 可選擇 shared write 或 CoW |
VirtualQuery 的 Type |
MEM_IMAGE |
MEM_MAPPED |
使用 SEC_IMAGE 時,決定檢視分頁保護的不是傳給 CreateFileMapping 的一般保護值,而是執行映像本身的區段屬性。3
4.1. 並不是整份 DLL 都一定會共用
像程式碼這種不會被修改的分頁,可以讓大量處理程序共用同一個實體分頁。需要做處理程序專屬變更的分頁,則會以 CoW 分岔出去。
不過,ASLR 造成的重新配置、載入器的修正、熱修補(hot patch),以及實際的 PE 區段屬性都會有影響。就算載入的是同一份 DLL,也不代表每一頁都一定會共用。
重要的是這樣的設計:先共用可以共用的分頁,只對需要變更的分頁做延遲複製。
5. 寫入時複製的來龍去脈
我們從兩個處理程序讀取同一個 CoW 分頁的狀態出發,一路追到只有 Process A 寫入 1 個位元組為止。
5.1. 寫入之前
在寫入發生之前,兩個處理程序的 PTE 在概念上都會到達同一個共用分頁,讀取也就直接成功。
flowchart LR
accTitle: 寫入時複製之前的共用狀態
accDescr: 在寫入發生之前,處理程序 A 與處理程序 B 的 PTE 都會到達同一個共用分頁 PFN X,讀取可以直接成功
pteA["Process A PTE"] --> pfnX["共用 PFN X(read / copy-on-write)"]
pteB["Process B PTE"] --> pfnX
圖 3:寫入之前,兩個處理程序的 PTE 指向同一個實體分頁。
5.2. 寫入引發保護錯誤
CoW 分頁從一開始就不是普通的共用可寫分頁。當 Process A 想要寫入時,CPU 會產生保護錯誤。接手控制權的記憶體管理員會判定,這不是非法寫入,而是對帶有 CoW 屬性的分頁所做的寫入。
5.3. 建立新的實體分頁
判定之後,Windows 會做下面幾件事。
- 取得一個要給 Process A 用的實體分頁。
- 把 PFN X 的內容複製到新分頁 PFN Y。
- 把 Process A 的 PTE 抽換成指向 PFN Y。
- 把 Process A 這一側的保護改成一般的讀寫。
- 重新執行剛才失敗的那道寫入指令。
flowchart LR
accTitle: 寫入時複製之後的分岔狀態
accDescr: 因應處理程序 A 的寫入,只有處理程序 A 的 PTE 被抽換到已複製內容的私有分頁 PFN Y,處理程序 B 的 PTE 則繼續指向原本的共用分頁 PFN X
pteA2["Process A PTE"] --> pfnY["私有 PFN Y(read/write,變更後)"]
pteB2["Process B PTE"] --> pfnX2["共用 PFN X(原本的內容)"]
pfnX2 -.->|寫入時複製內容| pfnY
圖 4:只有寫入的那個處理程序的 PTE 被抽換到新的私有分頁,另一邊則繼續讀原本的內容。
Process B 會繼續讀原本的內容,看不到 Process A 的變更。這就是 Copy-on-Write。DLL 的共用與 FILE_MAP_COPY,用的都是不寫就不複製這同一個原理。56
5.4. 與 FILE_MAP_WRITE 的差異
選擇的標準是:你想讓對方也看到這次寫入,還是想讓變更只留在自己這邊。6
| 檢視 | 寫入之後會發生的事 |
|---|---|
FILE_MAP_WRITE |
以共用寫入處理,使用同一個檔案對應的其他檢視也看得到變更 |
FILE_MAP_COPY |
只有被寫入的分頁變成該處理程序專用。變更不會寫回原始檔,解除檢視後就消失 |
是「想用共用記憶體把更新傳出去」,還是「想讓各處理程序從共同的初始資料出發,各自做私有變更」?目的不同,選擇就完全相反。
6. CoW 之後仍是 MEM_MAPPED / MEM_IMAGE
這一節要把區域的來歷,和目前實體分頁的共用狀態分開來看。
CoW 之後的分頁在實體上是 Private,但 VirtualQuery 的 Type 不會變成 MEM_PRIVATE。資料檢視仍然是 MEM_MAPPED,執行映像仍然是 MEM_IMAGE。因為 Type 表示的是這塊區域源自哪一種初始配置。7
想以分頁為單位判斷是否已經 CoW,可以照下面的步驟做。
- 存取目標分頁,讓它常駐。
- 用
QueryWorkingSetEx取得該分頁的 Working Set 資訊。 - 看
Shared位元。 - 如果
Shared == 0,那個常駐分頁就是 Private。
用 VMMap 確認時也一樣,不要只看區域的 Type,還要看 Working Set 的 Private/Shareable 明細。
6.1. Private Bytes 也可能不會增加
FILE_MAP_COPY 只會把被寫入的分頁私有化。不過,收取 Commit 的時間點是另一回事。
為了因應將來可能寫入檢視內每一頁的情況,Windows 會在對應的當下就收取相當於整個檢視的 Commit charge。6 因此,寫入第一頁的那一瞬間,Private Bytes 未必就會增加 4KiB。
觀測 CoW 時該優先採用的指標如下。
QueryWorkingSetEx的 Shared 位元- VMMap 的 Private WS / Shareable WS
- RAMMap 的實體分頁資訊
- Private Bytes 只是輔助資訊
如果只拿「寫入的瞬間 Private Bytes 有沒有增加」當成合格與否的判準,就會漏看正常運作中的 CoW。
7. 與 Cache Manager 的接點 ── 分開三條路徑
說「載入 EXE 與 DLL、檔案快取、共用記憶體全都是區段」,確實能抓住整體樣貌,但不能把實作壓成同一個物件。
檔案串流上有一個供記憶體管理員與 Cache Manager 使用的 SECTION_OBJECT_POINTERS。
typedef struct _SECTION_OBJECT_POINTERS {
PVOID DataSectionObject;
PVOID SharedCacheMap;
PVOID ImageSectionObject;
} SECTION_OBJECT_POINTERS;
這個結構把檔案物件接到檔案串流的區段,用來追蹤記憶體上的內容與快取資訊。4 不過,各欄位指向的狀態與處理路徑並不相同。
| 路徑 | 對應的欄位 | 處理上的角色 |
|---|---|---|
cached ReadFile / WriteFile |
SharedCacheMap |
Cache Manager 使用快取檢視 |
| 資料檔對應的分頁錯誤 | DataSectionObject |
記憶體管理員在資料區段這一側處理,並與同一檔案串流上的 cached I/O 協調,以維持內容一致 |
| EXE/DLL 的映像分頁錯誤 | ImageSectionObject |
記憶體管理員以映像區段與分頁 I/O 處理。這不是經過 SharedCacheMap 的路徑 |
與 I/O 系列的接點,就在於這三者被綁在同一個檔案串流上。但不能把它讀成「每一條路徑都會經過 Cache Manager」。
flowchart TB
accTitle: 綁在同一個檔案串流上的三條路徑
accDescr: cached ReadFile/WriteFile 使用 SharedCacheMap,資料對應的分頁錯誤使用 DataSectionObject,EXE/DLL 的映像分頁錯誤使用 ImageSectionObject,三者透過 SECTION_OBJECT_POINTERS 綁在同一個檔案串流上
cached["cached ReadFile / WriteFile"] --> scm["SharedCacheMap"]
dataFault["資料對應的分頁錯誤"] --> dso["DataSectionObject"]
imageFault["EXE/DLL 的映像分頁錯誤"] --> iso["ImageSectionObject"]
scm --> stream["同一個檔案串流(SECTION_OBJECT_POINTERS)"]
dso --> stream
iso --> stream
圖 5:三條路徑各自分開處理,但綁在同一個檔案串流上。
三者的共通點不是「全都進到 Cache Manager」,而是同一個檔案串流透過 SECTION_OBJECT_POINTERS,把快取、資料區段、映像區段這些各自獨立的狀態綁在一起。4 快取讀寫、Lazy Writer 以及 Cc/Mm 之間的關係,在「Windows I/O 的深層(第4回)── 快取管理員與 WriteFile」裡有說明。
另外,把記憶體對應檢視和 ReadFile/WriteFile 混用時,並不保證隨時都看得到同一瞬間的內容。設計時必須把同步、flush 與檔案共用模式一併考慮進去。36
8. 物件與檢視的生命週期
關閉控制代碼和解除檢視是兩回事。 只關掉 CreateFileMapping 的控制代碼,既有的檢視並不會消失。
檢視持有對區段的內部參照。要把所有檢視都 UnmapViewOfFile、所有控制代碼都 CloseHandle,物件才會進入可以銷毀的狀態。3
UnmapViewOfFile(view);
CloseHandle(mapping);
CloseHandle(file);
這種生命週期的分離,正是「明明關掉檔案了卻還在使用中」這個現象的原因。就算關掉檔案控制代碼,只要映像區段或資料檢視還參照著檔案串流,檔案最終的 close 就會往後延。
與 I/O 這一側 cleanup/close 的關係,請參考「Windows I/O 的深層(第1回)」;實作上的陷阱則請參考「共用記憶體的陷阱與實務最佳做法」。
9. 自己親眼確認
9.1. 用兩個處理程序看同一份 DLL
先用現有的處理程序確認 DLL 的共用。
- 以系統管理員身分啟動 Process Explorer。
- 啟動兩個
cmd.exe。 - 選擇 View > Lower Pane View > DLLs。
- 在兩個處理程序中確認同一份 DLL 的路徑與對應。
- 用 VMMap 分別開啟各個
cmd.exe,比較 Images 的 Working Set、Private 與 Shareable。
光是看到同一份 DLL,還無法證明 PFN 一致
在 Process Explorer 裡看到同一份 DLL,可以證明兩邊對應的是同一個映像。但光憑這一點,還無法證明每個分頁的 PFN 都一致。請結合 VMMap 的 Shareable 明細、RAMMap 與 QueryWorkingSetEx,以分頁為單位確認共用狀況。Process Explorer 與 VMMap 由 Sysinternals 提供。1011
9.2. 用兩個處理程序觀察 FILE_MAP_COPY
下面這支程式會把同一個檔案對應成 CoW 檢視,並顯示 QueryWorkingSetEx 的 Shared 位元。檔案對應物件是以 PAGE_READONLY 建立的,但這個保護與 FILE_MAP_COPY 的檢視相容,檢視這一側的第一次寫入就會引發 CoW。3
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <psapi.h>
#include <cstdio>
#include <cwchar>
#pragma comment(lib, "Psapi.lib")
void PrintPage(const char* stage, void* address)
{
MEMORY_BASIC_INFORMATION mbi{};
if (!VirtualQuery(address, &mbi, sizeof(mbi))) {
std::printf("VirtualQuery failed: %lu\n", GetLastError());
return;
}
for (int attempt = 0; attempt < 3; ++attempt) {
// The page may have been trimmed while the user was waiting.
// Touch it immediately before querying the working-set attributes.
volatile unsigned char resident =
*static_cast<volatile unsigned char*>(address);
(void)resident;
PSAPI_WORKING_SET_EX_INFORMATION ws{};
ws.VirtualAddress = address;
if (!QueryWorkingSetEx(GetCurrentProcess(), &ws, sizeof(ws))) {
std::printf("QueryWorkingSetEx failed: %lu\n", GetLastError());
return;
}
if (!ws.VirtualAttributes.Valid) {
Sleep(0);
continue;
}
std::printf(
"%s: Type=0x%lx Valid=1 Shared=%llu ShareCount=%llu\n",
stage,
static_cast<unsigned long>(mbi.Type),
static_cast<unsigned long long>(ws.VirtualAttributes.Shared),
static_cast<unsigned long long>(ws.VirtualAttributes.ShareCount));
return;
}
std::printf(
"%s: page is not resident; Shared/ShareCount were not interpreted\n",
stage);
}
int wmain(int argc, wchar_t** argv)
{
if (argc != 3) {
std::fwprintf(stderr, L"usage: cow_demo <file> <read|write>\n");
return 2;
}
HANDLE file = CreateFileW(
argv[1], GENERIC_READ, FILE_SHARE_READ,
nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
if (file == INVALID_HANDLE_VALUE) return 3;
HANDLE mapping = CreateFileMappingW(
file, nullptr, PAGE_READONLY, 0, 0, nullptr);
if (!mapping) {
CloseHandle(file);
return 4;
}
auto* view = static_cast<unsigned char*>(
MapViewOfFile(mapping, FILE_MAP_COPY, 0, 0, 0));
if (!view) {
CloseHandle(mapping);
CloseHandle(file);
return 5;
}
volatile unsigned char value = view[0];
(void)value;
std::puts("Start the other process. When both are waiting, press Enter...");
(void)std::getchar();
PrintPage("before", view);
if (std::wcscmp(argv[2], L"write") == 0) {
std::puts("Press Enter to trigger copy-on-write...");
(void)std::getchar();
view[0] ^= 0x5a;
PrintPage("after write", view);
} else {
std::puts("After the writer changes its page, press Enter...");
(void)std::getchar();
PrintPage("reader after peer write", view);
}
std::puts("Press Enter to exit...");
(void)std::getchar();
UnmapViewOfFile(view);
CloseHandle(mapping);
CloseHandle(file);
}
建置之後,用兩個處理程序開啟同一個檔案
先進行建置與準備。
cl /std:c++20 /EHsc /W4 cow_demo.cpp
$path = "$env:TEMP\\cow-demo.bin"
[IO.File]::WriteAllBytes($path, [byte[]]::new(65536))
接著在兩個主控台開啟同一個檔案。
.\\cow_demo.exe "$env:TEMP\\cow-demo.bin" read
.\\cow_demo.exe "$env:TEMP\\cow-demo.bin" write
分清按 Enter 的順序,以及要看的值
| 順序 | 操作 | 要確認的事 |
|---|---|---|
| 1 | 兩邊都啟動,先在 read 那一側按 Enter,再按 write 那一側 | 兩邊的 before 都顯示 Shared 已設立 |
| 2 | 在 write 那一側再按一次 Enter | 寫入的那個處理程序,其分頁的 Shared 會變成 0 |
| 3 | 之後在 read 那一側按 Enter | reader 這一側仍繼續讀原本的分頁 |
VirtualQuery 的 Type 在寫入之後仍然是 MEM_MAPPED。請把共用狀態的變化和 Type 的值分開確認。
只解讀 Valid 成立的結果
另外,PrintPage 會在查詢前重新碰一次目標分頁;若 Valid == 0,就不解讀 Shared 與 ShareCount,最多重試三次。如果還是沒有常駐,它就不輸出結果,而是顯示這個情況。ShareCount 會因時機與記憶體壓力而改變,因此請不要看固定值,而要看在 Valid == 1 之下確認到的 Shared 位元變化。
10. 實務上要避開的五種誤讀
10.1. 「共用記憶體會落在同一個虛擬位址」
被共用的是區段與實體分頁。檢視的虛擬位址會因處理程序而異,所以要存位移而不是原始指標。
10.2. 「同一份 DLL 就代表每一頁都一定共用」
clean 的程式碼分頁固然容易共用,但重新配置、writable section、CoW,以及量測當下的常駐狀態,都會產生 Private 分頁。
10.3. 「CoW 之後就會變成 MEM_PRIVATE」
VirtualQuery 的 Type 仍然是 MEM_MAPPED 或 MEM_IMAGE。實際的共用狀態要用 QueryWorkingSetEx 確認。7
10.4. 「Private Bytes 沒增加,就代表沒有發生 CoW」
FILE_MAP_COPY 會先對整個檢視收取 Commit。請優先看 Private WS 與 Shared 位元。6
10.5. 「分頁已經共用,就不需要同步」
看得到同一個實體分頁,和能從多個 CPU 核心安全地更新它,是兩個不同的問題。請把原子性、記憶體順序、互斥、當機時的中間狀態,以及版本相容性都設計進去。
把參照和生命週期分開追蹤的想法,同樣通到 Excel COM 互通時處理程序殘留的問題。請一併參考「Excel COM 互通時處理程序殘留的原因」。
11. 總結
- 區段物件代表一段可共用的記憶體範圍,各處理程序把它當成檢視對應到自己的虛擬空間。1
- 同一區段的同一個位移,會從不同的虛擬位址對應到同一個實體分頁。2
- 檔案後盾區段撐住實際的檔案,分頁檔後盾區段則撐住具名共用記憶體等用途。9
- EXE/DLL 被當成映像區段,一般檔案被當成資料區段,兩者的保護與回寫目的地都不同。3
- CoW 在讀取期間共用實體分頁,第一次寫入時只複製該分頁並抽換 PTE。5
- CoW 之後
VirtualQuery仍會回傳MEM_MAPPED/MEM_IMAGE,所以要用QueryWorkingSetEx的 Shared 位元確認。7 - 使用
FILE_MAP_COPY時會先收取整個檢視的 Commit,因此無法只靠 Private Bytes 判斷 CoW。6 - Cache Manager 的 cached I/O、資料對應與映像對應,分別以
SharedCacheMap、DataSectionObject、ImageSectionObject作為各自獨立的路徑,綁在同一個檔案串流上。4 - 要把檢視與控制代碼的生命週期、同步、ACL 乃至位移設計都納入,共用記憶體才算安全。
至此,「Windows 記憶體的深層」全 3 回就完結了。Reserve/Commit 虛擬位址,透過分頁錯誤取得實體分頁,從 Working Set 移到各個分頁清單,經由區段共用,最後只讓寫過的分頁以 CoW 分岔──Windows 的記憶體管理,就是由這一連串流程串起來的。
相關文章
- Windows 記憶體的深層(第1回)── 虛擬位址變成實體 RAM 的那一刻
- Windows 記憶體的深層(第2回)── 實體分頁的一生
- Windows I/O 的深層(第4回)── 快取管理員與 WriteFile
- 共用記憶體的陷阱與實務最佳做法
- Excel COM 互通時處理程序殘留的原因
- Process Explorer / Handle / VMMap 實戰
相關諮詢領域
小村軟體有限公司承接 Windows 應用程式的共用記憶體、檔案對應、DLL 載入、檔案鎖定、處理程序間通訊,以及記憶體使用量的問題調查。
參考連結
-
Microsoft Learn, Section Objects and Views. 關於區段物件代表一段可共用的記憶體範圍,以及各處理程序把區段的一部分對應成檢視。 ↩ ↩2 ↩3
-
Microsoft Learn, File-Backed and Page-File-Backed Sections. 關於檔案後盾/分頁檔後盾區段、CoW,以及可以從不同處理程序的虛擬位址共用同一塊實體記憶體。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, CreateFileMappingW function. 關於檔案對應物件、分頁檔後盾、
SEC_IMAGE、檢視與控制代碼的生命週期,以及撐住同一個檔案的各檢視之間的一致性。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 -
Microsoft Learn, SECTION_OBJECT_POINTERS structure. 關於 DataSectionObject、SharedCacheMap、ImageSectionObject 把檔案串流的對應與快取資訊接到記憶體管理員/Cache Manager。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Memory Protection. 關於多個處理程序共用同一份 DLL 的實體分頁,以及其中一方寫入時複製到新實體分頁並更新 PTE 的 CoW。 ↩ ↩2 ↩3
-
Microsoft Learn, MapViewOfFileEx function. 關於
FILE_MAP_COPY的 CoW、私有分頁由分頁檔撐住、對整個檢視收取 Commit charge,以及應該存放位移而不是虛擬位址。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 -
Microsoft Learn, VirtualQuery function. 關於 CoW 之後 Type 仍然是
MEM_MAPPED/MEM_IMAGE,以及可以用QueryWorkingSetEx的 Shared 位元確認私有化。 ↩ ↩2 ↩3 ↩4 -
Microsoft Learn, Managing Memory Sections. 關於在檢視被存取之前不會配置實體記憶體,以及第一次存取的分頁錯誤會讀入檔案內容。 ↩
-
Microsoft Learn, Sharing Files and Memory. 關於以名稱或控制代碼共用同一個檔案對應物件、以
INVALID_HANDLE_VALUE建立分頁檔後盾的共用記憶體,以及同步必須另外設計。 ↩ ↩2 ↩3 -
Microsoft Learn, Process Explorer - Sysinternals. 關於 Process Explorer 可以顯示處理程序的控制代碼與已載入的 DLL/記憶體對應檔。 ↩
-
Microsoft Learn, VMMap - Sysinternals. 關於 VMMap 把處理程序的虛擬記憶體分解成 Image、Mapped File、Private 等,並顯示 Working Set 的 Private/Shareable 明細。 ↩
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
DllMain 與載入器鎖定 ── 「DLL 初始化時什麼都別做」真正的理由
為什麼不能在 DllMain 裡呼叫 LoadLibrary 或與其他執行緒同步?本文依據一次資料,從序列化所有 DLL 通知的載入器鎖定機制,一路說明到死結成立的典型情境、延遲初始化等正確設計,以及無回應的調查步驟。
Windows 記憶體的深層(第2回) ── 實體分頁的一生:五份清單與分頁檔的真相
串起 PFN 資料庫、Standby、Modified、記憶體壓縮與分頁檔,說明離開 Working Set 的實體分頁會去哪裡。
Windows 記憶體的深層(第 1 回) ── 虛擬位址變成實體 RAM 的瞬間:分頁錯誤的來龍去脈
串起 VirtualAlloc、VAD、分頁表、TLB、demand-zero 與硬性錯誤,說明虛擬位址被指派實體 RAM 的那一瞬間。
Windows 的「記憶體使用量」代表什麼 ── 正確解讀 Working Set、Private Bytes、Commit 與分頁檔
工作管理員的記憶體、Working Set、Private Bytes、Commit 並非同一個數值。本文解說 Windows 虛擬記憶體與實體記憶體的關係、分頁檔的角色,以及在記憶體不足或洩漏調查時應該檢視的指標。
Windows 的 DLL 名稱解析機制 - 以實務角度整理搜尋順序、Known DLLs、API set、SxS
從實務角度整理 Windows 的 DLL 名稱解析,說明 loader 在掃描檔案系統前會先處理 DLL redirection、API set、SxS、Known DLLs,並用 SetDefaultDllDirectories 與 LoadLibraryEx 旗標縮小...
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
常見問題
整理諮詢這個主題時常見的問題。
- 使用同一份 DLL 的每個處理程序,都會把整份 DLL 複製到 RAM 嗎?
- 通常不會複製。同一映像中未被修改的分頁,會從各處理程序不同的虛擬位址對應到同一個實體分頁。只有需要寫入的分頁,才會透過寫入時複製等機制變成該處理程序專屬的實體分頁。
- CreateFileMapping 會在呼叫的當下就為處理程序配置記憶體嗎?
- CreateFileMapping 建立的是檔案對應物件,讓它出現在處理程序虛擬空間中的則是 MapViewOfFile。而且檢視的實體分頁通常要從第一次存取的那一頁開始,透過分頁錯誤才會真正具體化。
- FILE_MAP_WRITE 和 FILE_MAP_COPY 有什麼不同?
- FILE_MAP_WRITE 的變更是會套用到共用檔案資料那一側的寫入。FILE_MAP_COPY 會共用初始分頁,但只有被寫入的分頁會變成該處理程序專用的複本,變更不會寫回原始檔,解除檢視後就會消失。
- 寫入時複製之後,VirtualQuery 會回傳 MEM_PRIVATE 嗎?
- 不會。資料檢視仍然是 MEM_MAPPED,映像檢視仍然是 MEM_IMAGE。要確認分頁是否真的被私有化,可靠的做法是先讓該分頁常駐,再看 QueryWorkingSetEx 的 Shared 位元。
- 可以把原始指標存進共用記憶體嗎?
- 通常要避免。即使是同一個區段,也不保證各處理程序的檢視會被放到同一個虛擬位址。共用結構請使用相對於基底的位移、固定寬度整數,以及明確定義的配置方式與同步方式。