수정 이력(1건, 최종 수정 2026년 09월 03일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- 한국어 전면 재작성에 맞춰 본문 표현을 바로잡았습니다. 기술적인 주장은 일본어판과 같습니다.
- 최초 공개
이 글을 인용하기(DOI(등록된 아카이브): 10.5281/zenodo.22176188)
아래 DOI는 이전에 등록된 아카이브를 가리키며 현재 본문과 다를 수 있습니다. 현재 본문을 참조할 때는 이 페이지의 URL을 사용하세요.
Go Komura (2026). 「Windows 메모리의 심층(제3회) ── 섹션 객체와 Copy-on-Write: DLL과 파일 매핑의 정체」. 합동회사 코무라소프트. https://comcomponent.com/ko/blog/windows-memory-internals-section-copy-on-write/
- DOI(등록된 아카이브)
- 10.5281/zenodo.22176188
- DOI(마지막 등록 버전)
- 10.5281/zenodo.22176189
이전 글 「Windows 메모리의 심층(제2회) ── 물리 페이지의 일생」에서는 Working Set을 떠난 물리 페이지가 Modified, Standby, Free, Zeroed를 어떻게 이동하는지를 따라갔습니다. 그 Standby에는 DLL, EXE, 매핑된 파일, 파일 캐시의 페이지도 남아 있습니다.
여기서 질문이 생깁니다. 100개 프로세스가 같은 kernel32.dll을 쓸 때, 코드 페이지를 RAM에 100벌이나 둘까요? 두 프로세스가 같은 파일을 메모리 매핑하면, 한쪽이 읽은 페이지를 다른 쪽도 쓸 수 있을까요?
답은 여러 가상 주소에서 같은 물리 페이지로 대응시키는 것입니다. 그 공유 단위를 나타내는 중심이 섹션 객체이고, 쓸 때만 공유를 가르는 장치가 Copy-on-Write(CoW)입니다.
이 글에서는 EXE·DLL, 데이터 파일, 페이지 파일 기반 공유 메모리, 파일 캐시가 같은 파일 스트림 위에서 어디에 붙고 처리 경로가 어디서 갈라지는지를 따라갑니다. 숫자 읽는 법은 도입 글 「Windows의 「메모리 사용량」은 무엇을 나타내는가」를 전제로 합니다.
「Windows 메모리의 심층」 전 3회
- 제1회: 가상 주소와 페이지 폴트
Commit된 가상 페이지가 물리 RAM을 얻는 순간을 따라갑니다. - 제2회: 물리 페이지의 일생
Working Set을 떠난 페이지의 상태 전이를 따라갑니다. - 제3회(이 글): 섹션 객체와 Copy-on-Write
DLL, 파일 매핑, 공유 메모리가 물리 페이지를 공유하는 구조를 따라갑니다.
제3회가 답하는 질문은 하나뿐입니다.
같은 DLL이나 파일을, 왜 여러 프로세스가 한 벌의 물리 페이지로 쓸 수 있는가.
대상 독자는 DLL 공유, CreateFileMapping, MapViewOfFile, 공유 메모리, CoW, 파일 캐시와의 관계를 API 사용법만이 아니라 내부 구조에서 이해하고 싶은 개발자와 운영 담당자입니다. 전제 환경은 Windows 10/11 또는 현재 Windows Server이고, 전제 지식은 가상 주소, 페이지 폴트, Working Set의 기초입니다. 난이도는 중급이며 Control Area와 Prototype PTE 같은 내부 용어도 쓰지만, 관찰은 VMMap, Process Explorer, QueryWorkingSetEx로 재현할 수 있습니다.
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 19건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
1. 먼저 결론
먼저 전체 그림을 정리합니다.
- 섹션 객체는 공유 가능한 메모리 범위를 나타냅니다. 각 프로세스는 그 섹션의 일부를 자신의 가상 공간에 「뷰」로 매핑합니다.1
- 같은 섹션의 뷰가 같은 가상 주소일 필요는 없습니다.
프로세스 A의
0x000001...과 프로세스 B의0x000002...가 같은 섹션 오프셋과 같은 물리 페이지를 가리킬 수 있습니다.2 - 파일 기반 섹션과 페이지 파일 기반 섹션이 있습니다. 전자는 실제 파일을 쓰고, 후자는 명시적 파일이 없는 공유 메모리 등에 씁니다.2
- EXE/DLL 로드는 이미지 섹션, 일반 파일 매핑은 데이터 섹션입니다.
SEC_IMAGE에서는 PE 안의 섹션 속성이 페이지 보호를 정합니다.3 - 읽는 동안에는 같은 물리 페이지를 공유할 수 있습니다. 한쪽이 CoW 페이지에 쓰면 그 페이지만 복사되고, 쓴 프로세스의 PTE가 바뀝니다.4
- 같은 파일 스트림이어도 캐시·데이터·이미지 경로는 갈라집니다.
Cache Manager의 cached 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["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. 페이지 파일 기반 섹션
CreateFileMapping의 hFile에 INVALID_HANDLE_VALUE를 넘기고 크기를 지정하면 페이지 파일 기반 섹션이 됩니다.
HANDLE mapping = CreateFileMappingW(
INVALID_HANDLE_VALUE,
nullptr,
PAGE_READWRITE,
0,
64 * 1024,
L"Local\\KomuraMemoryDemo");
명시적 데이터 파일이 없고, 페이지 파일이 받치는 섹션입니다. 초기 내용은 0이고, 이름, 핸들 상속, 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
이 구조 덕분에 코드처럼 바뀌지 않는 페이지는 여러 프로세스가 같은 물리 페이지를 공유할 수 있고, 프로세스 전용 변경이 필요한 페이지만 CoW로 갈라집니다. 다만 ASLR에 의한 재배치, 로더의 수정, 핫패치, 실제 PE 섹션 속성 등에 따라, 모든 DLL 페이지가 반드시 공유되는 것은 아닙니다.
중요한 것은 공유할 수 있는 페이지를 먼저 공유하고, 변경이 필요한 페이지만 늦게 복사한다는 설계입니다.
5. Copy-on-Write의 전 과정
두 프로세스가 같은 CoW 페이지를 읽는 상태에서, Process A만 1바이트를 쓸 때까지를 따라갑니다.
5.1. 쓰기 전
쓰기가 일어나기 전에는 두 프로세스의 PTE가 개념적으로 같은 공유 페이지에 도달하고, 읽기는 그대로 성공합니다.
flowchart LR
accTitle: Copy-on-Write 전의 공유 상태
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: Copy-on-Write 후의 분기 상태
accDescr: 프로세스 A의 쓰기를 받아, 내용을 복사한 전용 페이지 PFN Y로 프로세스 A의 PTE만 바뀌고, 프로세스 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도 쓸 때까지 복사하지 않는다는 같은 원리를 씁니다.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 charge를 잡습니다.6 그 결과, 첫 1페이지를 쓴 순간에 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 연재와 메모리 연재가 만납니다. 다만 세 경로는 나눈 채로 이해합니다.
- cached
ReadFile/WriteFile은 Cache Manager의SharedCacheMap과 캐시 뷰를 씁니다. - 데이터 파일의 매핑 폴트는 메모리 관리자가
DataSectionObject쪽에서 처리합니다. 같은 파일 스트림의 cached I/O와는 내용의 정합성을 유지하도록 협력합니다. - EXE/DLL의 이미지 폴트는 메모리 관리자가
ImageSectionObject와 페이징 I/O로 처리합니다. Cache Manager의SharedCacheMap을 거치는 경로가 아닙니다.
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를 통해 캐시, 데이터 섹션, 이미지 섹션이라는 별도 상태를 묶고 있다는 점입니다.5 캐시 읽기/쓰기, 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을 비교합니다.
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
둘을 시작한 뒤 먼저 read 쪽, 다음에 write 쪽에서 Enter를 눌러, 양쪽 모두 before에서 Shared가 설정된 것을 확인합니다. 이어서 write 쪽에서 Enter를 한 번 더 누르면 그 프로세스의 페이지는 Shared가 0이 됩니다. 그 후 read 쪽에서 Enter를 누르면, reader 쪽이 원래 페이지를 계속 읽고 있음을 확인할 수 있습니다. VirtualQuery의 Type은 쓰기 후에도 MEM_MAPPED 그대로입니다.
PrintPage는 조회 직전에 대상 페이지를 다시 건드리고, Valid == 0이면 Shared와 ShareCount를 해석하지 않은 채 최대 3회 재시도합니다. 그래도 상주하지 않으면 결과를 내지 않고 그 사실을 표시합니다. 타이밍이나 메모리 압력에 따라 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 상호 운용에서 프로세스가 남는 문제에도 통합니다. 「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의 cached 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의 물리 페이지를 공유하고, 한쪽이 쓸 때 새 물리 페이지로 복사한 뒤 PTE를 갱신하는 CoW에 대한 내용. ↩ ↩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 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 알림을 직렬화하는 로더 락의 구조부터, 데드락이 성립하는 전형적인 시나리오, 지연 초기화 같은 올바른 설계, hang 조사 절차까지를 1차 정...
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의 프로세스 간 통신을 어떻게 고를까 ── Named Pipe / TCP / gRPC / 공유 메모리 / COM 판단표
Windows 앱끼리의 연동 수단을 어떻게 고를지 정리합니다. Named Pipe, 로컬 TCP, gRPC, 공유 메모리, 파일 연동, COM의 강점과 함정을 판단표로 정리하고, 정석 구성과 Named Pipe 구현 예까지 설명합니다.
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- 같은 DLL을 쓰는 프로세스마다 그 DLL 전체가 RAM에 복사됩니까?
- 보통은 그렇지 않습니다. 같은 이미지의 수정되지 않은 페이지는 각 프로세스의 서로 다른 가상 주소에서 같은 물리 페이지로 매핑됩니다. 쓰기가 필요한 페이지만 Copy-on-Write 등으로 프로세스 전용 물리 페이지가 됩니다.
- CreateFileMapping은 그 순간에 프로세스에 메모리를 할당합니까?
- CreateFileMapping은 파일 매핑 객체를 만들지만, 프로세스의 가상 공간에 보이게 하는 것은 MapViewOfFile입니다. 게다가 뷰의 물리 페이지는 보통 처음 접근한 페이지부터 페이지 폴트로 실체화됩니다.
- FILE_MAP_WRITE와 FILE_MAP_COPY의 차이는 무엇입니까?
- FILE_MAP_WRITE를 통한 변경은 공유된 파일 데이터 쪽에 반영되는 쓰기입니다. FILE_MAP_COPY는 초기 페이지를 공유하지만, 쓴 페이지만 프로세스 전용 복사본이 되고 변경은 원본 파일에 다시 쓰이지 않으며 뷰를 해제하면 사라집니다.
- Copy-on-Write 후에 VirtualQuery는 MEM_PRIVATE를 반환합니까?
- 반환하지 않습니다. 데이터 뷰는 MEM_MAPPED, 이미지 뷰는 MEM_IMAGE로 남습니다. 페이지가 실제로 프로세스 전용이 됐는지는 페이지를 상주시킨 뒤 QueryWorkingSetEx의 Shared 비트를 보면 됩니다.
- 공유 메모리에 생 포인터를 저장해도 됩니까?
- 보통은 피합니다. 같은 섹션이라도 각 프로세스의 뷰가 같은 가상 주소에 놓인다는 보장은 없습니다. 공유 구조에서는 베이스로부터의 오프셋, 고정 폭 정수, 명시적인 레이아웃과 동기화 방식을 씁니다.