ความลึกของหน่วยความจำ Windows (ตอนที่ 3) — ออบเจ็กต์เซกชันและคัดลอกเมื่อเขียน: DLL และการแมปไฟล์คืออะไรจริง ๆ

· · Windows, การจัดการหน่วยความจำ, หน่วยความจำร่วม, การแมปไฟล์, คัดลอกเมื่อเขียน, DLL, Cache Manager

ในบทความก่อน 「ความลึกของหน่วยความจำ Windows (ตอนที่ 2) — ชีวิตของเพจกายภาพ」 เราตามว่าเพจกายภาพที่ออกจาก Working Set เคลื่อนผ่าน Modified, Standby, Free และ Zeroed อย่างไร Standby นั้นยังเก็บเพจจาก DLL, EXE, ไฟล์ที่แมป และแคชไฟล์

คำถามเกิดขึ้น เมื่อ 100 โพรเซสใช้ kernel32.dll เดียวกัน Windows ใส่ชุดเพจโค้ด 100 ชุดใน RAM หรือไม่ เมื่อสองโพรเซสแมปไฟล์เดียวกันเข้าหน่วยความจำ อีกฝ่ายใช้เพจที่ฝ่ายหนึ่งอ่านแล้วได้หรือไม่

คำตอบคือ แมปหลายที่อยู่เสมือนไปยังเพจกายภาพเดียวกัน ศูนย์กลางที่แทนหน่วยการแบ่งใช้นั้นคือออบเจ็กต์เซกชัน และกลไกที่แยกการแบ่งใช้เฉพาะตอนเขียนคือคัดลอกเมื่อเขียน (Copy-on-Write, CoW)

บทความนี้ตามว่า EXE กับ DLL, ไฟล์ข้อมูล, หน่วยความจำร่วมที่ backed ด้วยเพจไฟล์ และแคชไฟล์ เกาะกับสตรีมไฟล์เดียวกันอย่างไร และเส้นทางประมวลผลแยกที่ไหน การอ่านตัวเลขเองยึดบทนำ “What Does Windows’ "Memory Usage" Actually Mean?” เป็นฐาน

「ความลึกของหน่วยความจำ Windows」 ทั้ง 3 ตอน

  1. ตอนที่ 1: ที่อยู่เสมือนและเพจฟอลต์
    ตามช่วงที่เพจเสมือนที่ Commit แล้วได้ RAM กายภาพ
  2. ตอนที่ 2: ชีวิตของเพจกายภาพ
    ตามการเปลี่ยนสถานะของเพจที่ออกจาก Working Set
  3. ตอนที่ 3 (บทความนี้): ออบเจ็กต์เซกชันและคัดลอกเมื่อเขียน
    ตามกลไกที่ DLL การแมปไฟล์ และหน่วยความจำร่วมใช้เพจกายภาพร่วมกัน

คำถามที่ตอนที่ 3 ตอบมีเพียงข้อเดียว

ทำไมหลายโพรเซสจึงใช้ DLL หรือไฟล์เดียวกันเป็นชุดเพจกายภาพชุดเดียวได้

ผู้อ่านเป้าหมาย คือนักพัฒนาและผู้ดูแลที่ต้องการเข้าใจการแบ่งใช้ DLL, CreateFileMapping, MapViewOfFile, หน่วยความจำร่วม, CoW และความสัมพันธ์กับแคชไฟล์ ไม่ใช่แค่การเรียก API แต่จากโครงสร้างภายใน สภาพแวดล้อมที่ต้องมี คือ Windows 10/11 หรือ Windows Server ปัจจุบัน และ พื้นความรู้ที่ต้องมี คือพื้นฐานของที่อยู่เสมือน เพจฟอลต์ และ Working Set ระดับความยากคือระดับกลาง เราใช้ศัพท์ภายในอย่าง Control Area และ Prototype PTE ด้วย แต่การสังเกตทำซ้ำได้ด้วย VMMap, Process Explorer และ QueryWorkingSetEx

1. สรุปก่อน

