이전 글 「Windows 메모리의 심층(제2회) ── 물리 페이지의 일생」에서는 Working Set을 떠난 물리 페이지가 Modified, Standby, Free, Zeroed를 어떻게 이동하는지를 따라갔습니다. 그 Standby에는 DLL, EXE, 매핑된 파일, 파일 캐시의 페이지도 남아 있습니다.
여기서 질문이 생깁니다. 100개 프로세스가 같은 kernel32.dll을 쓸 때, Windows는 코드 페이지를 RAM에 100벌 둘까요? 두 프로세스가 같은 파일을 메모리 매핑하면, 한쪽이 읽은 페이지를 다른 쪽도 쓸 수 있을까요?
답은 여러 가상 주소를 같은 물리 페이지에 매핑하는 것입니다. 그 공유 단위를 나타내는 중심이 섹션 객체이고, 쓰기 시점에만 공유를 가르는 장치가 복사 시 쓰기(Copy-on-Write, CoW)입니다.
이 글은 EXE와 DLL, 데이터 파일, 페이지 파일 백업 공유 메모리, 파일 캐시가 같은 파일 스트림에 어떻게 붙고 처리 경로가 어디서 갈라지는지를 따라갑니다. 숫자 읽기 자체는 입문 글 「Windows의 「메모리 사용량」은 무엇을 나타내는가」를 전제로 합니다.
「Windows 메모리의 심층」 전 3회
- 제1회: 가상 주소와 페이지 폴트
Commit된 가상 페이지가 물리 RAM을 얻는 순간을 따라갑니다. - 제2회: 물리 페이지의 일생
Working Set을 떠난 페이지의 상태 전이를 따라갑니다. - 제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 - 같은 섹션의 뷰가 같은 가상 주소에 있을 필요는 없습니다.
프로세스 A의0x000001...과 프로세스 B의0x000002...가 같은 섹션 오프셋과 같은 물리 페이지를 가리킬 수 있습니다.2 - 파일 백업 섹션과 페이지 파일 백업 섹션이 있습니다.
전자는 실제 파일을 쓰고, 후자는 명시적 파일이 없는 공유 메모리 등에 쓰입니다.2 - EXE/DLL 로드는 이미지 섹션, 일반 파일 매핑은 데이터 섹션입니다.
SEC_IMAGE에서는 PE 안의 섹션 속성이 페이지 보호를 정합니다.3 - 읽기 중에는 같은 물리 페이지를 공유할 수 있습니다.
한쪽이 CoW 페이지에 쓰면 그 페이지만 복사되고 쓴 프로세스의 PTE가 바뀝니다.4 - 같은 파일 스트림이어도 캐시, 데이터, 이미지 경로는 갈라집니다.
Cache Manager의 캐시 I/O는SharedCacheMap, 데이터 매핑은DataSectionObject, EXE/DLL은ImageSectionObject를 씁니다. 셋은SECTION_OBJECT_POINTERS를 통해 같은 파일 스트림에 붙지만, 이미지 폴트는 Cache Manager를 거치지 않습니다.5 - Private Bytes만으로는 CoW가 일어났는지 확인할 수 없습니다.
FILE_MAP_COPY는 나중에 모든 페이지가 사유화될 가능성에 대비해 매핑 시점에 뷰 전체의 Commit을 부과합니다. 페이지 단위 확인은QueryWorkingSetEx의 Shared 비트를 쓰십시오.67
한 문장으로: 공유되는 것은 가상 주소가 아니라, 섹션 안의 내용과 그 순간에 대응하는 물리 페이지입니다.
2. 섹션 객체와 뷰
Microsoft의 정의에서 섹션 객체는 공유 가능한 메모리 영역을 나타내며, 파일을 프로세스 주소 공간에 매핑하는 장치이기도 합니다.1
이해하는 요령은 섹션 자체와 각 프로세스가 보는 뷰를 나누는 것입니다.
| 개념 | 역할 |
|---|---|
| 섹션 객체 | 공유할 내용, 크기, 백킹 스토어, 보호 상한을 나타냅니다 |
| 뷰 | 섹션의 일부를 어떤 프로세스의 가상 주소 범위로 보여 줍니다 |
| 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에 올라가지는 않습니다. 처음 건드린 페이지부터 페이지 폴트가 파일 내용을 읽고 PTE에 물리 페이지를 묶습니다.8 즉 제1회에서 본 디맨드 페이징이 섹션 뷰에도 적용됩니다.
2.1. 같은 섹션, 다른 가상 주소
프로세스 A와 프로세스 B가 같은 섹션의 같은 오프셋을 매핑해도, 뷰의 시작 주소는 다를 수 있습니다.
flowchart LR
accTitle: 서로 다른 가상 주소를 같은 물리 페이지에 매핑
accDescr: 프로세스 A와 프로세스 B는 서로 다른 가상 주소의 뷰를 갖지만, 같은 섹션 오프셋을 거쳐 같은 물리 페이지 PFN X에 도달합니다
viewA["프로세스 A: 0x000001A00000 + 0x3000"] --> offset["섹션 오프셋 0x3000"]
viewB["프로세스 B: 0x000002700000 + 0x3000"] --> offset
offset --> pfnX["같은 물리 페이지 PFN X"]
그림 1: 공유되는 것은 가상 주소가 아니라 섹션 안의 내용과 물리 페이지입니다.
그래서 공유 메모리에 생 포인터를 저장하면 안 됩니다. 프로세스 A의 포인터 값이 프로세스 B에서는 무관한 주소일 수 있습니다.
공유 구조에서는 뷰 시작으로부터의 오프셋, 고정 폭 정수, 명시적인 버전과 정렬을 쓰십시오. Microsoft의 MapViewOfFileEx 문서도 같은 주소를 앞으로 쓸 수 있다는 보장이 없으므로 포인터 대신 베이스로부터의 오프셋을 저장하라고 권합니다.6
3. 파일 백업과 페이지 파일 백업
섹션은 내용을 어디서 복원할 수 있느냐에 따라 크게 두 종류로 나뉩니다.
flowchart TB
accTitle: 파일 백업과 페이지 파일 백업이 갈라지는 지점
accDescr: CreateFileMapping에 실제 파일을 넘기면 파일 백업 섹션이 되고 깨끗한 페이지는 원본 파일에서 다시 읽을 수 있습니다. INVALID_HANDLE_VALUE를 넘기면 페이지 파일 백업 섹션이 되며, 페이지 파일이 내용을 받치고 객체를 파괴하면 내용이 사라집니다
create["CreateFileMapping"] -->|실제 파일 핸들을 전달| fileBacked["파일 백업 섹션"]
create -->|INVALID_HANDLE_VALUE를 전달| pfBacked["페이지 파일 백업 섹션"]
fileBacked --> restore1["깨끗한 페이지는 원본 파일에서 다시 읽을 수 있음"]
pfBacked --> restore2["페이지 파일이 내용을 받치며 파괴 시 사라짐"]
그림 2: 백킹 스토어의 차이가 내용을 어디서 복원하고 얼마나 오래 사는지를 정합니다.
3.1. 파일 백업 섹션
CreateFileMapping에 실제 파일을 넘기면 파일 백업 섹션이 됩니다.
- 읽기 전용 뷰는 필요한 페이지를 파일에서 읽습니다.
- 읽기/쓰기 뷰의 변경은 그 파일의 데이터로 다뤄집니다.
- CoW 뷰의 변경은 원본 파일에 쓰이지 않고 전용 페이지가 됩니다.
파일 백업 페이지가 깨끗하면 물리 페이지를 버리고 원본 파일에서 다시 읽을 수 있습니다. 그 성질이 제2회에서 본 Standby와 파일 캐시의 효율을 받칩니다.
3.2. 페이지 파일 백업 섹션
CreateFileMapping의 hFile에 INVALID_HANDLE_VALUE를 넘기고 크기를 지정하면 페이지 파일 백업 섹션이 됩니다.
HANDLE mapping = CreateFileMappingW(
INVALID_HANDLE_VALUE,
nullptr,
PAGE_READWRITE,
0,
64 * 1024,
L"Local\\KomuraMemoryDemo");
이것은 명시적 데이터 파일이 없고 페이지 파일이 받치는 섹션입니다. 초기 내용은 0이고, 여러 프로세스가 이름, 핸들 상속, DuplicateHandle 등으로 같은 객체를 열 수 있습니다.93 변경은 같은 공유 페이지를 매핑한 프로세스에 보입니다. 반면 섹션 객체를 파괴하면 내용은 남지 않으므로, 영속 파일을 남기는 용도에는 맞지 않습니다.2
주의: 공유 메모리에 상호 배제가 자동으로 따라오지는 않습니다. 뮤텍스, 세마포어, 이벤트, 락프리 프로토콜 등은 따로 설계합니다.9
4. 이미지 매핑과 데이터 매핑
「EXE나 DLL도 파일 매핑」이라고 말해도, 일반 데이터 파일과의 차이는 따로 잡아 두어야 합니다.
| 항목 | 이미지 매핑 | 데이터 매핑 |
|---|---|---|
| 주된 용도 | EXE 또는 DLL 로드 | 일반 파일, 공유 데이터 |
| 생성 속성 | SEC_IMAGE |
PAGE_READONLY, PAGE_READWRITE 등 |
| 페이지 보호 | PE 이미지 안의 속성이 결정 | 매핑과 뷰 지정이 결정 |
| 쓰기 | 쓰기 가능 섹션 또는 CoW로 사유화할 수 있음 | 공유 쓰기 또는 CoW를 선택할 수 있음 |
VirtualQuery Type |
MEM_IMAGE |
MEM_MAPPED |
SEC_IMAGE에서는 CreateFileMapping에 넘긴 일반 보호 값보다, 실행 이미지 자신의 섹션 속성이 뷰의 페이지 보호를 정합니다.3
이 장치로 코드처럼 수정되지 않은 페이지는 많은 프로세스가 같은 물리 페이지를 공유하고, 프로세스 전용 변경이 필요한 페이지만 CoW로 갈라집니다. 다만 모든 DLL 페이지가 반드시 공유되는 것은 아닙니다. ASLR 재배치, 로더 수정, 핫패치, 실제 PE 섹션 속성 등이 있기 때문입니다.
중요한 설계는 공유할 수 있는 페이지를 먼저 공유하고, 변경이 필요한 페이지만 늦게 복사하는 것입니다.
5. 복사 시 쓰기를 처음부터 끝까지
두 프로세스가 같은 CoW 페이지를 읽는 상태에서 프로세스 A가 1바이트를 쓰기까지의 흐름을 따라갑니다.
5.1. 쓰기 전
쓰기가 일어나기 전에는 두 프로세스의 PTE가 개념적으로 같은 공유 페이지에 도달하고, 읽기는 그대로 성공합니다.
flowchart LR
accTitle: 복사 시 쓰기 전의 공유 상태
accDescr: 쓰기가 일어나기 전, 프로세스 A의 PTE와 프로세스 B의 PTE는 모두 같은 공유 페이지 PFN X에 도달하며 읽기는 그대로 성공합니다
pteA["프로세스 A PTE"] --> pfnX["공유 PFN X(읽기 / 복사 시 쓰기)"]
pteB["프로세스 B PTE"] --> pfnX
그림 3: 쓰기 전에는 두 프로세스의 PTE가 같은 물리 페이지를 가리킵니다.
5.2. 쓰기 시의 보호 폴트
CoW 페이지는 처음부터 일반 공유 쓰기 가능 페이지가 아닙니다. 프로세스 A가 쓰려고 하면 CPU가 보호 폴트를 일으킵니다. 제어를 받은 메모리 관리자는 이것이 불법 쓰기가 아니라 CoW 속성에 대한 쓰기라고 판단합니다.
5.3. 새 물리 페이지 만들기
그 판단에서 Windows는 다음을 합니다.
- 프로세스 A용 물리 페이지를 하나 얻습니다.
- PFN X의 내용을 새 페이지 PFN Y에 복사합니다.
- 프로세스 A의 PTE를 PFN Y로 바꿉니다.
- 프로세스 A의 보호를 일반 읽기/쓰기로 바꿉니다.
- 실패한 쓰기 명령을 다시 실행합니다.
flowchart LR
accTitle: 복사 시 쓰기 후의 분기 상태
accDescr: 프로세스 A의 쓰기 후, 프로세스 A의 PTE만 내용 복사본을 받은 전용 페이지 PFN Y로 바뀌고, 프로세스 B의 PTE는 원래 공유 페이지 PFN X를 계속 가리킵니다
pteA2["프로세스 A PTE"] --> pfnY["전용 PFN Y(R/W, 쓰기 후)"]
pteB2["프로세스 B PTE"] --> pfnX2["공유 PFN X(원본)"]
pfnX2 -.->|쓰기 시 복사됨| pfnY
그림 4: 쓴 프로세스의 PTE만 새 전용 페이지로 바뀌고, 다른 쪽은 원래 내용을 계속 읽습니다.
프로세스 B는 원래 내용을 계속 읽고 프로세스 A의 변경을 보지 않습니다. 그것이 복사 시 쓰기입니다. 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가 되었습니다. 그러면 VirtualQuery의 Type도 MEM_PRIVATE로 바뀔 것 같지만, 실제로는 데이터 뷰는 MEM_MAPPED, 실행 이미지는 MEM_IMAGE로 남습니다. VirtualQuery는 그 영역이 어떤 초기 할당에서 왔는지를 보고합니다.7
페이지마다 CoW가 이미 일어났는지 보려면 다음 절차를 쓰십시오.
- 대상 페이지에 접근해 상주시키십시오.
QueryWorkingSetEx로 그 페이지의 Working Set 정보를 얻으십시오.Shared비트를 보십시오.Shared == 0이면 그 상주 페이지는 Private입니다.
VMMap으로 확인할 때도 영역의 Type만이 아니라 Working Set의 Private/Shareable 내역을 보십시오.
6.1. Private Bytes가 늘지 않을 수 있습니다
FILE_MAP_COPY에서는 프로세스가 나중에 뷰의 모든 페이지에 쓸 수 있습니다. 그래서 Windows는 매핑 시점에 뷰 전체에 해당하는 Commit 과금을 합니다.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;
DataSectionObject: 데이터 파일의 섹션 상태SharedCacheMap: Cache Manager가 추적하는 캐시 뷰ImageSectionObject: 실행 이미지의 섹션 상태
Microsoft 문서는 이 구조가 파일 객체를 파일 스트림의 섹션에 묶고, 메모리 안의 내용과 캐시 정보를 추적한다고 설명합니다.5
여기서 I/O 계열과 메모리 계열이 만납니다. 다만 세 경로는 나눠서 이해하십시오.
- 캐시된
ReadFile/WriteFile은 Cache Manager의SharedCacheMap과 캐시 뷰를 씁니다. - 데이터 파일의 매핑 폴트는 메모리 관리자가
DataSectionObject쪽에서 처리합니다. 같은 파일 스트림의 캐시 I/O와 협력해 내용을 일치시킵니다. - EXE/DLL의 이미지 폴트는 메모리 관리자가
ImageSectionObject와 페이징 I/O로 처리합니다. Cache Manager의SharedCacheMap을 거치는 경로가 아닙니다.
flowchart TB
accTitle: 같은 파일 스트림에 붙는 세 경로
accDescr: 캐시된 ReadFile/WriteFile은 SharedCacheMap을, 데이터 매핑 폴트는 DataSectionObject를, EXE/DLL 이미지 폴트는 ImageSectionObject를 쓰며, 셋은 SECTION_OBJECT_POINTERS를 통해 같은 파일 스트림에 붙습니다
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로 묶는다는 점입니다.5 캐시 읽기/쓰기, Lazy Writer, Cc와 Mm의 관계는 「Windows I/O의 심층(제4회) ── 캐시 관리자: 당신의 WriteFile은 언제 디스크에 도달하는가」에서 다룹니다.
메모리 매핑 뷰와 ReadFile/WriteFile을 섞으면 항상 같은 순간의 내용을 본다는 보장은 없습니다. 설계에는 동기화, 플러시, 파일 공유 모드가 들어가야 합니다.36
8. 객체와 뷰의 수명
CreateFileMapping 핸들을 닫는 것만으로는 기존 뷰가 파괴되지 않습니다. 뷰는 섹션에 대한 내부 참조를 갖고, 모든 뷰가 UnmapViewOfFile되고 모든 핸들이 CloseHandle된 뒤에야 객체를 파괴할 수 있습니다.3
UnmapViewOfFile(view);
CloseHandle(mapping);
CloseHandle(file);
이 수명 분리가 「파일은 닫았는데 아직 사용 중」이라는 현상의 원인입니다. 파일 핸들을 닫은 뒤에도 이미지 섹션이나 데이터 뷰가 파일 스트림을 참조하면, 파일의 최종 닫힘은 더 늦어집니다.
I/O 쪽의 정리/닫힘과의 관계는 「Windows I/O의 심층(제1회)」에서, 구현의 함정은 「공유 메모리를 사용할 때의 함정과 베스트 프랙티스」에서 다룹니다.
9. 직접 확인하기
9.1. 두 프로세스에서 같은 DLL 보기
먼저 기존 프로세스로 DLL 공유를 확인합니다.
- 관리자 권한으로 Process Explorer를 시작합니다.
cmd.exe프로세스를 두 개 시작합니다.- View > Lower Pane View > DLLs를 선택합니다.
- 두 프로세스에서 같은 DLL의 경로와 매핑을 확인합니다.
- 각
cmd.exe를 VMMap에서 열고 Images Working Set, Private, Shareable을 비교합니다.
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를 눌러 양쪽 before에서 Shared가 설정된 것을 확인합니다. 쓰기 쪽에서 Enter를 한 번 더 누르면 그 프로세스의 페이지가 Shared 0이 됩니다. 이어서 읽기 쪽에서 Enter를 누르면 읽기 쪽이 원래 페이지를 계속 읽는 것을 확인할 수 있습니다. VirtualQuery의 Type은 쓰기 후에도 MEM_MAPPED로 남습니다.
참고로 PrintPage는 조회 직전에 대상 페이지를 다시 건드리고, Valid == 0이면 Shared와 ShareCount를 해석하지 않고 최대 세 번 재시도합니다. 페이지가 여전히 상주하지 않으면 결과를 내지 않고 그 사실을 보고합니다. ShareCount는 타이밍과 메모리 압박에 따라 변할 수 있으므로, 고정값이 아니라 Valid == 1에서 확인한 Shared 비트의 변화를 보십시오.
10. 실무에서 피할 다섯 가지 오해
10.1. 「공유 메모리는 같은 가상 주소에 놓인다」
공유되는 것은 섹션과 물리 페이지입니다. 뷰의 가상 주소는 프로세스마다 다를 수 있으므로, 생 포인터가 아니라 오프셋을 저장하십시오.
10.2. 「같은 DLL이면 모든 페이지가 반드시 공유된다」
깨끗한 코드 페이지는 공유하기 쉽지만, 재배치, 쓰기 가능 섹션, 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 상호 운용 뒤에 프로세스가 남는 문제에도 적용됩니다. 「C#의 Excel 조작에서 EXCEL.EXE가 남는 문제 ── COM 참조 해제 패턴과 교체 판단」도 보십시오.
11. 정리
- 섹션 객체는 공유 가능한 메모리 범위를 나타내고, 각 프로세스는 자신의 가상 공간에 뷰로 매핑합니다.1
- 같은 섹션의 같은 오프셋은 서로 다른 가상 주소에서 같은 물리 페이지로 매핑됩니다.2
- 파일 백업 섹션은 실제 파일을 받치고, 페이지 파일 백업 섹션은 이름 있는 공유 메모리 등을 받칩니다.9
- EXE/DLL은 이미지 섹션, 일반 파일은 데이터 섹션으로 다루며 보호와 다시 쓰기 대상이 다릅니다.3
- CoW는 읽기 중에 물리 페이지를 공유하고, 첫 쓰기에서 그 페이지만 복사한 뒤 PTE를 바꿉니다.4
- CoW 이후에도
VirtualQuery는MEM_MAPPED/MEM_IMAGE를 반환하므로,QueryWorkingSetEx의 Shared 비트로 확인합니다.7 FILE_MAP_COPY는 뷰 전체의 Commit을 먼저 부과하므로, Private Bytes만으로 CoW를 판단할 수 없습니다.6- Cache Manager의 캐시 I/O, 데이터 매핑, 이미지 매핑은 각각
SharedCacheMap,DataSectionObject,ImageSectionObject를 쓰는 별도 경로로 같은 파일 스트림에 붙습니다.5 - 공유 메모리는 뷰와 핸들의 수명, 동기화, ACL, 오프셋 설계까지 포함해야 안전해집니다.
이것으로 「Windows 메모리의 심층」 전 3회가 끝납니다. 가상 주소를 Reserve/Commit하고, 페이지 폴트로 물리 페이지를 얻고, 페이지를 Working Set에서 페이지 목록으로 옮기고, 섹션으로 공유하고, 쓴 페이지만 CoW로 가르는 것 ── Windows 메모리 관리는 이 하나의 흐름으로 이어져 있습니다.
관련 기사
- Windows 메모리의 심층(제1회) ── 가상 주소가 물리 RAM이 되는 순간: 페이지 폴트를 처음부터 끝까지
- Windows 메모리의 심층(제2회) ── 물리 페이지의 일생: 다섯 목록과 페이지 파일의 실상
- Windows I/O의 심층(제4회) ── 캐시 관리자: 당신의 WriteFile은 언제 디스크에 도달하는가
- 공유 메모리를 사용할 때의 함정과 베스트 프랙티스
- C#의 Excel 조작에서 EXCEL.EXE가 남는 문제 ── COM 참조 해제 패턴과 교체 판단
- Process Explorer / Handle / VMMap 실전 ── 행(hang)・누수・「파일 사용 중」을 지금 이 순간의 상태에서 추적한다
관련 상담 영역
합동회사 코무라소프트는 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, Memory Protection. 여러 프로세스가 같은 DLL의 물리 페이지를 공유하고, 한쪽이 쓰면 CoW가 새 물리 페이지로 복사한 뒤 PTE를 갱신한다는 내용. ↩ ↩2 ↩3
-
Microsoft Learn, SECTION_OBJECT_POINTERS structure. DataSectionObject, SharedCacheMap, ImageSectionObject가 파일 스트림의 매핑과 캐시 정보를 메모리 관리자 / Cache Manager에 묶는 내용. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, MapViewOfFileEx function.
FILE_MAP_COPY의 CoW, 전용 페이지가 페이지 파일로 받쳐진다는 점, 뷰 전체의 Commit 과금, 가상 주소 대신 오프셋을 저장하라는 내용. ↩ ↩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 알림을 직렬화하는 구조, 데드락이 구조적으로 확정되는 고전적 시나리오, 초기화를 미루는 올바른 설계, 행(hang)...
Windows 메모리의 심층(제2회) ── 물리 페이지의 일생: 다섯 목록과 페이지 파일의 진실
PFN 데이터베이스, Standby, Modified, 메모리 압축, 페이지 파일을 연결해 Working Set에서 벗어난 물리 페이지가 어디로 가는지 설명합니다.
Windows 메모리의 심층(제1회) ── 가상 주소가 물리 RAM이 되는 순간: 페이지 폴트의 처음부터 끝까지
VirtualAlloc, VAD, 페이지 테이블, TLB, 디맨드 제로, 하드 폴트를 이어서 가상 주소에 물리 RAM이 붙는 순간을 설명합니다.
Windows의 「메모리 사용량」은 무엇을 나타내는가 ── Working Set·Private Bytes·Commit·페이지 파일을 올바르게 읽는 법
작업 관리자의 메모리, Working Set, Private Bytes, Commit은 같은 값이 아닙니다. Windows의 가상 메모리와 물리 메모리의 관계, 페이지 파일의 역할, 메모리 부족이나 누수 조사에서 봐야 할 지표를 설명합니다.
Windows의 프로세스 간 통신을 어떻게 선택할까 ── 네임드 파이프 / TCP / gRPC / 공유 메모리 / COM 판단표
Windows 앱 사이의 연동 수단을 어떻게 선택할지 정리합니다. 네임드 파이프, 로컬 TCP, gRPC, 공유 메모리, 파일 연동, COM 각각의 강점과 함정을 판단표로 정리하고, UI+서비스 분리・32bit/64bit 브리지・권한 경계 같은 ...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 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 비트를 보면 됩니다.
- 공유 메모리에 생 포인터를 저장해도 됩니까?
- 보통은 안 됩니다. 같은 섹션이라도 각 프로세스의 뷰가 같은 가상 주소에 놓인다는 보장은 없습니다. 공유 구조에서는 베이스로부터의 오프셋, 고정 폭 정수, 명시적인 레이아웃과 동기화 방식을 쓰십시오.