เริ่มจากภาพรวม

  • ออบเจ็กต์เซกชันแทนช่วงหน่วยความจำที่แบ่งใช้ได้
    แต่ละโพรเซสแมปส่วนหนึ่งของเซกชันนั้นเข้าพื้นที่เสมือนของตนเป็น 「วิว」1
  • วิวของเซกชันเดียวกันไม่จำเป็นต้องอยู่ที่อยู่เสมือนเดียวกัน
    0x000001... ของโพรเซส A และ 0x000002... ของโพรเซส B ชี้ไปยังออฟเซ็ตเซกชันเดียวกันและเพจกายภาพเดียวกันได้2
  • มีเซกชันที่ backed ด้วยไฟล์ และที่ backed ด้วยเพจไฟล์
    แบบแรกใช้ไฟล์จริง แบบหลังใช้กับหน่วยความจำร่วมที่ไม่มีไฟล์ชัดเจน เป็นต้น2
  • การโหลด EXE/DLL เป็นเซกชันอิมเมจ การแมปไฟล์ทั่วไปเป็นเซกชันข้อมูล
    ด้วย SEC_IMAGE คุณสมบัติเซกชันใน PE กำหนดการป้องกันเพจ3
  • ระหว่างอ่าน เพจกายภาพเดียวกันแบ่งใช้ได้
    เมื่อฝ่ายหนึ่งเขียนเพจ CoW จะคัดลอกเฉพาะเพจนั้นแล้วสลับ PTE ของโพรเซสที่เขียน4
  • แม้บนสตรีมไฟล์เดียวกัน เส้นทางแคช ข้อมูล และอิมเมจก็แยกกัน
    I/O แคชของ Cache Manager ใช้ SharedCacheMap การแมปข้อมูลใช้ DataSectionObject และ EXE/DLL ใช้ ImageSectionObject ทั้งสามเกาะสตรีมไฟล์เดียวกันผ่าน SECTION_OBJECT_POINTERS แต่ฟอลต์อิมเมจไม่ผ่าน Cache Manager5
  • Private Bytes อย่างเดียวยืนยันไม่ได้ว่าเกิด CoW
    FILE_MAP_COPY คิด Commit ทั้งวิวล่วงหน้า เผื่อทุกเพจจะถูกทำให้เป็นของส่วนตัวในภายหลัง การตรวจรายเพจให้ใช้บิต Shared ของ QueryWorkingSetEx67

ประโยคเดียว: สิ่งที่แบ่งใช้ไม่ใช่ที่อยู่เสมือน แต่เป็นเนื้อหาในเซกชันและเพจกายภาพที่สอดคล้อง ณ ขณะนั้น

2. ออบเจ็กต์เซกชันและวิว

ตามนิยามของ Microsoft ออบเจ็กต์เซกชันแทนบริเวณหน่วยความจำที่แบ่งใช้ได้ และยังเป็นกลไกที่แมปไฟล์เข้าพื้นที่ที่อยู่ของโพรเซส1

เคล็ดในการเข้าใจคือแยกเซกชันเองออกจากวิวที่แต่ละโพรเซสเห็น

แนวคิด บทบาท
ออบเจ็กต์เซกชัน แทนเนื้อหาที่จะแบ่งใช้ ขนาด backing store และเพดานการป้องกัน
วิว แสดงส่วนหนึ่งของเซกชันเป็นช่วงที่อยู่เสมือนในโพรเซสใดโพรเซสหนึ่ง
PTE ผูกแต่ละเพจเสมือนในวิวกับเพจกายภาพปัจจุบันหรือสถานะที่ยังไม่เกิดจริง
PFN แทนเพจกายภาพที่มีจริงใน RAM

ใน Win32 CreateFileMapping คืนแฮนเดิลไปยังออบเจ็กต์แมปไฟล์ และ MapViewOfFile สร้างวิวในพื้นที่เสมือนของโพรเซส3

HANDLE mapping = CreateFileMappingW(
    file,
    nullptr,
    PAGE_READONLY,
    0,
    0,
    nullptr);

void* view = MapViewOfFile(
    mapping,
    FILE_MAP_READ,
    0,
    0,
    0);

เรียก CreateFileMapping อย่างเดียวยังไม่ได้ที่อยู่ที่โพรเซสอ่านได้ ยิ่งไปกว่านั้น การสร้างวิวไม่ได้ใส่ทุกเพจเข้า RAM ทันที จากเพจแรกที่แตะ เพจฟอลต์อ่านเนื้อหาไฟล์แล้วผูกเพจกายภาพเข้า PTE8 กล่าวคือ demand paging ในตอนที่ 1 ใช้กับวิวเซกชันด้วย

2.1. เซกชันเดียวกัน ที่อยู่เสมือนต่างกัน

แม้โพรเซส A กับโพรเซส B แมปออฟเซ็ตเดียวกันของเซกชันเดียวกัน ที่อยู่เริ่มต้นของวิวก็ต่างกันได้

แมปที่อยู่เสมือนต่างกันไปยังเพจกายภาพเดียวกันโพรเซส A และโพรเซส B แต่ละตัวมีวิวที่ที่อยู่เสมือนต่างกัน แต่ไปถึงเพจกายภาพ PFN X เดียวกันผ่านออฟเซ็ตเซกชันเดียวกันโพรเซส A: 0x000001A00000 + 0x3000ออฟเซ็ตเซกชัน 0x3000โพรเซส B: 0x000002700000 + 0x3000เพจกายภาพเดียวกัน PFN X

ภาพ 1: สิ่งที่แบ่งใช้คือเนื้อหาในเซกชันและเพจกายภาพ ไม่ใช่ที่อยู่เสมือน

จึงห้ามเก็บพอยน์เตอร์ดิบในหน่วยความจำร่วม ค่าพอยน์เตอร์ของโพรเซส A อาจเป็นที่อยู่ที่ไม่เกี่ยวข้องในโพรเซส B

ในโครงสร้างร่วม ใช้ออฟเซ็ตจากต้นวิว จำนวนเต็มความกว้างคงที่ และเวอร์ชันกับการจัดแนวที่ระบุชัด เอกสาร MapViewOfFileEx ของ Microsoft ก็แนะนำให้เก็บออฟเซ็ตจากฐานแทนพอยน์เตอร์ เพราะไม่มีการรับประกันว่าที่อยู่เดิมจะใช้ได้ในอนาคต6

3. Backed ด้วยไฟล์และ backed ด้วยเพจไฟล์

เซกชันแยกเป็นสองชนิดใหญ่ตามว่าเนื้อหาคืนจากที่ใดได้

จุดที่ file-backed กับ pagefile-backed แยกกันส่งไฟล์จริงให้ CreateFileMapping ได้เซกชันที่ backed ด้วยไฟล์ และเพจที่สะอาดอ่านใหม่จากไฟล์ต้นได้ ส่ง INVALID_HANDLE_VALUE ได้เซกชันที่ backed ด้วยเพจไฟล์ เพจไฟล์รองรับเนื้อหา ซึ่งหายเมื่อทำลายออบเจ็กต์ส่งแฮนเดิลไฟล์จริงส่ง INVALID_HANDLE_VALUECreateFileMappingเซกชันที่ backed ด้วยไฟล์เซกชันที่ backed ด้วยเพจไฟล์เพจสะอาดอ่านใหม่จากไฟล์ต้นได้เพจไฟล์รองรับเนื้อหา ซึ่งหายเมื่อทำลาย

ภาพ 2: ความต่างของ backing store ตัดสินว่าเนื้อหาคืนจากไหนและอยู่ได้นานเท่าใด

3.1. เซกชันที่ backed ด้วยไฟล์

ส่งไฟล์จริงให้ CreateFileMapping ได้เซกชันที่ backed ด้วยไฟล์

  • วิวอ่านอย่างเดียวอ่านเพจที่ต้องการจากไฟล์
  • การเปลี่ยนแปลงของวิวอ่าน/เขียนถูกถือเป็นข้อมูลของไฟล์นั้น
  • การเปลี่ยนแปลงของวิว CoW ไม่ถูกเขียนลงไฟล์ต้น มันกลายเป็นเพจส่วนตัว

ถ้าเพจที่ backed ด้วยไฟล์สะอาด สามารถทิ้งเพจกายภาพแล้วอ่านใหม่จากไฟล์ต้นได้ คุณสมบัตินี้รองรับประสิทธิภาพของ Standby และแคชไฟล์ในตอนที่ 2

3.2. เซกชันที่ backed ด้วยเพจไฟล์

ส่ง INVALID_HANDLE_VALUE เป็น hFile ของ CreateFileMapping และระบุขนาด จะได้เซกชันที่ backed ด้วยเพจไฟล์

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 ตัดสิน ข้อกำหนดการแมปและวิวตัดสิน
การเขียน ทำให้เป็นของส่วนตัวผ่านเซกชันเขียนได้หรือ CoW ได้ เลือกเขียนร่วมหรือ CoW ได้
Type ของ VirtualQuery MEM_IMAGE MEM_MAPPED

ด้วย SEC_IMAGE คุณสมบัติเซกชันของอิมเมจที่กำลังรันเองกำหนดการป้องกันเพจของวิว มากกว่าค่าการป้องกันทั่วไปที่ส่งให้ CreateFileMapping3

ด้วยกลไกนี้ เพจที่ยังไม่ถูกแก้เช่นโค้ดสามารถแบ่งเพจกายภาพเดียวกันข้ามหลายโพรเซส และเฉพาะเพจที่ต้องเปลี่ยนเฉพาะโพรเซสเท่านั้นที่แยกผ่าน CoW อย่างไรก็ตาม ไม่ใช่ทุกเพจ DLL ที่ต้องถูกแบ่งใช้เสมอ — เพราะการย้ายที่ ASLR การแก้ของโหลดเดอร์ ฮอตแพตช์ คุณสมบัติเซกชัน PE จริง เป็นต้น

การออกแบบสำคัญคือ แบ่งใช้เพจที่แบ่งใช้ได้ก่อน แล้วคัดลอกช้าเฉพาะเพจที่ต้องเปลี่ยน

5. คัดลอกเมื่อเขียนตั้งแต่ต้นจนจบ

ให้ตามจากสถานะที่สองโพรเซสกำลังอ่านเพจ CoW เดียวกัน จนโพรเซส A เขียนหนึ่งไบต์

5.1. ก่อนเขียน

ก่อนเกิดการเขียน PTE ของทั้งสองโพรเซสในเชิงแนวคิดไปถึงเพจร่วมเดียวกัน และการอ่านสำเร็จตามนั้น

สถานะร่วมก่อนคัดลอกเมื่อเขียนก่อนเกิดการเขียน PTE ของโพรเซส A และ PTE ของโพรเซส B ไปถึงเพจร่วม PFN X เดียวกัน และการอ่านสำเร็จตามนั้นPTE โพรเซส APFN X ร่วม(อ่าน / คัดลอกเมื่อเขียน)PTE โพรเซส B

ภาพ 3: ก่อนเขียน PTE ของทั้งสองโพรเซสชี้เพจกายภาพเดียวกัน

5.2. ฟอลต์การป้องกันตอนเขียน

เพจ CoW ไม่ใช่เพจร่วมที่เขียนได้ทั่วไปตั้งแต่ต้น เมื่อโพรเซส A พยายามเขียน CPU ยกฟอลต์การป้องกัน ตัวจัดการหน่วยความจำที่รับการควบคุมตัดสินว่านี่ไม่ใช่การเขียนผิดกฎหมาย แต่เป็นการเขียนต่อคุณสมบัติ CoW

5.3. สร้างเพจกายภาพใหม่

ตามการตัดสินนั้น Windows ทำดังนี้

  1. ขอเพจกายภาพหนึ่งเพจสำหรับโพรเซส A
  2. คัดลอกเนื้อหาของ PFN X ไปเพจใหม่ PFN Y
  3. สลับ PTE ของโพรเซส A ไป PFN Y
  4. เปลี่ยนการป้องกันของโพรเซส A เป็นอ่าน/เขียนทั่วไป
  5. รันคำสั่งเขียนที่ล้มเหลวอีกครั้ง
สถานะแยกหลังคัดลอกเมื่อเขียนหลังการเขียนของโพรเซส A มีเพียง PTE ของโพรเซส A ที่ถูกสลับไปเพจส่วนตัว PFN Y ซึ่งได้รับสำเนาเนื้อหา ในขณะที่ PTE ของโพรเซส B ยังชี้เพจร่วมเดิม PFN XคัดลอกตอนเขียนPTE โพรเซส APFN Y ส่วนตัว(R/W หลังเขียน)PTE โพรเซส BPFN X ร่วม(ต้นฉบับ)

ภาพ 4: เฉพาะ PTE ของโพรเซสที่เขียนถูกสลับไปเพจส่วนตัวใหม่ อีกฝ่ายยังอ่านเนื้อหาเดิม

โพรเซส B ยังอ่านเนื้อหาเดิมและไม่เห็นการเปลี่ยนแปลงของโพรเซส A นั่นคือ Copy-on-Write การแบ่งใช้ DLL และ FILE_MAP_COPY ใช้หลักเดียวกัน: อย่าคัดลอกจนกว่าจะเขียน46

5.4. ความต่างจาก FILE_MAP_WRITE

เพจที่เขียนผ่านการเขียนร่วมด้วย FILE_MAP_WRITE ถูกออกแบบให้การเปลี่ยนแปลงของฝ่ายหนึ่งมองเห็นจากวิวอื่นที่ใช้การแมปไฟล์เดียวกัน ด้วย FILE_MAP_COPY ในทางกลับกัน เฉพาะเพจที่ถูกเขียนกลายเป็นของส่วนตัวโพรเซส การเปลี่ยนแปลงไม่ถูกเขียนกลับไฟล์ต้นและหายเมื่อยกเลิกแมปวิว6

คุณต้องการ 「ส่งการอัปเดตผ่านหน่วยความจำร่วม」 หรือ 「ให้แต่ละโพรเซสแก้เฉพาะตัวจากข้อมูลเริ่มต้นร่วม」 การเลือกที่ถูกตรงข้ามกันตามจุดประสงค์

6. หลัง CoW ยังเป็น MEM_MAPPED / MEM_IMAGE

เพจหลัง CoW กลายเป็น Private ทางกายภาพ คุณอาจคาดว่า Type ของ VirtualQuery จะเปลี่ยนเป็น MEM_PRIVATE ด้วย แต่จริง ๆ วิวข้อมูลยังเป็น MEM_MAPPED และอิมเมจที่รันได้ยังเป็น MEM_IMAGE VirtualQuery รายงานว่าภูมิภาคมาจากการจัดสรรเริ่มต้นแบบใด7

หากจะดูรายเพจว่า CoW เกิดแล้วหรือยัง ใช้ขั้นตอนนี้

  1. เข้าถึงเพจเป้าหมายและทำให้เรซิเดนต์
  2. ขอข้อมูล Working Set ของเพจด้วย QueryWorkingSetEx
  3. ดูบิต Shared
  4. ถ้า Shared == 0 เพจเรซิเดนต์นั้นเป็น Private

เมื่อยืนยันใน VMMap ด้วย อย่าดูแค่ Type ของภูมิภาค แต่ดูการแยก Private/Shareable ของ Working Set

6.1. Private Bytes อาจไม่เพิ่ม

ด้วย FILE_MAP_COPY โพรเซสอาจเขียนทุกเพจในวิวในภายหลัง ดังนั้น Windows จึงคิด ค่า Commit เท่ากับทั้งวิว ตอนแมป6 ผลคือการเขียนเพจแรกไม่จำเป็นต้องเพิ่ม Private Bytes 4KiB ทันที

ตัวชี้วัดที่ควรใช้เมื่อสังเกต CoW มีดังนี้

  • บิต Shared จาก QueryWorkingSetEx
  • Private WS / Shareable WS ของ VMMap
  • ข้อมูลเพจกายภาพของ RAMMap
  • Private Bytes เป็นข้อมูลเสริม

ถ้าถือ 「Private Bytes เพิ่มตอนเขียนหรือไม่」 เป็นเกณฑ์ผ่าน/ไม่ผ่าน คุณจะพลาด CoW ที่ทำงานถูกต้อง

7. จุดสัมผัสกับ Cache Manager — แยกสามเส้นทาง

การพูดว่า 「การโหลด EXE/DLL แคชไฟล์ และหน่วยความจำร่วมล้วนเป็นเซกชัน」 ให้ภาพรวม แต่ห้ามยุบการทำงานเป็นออบเจ็กต์เดียว

สตรีมไฟล์มี SECTION_OBJECT_POINTERS ซึ่งตัวจัดการหน่วยความจำและ Cache Manager ใช้

typedef struct _SECTION_OBJECT_POINTERS {
    PVOID DataSectionObject;
    PVOID SharedCacheMap;
    PVOID ImageSectionObject;
} SECTION_OBJECT_POINTERS;
  • DataSectionObject: สถานะเซกชันของไฟล์ข้อมูล
  • SharedCacheMap: วิวแคชที่ Cache Manager ติดตาม
  • ImageSectionObject: สถานะเซกชันของอิมเมจที่รันได้

เอกสาร Microsoft อธิบายว่าโครงสร้างนี้ผูกออบเจ็กต์ไฟล์กับเซกชันของสตรีมไฟล์ และติดตามเนื้อหาในหน่วยความจำกับข้อมูลแคช5

ที่นี่ชุด I/O กับชุดหน่วยความจำมาบรรจบกัน แต่ให้เข้าใจสามเส้นทางแยกกัน

  • ReadFile / WriteFile ที่ใช้แคช ใช้ SharedCacheMap และวิวแคชของ Cache Manager
  • ฟอลต์การแมปบนไฟล์ข้อมูล ตัวจัดการหน่วยความจำจัดการด้าน DataSectionObject มันร่วมมือกับ I/O แคชบนสตรีมไฟล์เดียวกันเพื่อให้เนื้อหาสอดคล้อง
  • ฟอลต์อิมเมจบน EXE/DLL ตัวจัดการหน่วยความจำจัดการด้วย ImageSectionObject และ I/O เพจ นี่ไม่ใช่เส้นทางที่ผ่าน SharedCacheMap ของ Cache Manager
สามเส้นทางที่เกาะสตรีมไฟล์เดียวกันReadFile/WriteFile ที่ใช้แคชใช้ SharedCacheMap ฟอลต์การแมปข้อมูลใช้ DataSectionObject และฟอลต์อิมเมจ EXE/DLL ใช้ ImageSectionObject ทั้งสามเกาะสตรีมไฟล์เดียวกันผ่าน SECTION_OBJECT_POINTERSReadFile / WriteFile ที่ใช้แคชSharedCacheMapฟอลต์การแมปข้อมูลDataSectionObjectฟอลต์อิมเมจ EXE/DLLImageSectionObjectสตรีมไฟล์เดียวกัน(SECTION_OBJECT_POINTERS)

ภาพ 5: สามเส้นทางถูกประมวลผลแยกกัน แต่เกาะบนสตรีมไฟล์เดียวกัน

สิ่งที่ทั้งสามมีร่วมกันไม่ใช่ 「ทุกอย่างเข้า Cache Manager」 แต่คือสตรีมไฟล์เดียวกันผูกสถานะแยก — แคช เซกชันข้อมูล และเซกชันอิมเมจ — ผ่าน SECTION_OBJECT_POINTERS5 การอ่านเขียนแคช Lazy Writer และความสัมพันธ์ระหว่าง Cc กับ Mm อยู่ใน “The Depths of Windows I/O (Part 4) — Cache Manager: When Does Your WriteFile Actually Reach the Disk?

โปรดทราบว่าเมื่อผสมวิวที่แมปหน่วยความจำกับ ReadFile/WriteFile คุณไม่ได้รับการรับประกันว่าจะเห็นเนื้อหา ณ ขณะเดียวกันเสมอ การออกแบบต้องรวมการซิงค์ การ flush และโหมดแบ่งใช้ไฟล์36

8. อายุของออบเจ็กต์และวิว

ปิดแฮนเดิล CreateFileMapping อย่างเดียวไม่ทำลายวิวที่มีอยู่ วิวถือการอ้างอิงภายในไปยังเซกชัน และหลังทุกวิวถูก UnmapViewOfFile และทุกแฮนเดิลถูก CloseHandle แล้ว ออบเจ็กต์จึงทำลายได้3

UnmapViewOfFile(view);
CloseHandle(mapping);
CloseHandle(file);

การแยกอายุนี้เป็นสาเหตุของปรากฏการณ์ 「ปิดไฟล์แล้ว แต่ยังถูกใช้」 แม้หลังปิดแฮนเดิลไฟล์ ถ้าเซกชันอิมเมจหรือวิวข้อมูลยังอ้างสตรีมไฟล์ การปิดไฟล์ครั้งสุดท้ายจะมาทีหลัง

ความสัมพันธ์กับการทำความสะอาด/ปิดด้าน I/O อยู่ใน “The Depths of Windows I/O (Part 1)” และกับดักการทำจริงอยู่ใน “Shared Memory Pitfalls and Practical Best Practices

9. ดูด้วยตนเอง

9.1. มอง DLL เดียวกันจากสองโพรเซส

ก่อนอื่น ยืนยันการแบ่งใช้ DLL ด้วยโพรเซสที่มีอยู่

  1. เริ่ม Process Explorer ในฐานะผู้ดูแลระบบ
  2. เริ่มโพรเซส cmd.exe สองตัว
  3. เลือก View > Lower Pane View > DLLs
  4. ยืนยันเส้นทางและการแมปของ DLL เดียวกันในทั้งสองโพรเซส
  5. เปิดแต่ละ cmd.exe ใน VMMap แล้วเปรียบเทียบ Images Working Set, Private และ Shareable

การเห็น DLL เดียวกันใน Process Explorer เป็นหลักฐานว่าทั้งคู่แมปอิมเมจเดียวกัน แต่แค่นั้นยังไม่พิสูจน์ว่า PFN ของแต่ละเพจตรงกัน รวมการแยก Shareable ของ VMMap, RAMMap และ QueryWorkingSetEx เพื่อยืนยันการแบ่งใช้รายเพจ Process Explorer และ VMMap ให้โดย Sysinternals1011

9.2. สังเกต FILE_MAP_COPY จากสองโพรเซส

โปรแกรมต่อไปนี้แมปไฟล์เดียวกันเป็นวิว CoW และแสดงบิต Shared จาก QueryWorkingSetEx ออบเจ็กต์แมปไฟล์ถูกสร้างด้วย PAGE_READONLY แต่การป้องกันนั้นเข้ากันได้กับวิว FILE_MAP_COPY และการเขียนครั้งแรกด้านวิวทำให้เกิด CoW3

#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 ด้านอ่านก่อนแล้วด้านเขียน และยืนยันว่า Shared ถูกตั้งใน before ของทั้งคู่ กด Enter อีกครั้งด้านเขียน แล้วเพจของโพรเซสนั้นเป็น Shared 0 จากนั้นกด Enter ด้านอ่าน คุณยืนยันได้ว่าฝ่ายอ่านยังอ่านเพจเดิม Type ของ VirtualQuery ยังเป็น MEM_MAPPED หลังเขียนด้วย

โปรดทราบว่า PrintPage แตะเพจเป้าหมายอีกครั้งทันทีก่อนสอบถาม และถ้า Valid == 0 จะไม่ตีความ Shared กับ ShareCount และลองใหม่ได้ถึงสามครั้ง ถ้าเพจยังไม่เรซิเดนต์ จะไม่ให้ผลและรายงานเช่นนั้น ShareCount เปลี่ยนตามจังหวะและความกดดันหน่วยความจำได้ ดังนั้นให้ดูการเปลี่ยนของบิต Shared ที่ยืนยันเมื่อ Valid == 1 ไม่ใช่ค่าคงที่

10. ห้าความเข้าใจผิดที่ควรเลี่ยงในงานจริง

10.1. 「หน่วยความจำร่วมไปลงที่อยู่เสมือนเดียวกัน」

สิ่งที่แบ่งใช้คือเซกชันกับเพจกายภาพ ที่อยู่เสมือนของวิวต่างกันได้ต่อโพรเซส จึงให้เก็บออฟเซ็ต ไม่ใช่พอยน์เตอร์ดิบ

10.2. 「ถ้าเป็น DLL เดียวกัน ทุกเพจต้องถูกแบ่งใช้」

เพจโค้ดที่สะอาดแบ่งใช้ได้ง่าย ในขณะที่การย้ายที่ เซกชันเขียนได้ CoW และสถานะเรซิเดนต์ ณ ขณะวัด ก็สร้างเพจ Private ได้

10.3. 「หลัง CoW กลายเป็น MEM_PRIVATE

Type ของ VirtualQuery ยังเป็น MEM_MAPPED หรือ MEM_IMAGE ยืนยันสถานะการแบ่งใช้จริงด้วย QueryWorkingSetEx7

10.4. 「ถ้า Private Bytes ไม่เพิ่ม แสดงว่าไม่เกิด CoW」

FILE_MAP_COPY คิด Commit ทั้งวิวล่วงหน้า ให้เน้น Private WS และบิต Shared6

10.5. 「ถ้าเพจถูกแบ่งใช้ ไม่ต้องซิงค์」

การเห็นเพจกายภาพเดียวกัน กับการอัปเดตอย่างปลอดภัยจากหลายคอร์ CPU เป็นปัญหาคนละเรื่อง ออกแบบความเป็นอะตอม ลำดับหน่วยความจำ การกันชน สถานะกลางตอนแครช และความเข้ากันได้ของเวอร์ชัน

แนวคิดการตามการอ้างอิงกับอายุแยกกัน ใช้กับปัญหาโพรเซสที่ค้างหลัง interop COM ของ Excel ได้ด้วย ดูเพิ่ม “Why EXCEL.EXE Processes Remain After C# Excel COM Automation — Reference Release Patterns and the Replacement Decision

11. สรุป

  • ออบเจ็กต์เซกชันแทนช่วงหน่วยความจำที่แบ่งใช้ได้ และแต่ละโพรเซสแมปเข้าพื้นที่เสมือนของตนเป็นวิว1
  • ออฟเซ็ตเดียวกันของเซกชันเดียวกันถูกแมปจากที่อยู่เสมือนต่างกันไปยังเพจกายภาพเดียวกัน2
  • เซกชันที่ backed ด้วยไฟล์รองรับไฟล์จริง เซกชันที่ backed ด้วยเพจไฟล์รองรับหน่วยความจำร่วมที่มีชื่อ เป็นต้น9
  • EXE/DLL ถูกถือเป็นเซกชันอิมเมจ และไฟล์ทั่วไปเป็นเซกชันข้อมูล การป้องกันและปลายทางการเขียนกลับต่างกัน3
  • CoW แบ่งใช้เพจกายภาพตอนอ่าน และเมื่อเขียนครั้งแรกคัดลอกเฉพาะเพจนั้นแล้วสลับ PTE4
  • หลัง CoW VirtualQuery ยังคืน MEM_MAPPED/MEM_IMAGE จึงยืนยันด้วยบิต Shared ของ QueryWorkingSetEx7
  • ด้วย FILE_MAP_COPY Commit ทั้งวิวถูกคิดก่อน จึงตัดสิน CoW จาก Private Bytes อย่างเดียวไม่ได้6
  • I/O แคชของ Cache Manager การแมปข้อมูล และการแมปอิมเมจเกาะสตรีมไฟล์เดียวกันเป็นเส้นทางแยกที่ใช้ SharedCacheMap, DataSectionObject และ ImageSectionObject ตามลำดับ5
  • หน่วยความจำร่วมปลอดภัยก็ต่อเมื่อรวมอายุของวิวและแฮนเดิล การซิงค์ ACL และการออกแบบออฟเซ็ต

ครบทั้งสามตอนของ 「ความลึกของหน่วยความจำ Windows」 คุณ Reserve/Commit ที่อยู่เสมือน ได้เพจกายภาพผ่านเพจฟอลต์ ย้ายเพจจาก Working Set ไปรายการเพจ แบ่งใช้ผ่านเซกชัน และแยกเฉพาะเพจที่เขียนด้วย CoW — การจัดการหน่วยความจำ Windows เชื่อมเป็นสายเดียวนี้

บทความที่เกี่ยวข้อง

ด้านให้คำปรึกษาที่เกี่ยวข้อง

KomuraSoft LLC รับงานสอบสวนข้อบกพร่องเรื่องหน่วยความจำร่วม การแมปไฟล์ การโหลด DLL การล็อกไฟล์ การสื่อสารระหว่างโพรเซส และการใช้หน่วยความจำของแอปพลิเคชัน Windows

ลิงก์อ้างอิง

  1. Microsoft Learn, Section Objects and Views. เกี่ยวกับออบเจ็กต์เซกชันที่แทนช่วงหน่วยความจำที่แบ่งใช้ได้ และแต่ละโพรเซสแมปส่วนหนึ่งของเซกชันเป็นวิว  2 3

  2. Microsoft Learn, File-Backed and Page-File-Backed Sections. เกี่ยวกับเซกชันที่ backed ด้วยไฟล์และเพจไฟล์ CoW และการแบ่งใช้หน่วยความจำกายภาพเดียวกันจากที่อยู่เสมือนของโพรเซสต่างกัน  2 3 4

  3. Microsoft Learn, CreateFileMappingW function. เกี่ยวกับออบเจ็กต์แมปไฟล์ เซกชันที่ backed ด้วยเพจไฟล์ SEC_IMAGE อายุของวิวและแฮนเดิล และความสอดคล้องระหว่างวิวที่รองรับไฟล์เดียวกัน  2 3 4 5 6 7 8

  4. Microsoft Learn, Memory Protection. เกี่ยวกับหลายโพรเซสที่แบ่งใช้เพจกายภาพของ DLL เดียวกัน และ CoW ที่คัดลอกไปเพจกายภาพใหม่แล้วอัปเดต PTE เมื่อฝ่ายหนึ่งเขียน  2 3

  5. Microsoft Learn, SECTION_OBJECT_POINTERS structure. เกี่ยวกับ DataSectionObject, SharedCacheMap และ ImageSectionObject ที่ผูกการแมปสตรีมไฟล์และข้อมูลแคชกับตัวจัดการหน่วยความจำ / Cache Manager  2 3 4

  6. Microsoft Learn, MapViewOfFileEx function. เกี่ยวกับ CoW ด้วย FILE_MAP_COPY เพจส่วนตัวที่ backed ด้วยเพจไฟล์ การคิด Commit ทั้งวิว และการเก็บออฟเซ็ตแทนที่อยู่เสมือน  2 3 4 5 6 7 8

  7. Microsoft Learn, VirtualQuery function. เกี่ยวกับ Type ที่ยังเป็น MEM_MAPPED/MEM_IMAGE หลัง CoW และการยืนยันการทำให้เป็นของส่วนตัวด้วยบิต Shared ของ QueryWorkingSetEx  2 3 4

  8. Microsoft Learn, Managing Memory Sections. เกี่ยวกับการที่หน่วยความจำกายภาพยังไม่ถูกกำหนดจนกว่าจะเข้าถึงวิว และเพจฟอลต์ครั้งแรกอ่านเนื้อหาไฟล์ 

  9. Microsoft Learn, Sharing Files and Memory. เกี่ยวกับการแบ่งใช้ออบเจ็กต์แมปไฟล์เดียวกันด้วยชื่อหรือแฮนเดิล การสร้างหน่วยความจำร่วมที่ backed ด้วยเพจไฟล์ด้วย INVALID_HANDLE_VALUE และการซิงค์ที่ต้องออกแบบแยก  2 3

  10. Microsoft Learn, Process Explorer - Sysinternals. เกี่ยวกับ Process Explorer ที่แสดงแฮนเดิลของโพรเซสและ DLL / ไฟล์ที่แมปหน่วยความจำที่โหลดแล้วได้ 

  11. Microsoft Learn, VMMap - Sysinternals. เกี่ยวกับ VMMap ที่แยกหน่วยความจำเสมือนของโพรเซสเป็น Image, Mapped File, Private เป็นต้น และแสดงการแยก Private/Shareable ของ Working Set 

บทความล่าสุดที่มีแท็กเดียวกัน เพื่อเจาะลึกหัวข้อใกล้เคียง

DllMain และ Loader Lock — เหตุผลจริงที่ถูกบอกให้ "อย่าทำอะไรในการเริ่มต้น DLL"

ทำไมคุณต้องไม่เรียก LoadLibrary หรือซิงโครไนซ์กับเธรดอื่นจาก DllMain บทความนี้อธิบายจากแหล่งปฐมภูมิว่า loader lock ทำให้การแจ้ง DLL ทุกคร...

ความลึกของหน่วยความจำ Windows (ตอนที่ 2) — ชีวิตของเพจทางกายภาพ: ห้าบัญชีและเรื่องจริงของไฟล์เพจ

บทความนี้เชื่อมฐานข้อมูล PFN, Standby, Modified, การบีบอัดหน่วยความจำ และไฟล์เพจ เพื่ออธิบายว่าเพจทางกายภาพไปไหนหลังจากออกจาก Working Set.

ความลึกของหน่วยความจำ Windows (ตอนที่ 1) — ช่วงที่ที่อยู่เสมือนกลายเป็น RAM กายภาพ: page fault จากต้นจนจบ

บทความนี้เชื่อม VirtualAlloc, VAD, ตารางเพจ, TLB, demand-zero และ hard fault เพื่ออธิบายช่วงที่ที่อยู่เสมือนได้รับ RAM กายภาพ.

เชิงลึกของการจำลองเสมือนบน Windows (ตอนที่ 3) — เครื่องเสมือนที่บูตในไม่กี่วินาที: ทำไม WSL2, Windows Sandbox และคอนเทนเนอร์จึงเบา

ทำไม WSL2 และ Windows Sandbox จึงเริ่มในไม่กี่วินาทีและรู้สึกเบา? บทความนี้อธิบายกลไก ตั้งแต่ dynamic base image และ direct map ไปจนถึงกา...

เชิงลึกของการจำลองเสมือนบน Windows (ตอนที่ 2) — หน่วยความจำที่แม้เคอร์เนลก็มองไม่เห็น: กลไกของ VBS, HVCI และ Credential Guard

เมื่อติดตั้งใหม่บนฮาร์ดแวร์ที่รองรับ VBS จะเปิดตามค่าเริ่มต้น และใช้ hypervisor กับ SLAT สร้างการแยกที่แข็งกว่าเคอร์เนล บทความนี้อธิบายโค...

หน้าเหล่านี้วางหัวข้อไว้ในบริบทที่กว้างขึ้นของบริการและการตัดสินใจ

บทความนี้เกี่ยวข้องโดยตรงกับบริการต่อไปนี้

คำถามที่พบบ่อย

คำถามที่มักพบในการปรึกษาเกี่ยวกับหัวข้อของบทความนี้

แต่ละโพรเซสที่ใช้ 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 หากจะดูว่าเพจถูกทำให้เป็นของส่วนตัวจริงหรือไม่ ให้ทำให้เพจเรซิเดนต์แล้วดูบิต Shared ของ QueryWorkingSetEx
เก็บพอยน์เตอร์ดิบในหน่วยความจำร่วมได้หรือไม่?
โดยปกติไม่ควร แม้เซกชันเดียวกันก็ไม่มีการรับประกันว่าวิวของแต่ละโพรเซสจะอยู่ที่อยู่เสมือนเดียวกัน ในโครงสร้างร่วมให้ใช้ออฟเซ็ตจากฐาน จำนวนเต็มความกว้างคงที่ และเค้าโครงกับวิธีซิงค์ที่ระบุชัด

โปรไฟล์ผู้เขียน

หน้าแนะนำผู้เขียนบทความ

Go Komura

ผู้แทนของ KomuraSoft LLC

เชี่ยวชาญด้านการพัฒนาซอฟต์แวร์ Windows ที่ปรึกษาเทคนิค และการตรวจสอบบั๊ก โดยเฉพาะโปรเจกต์ที่มีระบบเดิมและบั๊กที่ทำซ้ำได้ยาก

ลิงก์สาธารณะ

กลับไปยังบล็อก