공유 메모리를 사용할 때의 함정과 베스트 프랙티스 - 동기, 가시성, 수명, ABI, 보안을 먼저 정리
· 업데이트: · 小村 豪 · Shared Memory, IPC, Concurrency, C++, C#, Windows 개발
수정 이력(1건, 최종 수정 2026년 09월 01일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- 일본어 원문의 완역으로 다시 번역했습니다. 기존 한국어판은 원문의 일부만 옮긴 축약본이라 절, 표, Mermaid 그림, 그림 설명, FAQ가 빠져 있었습니다. 일본어 원문에 맞춰 이들을 모두 복원했고, 기술적인 주장은 일본어판과 같습니다. 수정 전 버전 보기 (DOI: 10.5281/zenodo.21635185)
- 최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635184)
이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.
小村 豪 (2026). 「공유 메모리를 사용할 때의 함정과 베스트 프랙티스 - 동기, 가시성, 수명, ABI, 보안을 먼저 정리」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635184 https://comcomponent.com/ko/blog/2026/03/18/000-shared-memory-pitfalls-best-practices/
- DOI(최신 버전)
- 10.5281/zenodo.21635184
- DOI(이 버전)
- 10.5281/zenodo.22217464
이미지 프레임, 검사 결과, 시계열 로그, 호가 정보, 거대한 버퍼. 같은 머신 안에서 큰 데이터를 저지연으로 주고받고 싶을 때, 공유 메모리는 꽤 매력적입니다.
다만 여기서 조금 위험한 것은, 공유 메모리가 「빠른 IPC」라는 얼굴로 다가온다는 점입니다. 실제로는 공유 메모리는 「복사를 줄일 수 있는 대신, 정합성의 책임을 앱 쪽으로 되돌려주는 IPC」입니다.
- 빠르다
- 유연하다
- 하지만 protocol은 직접 구현
- 사고가 나면 증상이 화려하다
대체로 이 4가지 세트입니다.
flowchart TB
accTitle: 공유 메모리의 두 얼굴
accDescr: 공유 메모리는 빠른 IPC라는 얼굴로 다가오지만, 실제로는 복사를 줄일 수 있는 대신 정합성의 책임을 앱 쪽으로 되돌려주는 IPC임을 보여주는 그림.
f1["「빠른 IPC」라는 얼굴"] --> f2["실제 모습"]
f2 --> f3["복사를 줄일 수 있다"]
f2 --> f4["정합성의 책임은 앱 쪽"]
f4 -.-> f5["protocol은 직접 구현·사고 나면 증상이 화려"]
그림 1: 공유 메모리는 「빠른 IPC」의 얼굴로 다가와, 정합성의 책임을 앱 쪽으로 되돌려준다.
이 글에서는 Windows의 file mapping과 POSIX shm_open / mmap을 염두에 두고, 공유 메모리를 실무에서 사용할 때 막히는 지점과 사고율을 낮추는 설계를 정리합니다.
C/C++이든 C#의 MemoryMappedFile이든, 본질은 거의 같습니다.1
대상 독자와 전제
같은 머신 안의 프로세스 사이에서 큰 데이터를 주고받는 설계를, 이제부터 정하려는 개발자를 위해 쓴 글입니다. C / C++에서 Windows의 file mapping이나 POSIX의 shm_open을 직접 다루는 분을 주된 대상으로 삼고 있지만, C#의 MemoryMappedFile부터 입문하는 분도 같은 함정에 빠집니다. 함정과 설계 지침을 다루는 장(5장·6장)은 언어에 의존하지 않는 내용입니다.
동작하는 샘플은 6.9에 두었고, C(Windows / MSVC)와 C# 양쪽을 실었습니다. POSIX 쪽은 API 이름의 대응 관계만 7장의 표로 보여줍니다.
먼저 짚어 둘 용어
본문에는 영어 그대로 등장하는 용어가 몇 가지 있습니다. 처음 나올 때 걸리지 않도록 먼저 정리해 둡니다.
| 용어 | 의미 |
|---|---|
| IPC (Inter-Process Communication) | 프로세스 간 통신. 다른 프로세스와 데이터나 신호를 주고받는 구조 전반을 가리킵니다. pipe, socket, named pipe, 공유 메모리 등이 포함됩니다 |
| coherent | 같은 실체를 가리키는 여러 view가, 같은 시점에 같은 내용으로 보이는 것입니다. 「reader가 항상 일관된 갱신 완료 레코드를 읽을 수 있다」는 의미가 아닙니다 |
| ABI (Application Binary Interface) | 소스 코드가 아니라, 실행 파일끼리 지켜야 할 바이너리 수준의 약속입니다. 타입의 크기, alignment, padding, 구조체의 배열 순서 등이 포함됩니다 |
| SPSC / MPSC / SPMC / MPMC | producer와 consumer의 수를 나타내는 약칭입니다. S는 single, M은 multi, P는 producer, C는 consumer입니다. SPSC라면 writer 1, reader 1입니다. 4.2에서 자세히 다룹니다 |
| lock-free | lock을 잡지 않고 atomic 연산만으로 진행하는 구조입니다. 「어느 스레드 하나는 반드시 전진할 수 있다」는 진행 보증을 가리키는 말이며, 「빠르다」와는 별개의 성질입니다 |
| sentinel | 「무효」나 「종단」을 나타내기 위해 예약해 두는 특별한 값입니다. offset이라면 「UINT64_MAX는 무효」라고 정해 두는 식으로 씁니다 |
| NUMA (Non-Uniform Memory Access) | CPU에서 본 메모리까지의 거리가 균일하지 않은 구성입니다. 먼 노드의 메모리를 건드리면 같은 코드라도 눈에 띄게 느려집니다 |
1. 먼저 결론(한마디로)
먼저 꽤 거칠지만 실무에 도움이 되는 말로 정리하면 이렇습니다.
- 공유 메모리는 같은 바이트열을 여러 프로세스에서 보여주는 구조일 뿐, 동기화 그 자체는 아닙니다23
- 빠른 것은 큰 데이터를 같은 머신 안에서 주고받을 때입니다. 작은 제어 메시지만이라면 pipe / socket / named pipe / queue 쪽이 편한 경우가 꽤 많습니다
- 공유 메모리에서는 보이는 것과 안전하게 읽을 수 있는 것이 별개의 문제입니다
volatile은 설계의 토대로 삼지 않는 편이 좋습니다. 원자성, 순서, 대기는 따로 생각합니다45- 생 포인터,
HANDLE, file descriptor,std::string,std::vector,std::mutex를 그대로 두면 대체로 나중에 후회하게 됩니다 - 공유 메모리에 두는 데이터는 고정 폭 정수 + 명시적 레이아웃 + 버전이 붙은 헤더로 기울이는 편이 안전합니다
- 선두 헤더에 magic / version / size / state / generation / heartbeat를 두는 것만으로 사고 조사의 용이함이 꽤 달라집니다
- 공유 메모리의 난관은 속도가 아니라 초기화, 수명, 복구, 권한, ABI입니다
- Windows라면
CreateFileMapping/OpenFileMapping/MapViewOfFile, POSIX라면shm_open/ftruncate/mmap이 뼈대입니다63 - 가장 사고 나기 어려운 것은 SPSC(single-producer single-consumer)의 링 버퍼나 더블 버퍼부터 시작하는 것입니다
요컨대 공유 메모리는 빠르지만, 대충 쓰면 「알아서 동기화되고 있는 것 같은 착각」에 빠지는 병에 걸립니다. 이것을 피하는 것이 첫 승부입니다.
flowchart TB
accTitle: 보이는 것과 안전하게 읽을 수 있는 것의 차이
accDescr: 공유 메모리는 같은 바이트열을 여러 프로세스에서 보여주는 구조일 뿐 동기화 그 자체가 아니며, 보이는 것과 안전하게 읽을 수 있는 것은 별개의 문제임을 보여주는 그림.
k1["같은 바이트열을 보여주는 구조"] --> k2["동기화 그 자체는 아니다"]
k2 --> k3["보이는 것"]
k2 --> k4["안전하게 읽을 수 있는 것"]
k3 --> k5["별개 문제로 설계한다"]
k4 --> k5
그림 2: 공유 메모리에서는 「보이는 것」과 「안전하게 읽을 수 있는 것」이 별개의 문제가 된다.
이 글의 지식 맵
공유 메모리는 Windows의 CreateFileMapping/MapViewOfFile이나 POSIX의 shm_open/mmap을 사용해 같은 물리 페이지를 여러 프로세스에서 보이게 하는 구조이지만, 동기화 자체는 아니므로 동기화 없이 여러 필드를 읽고 쓰면 writer가 쓰는 도중 상태를 읽어 버리는 사고가 일어날 수 있습니다. 이 사고는 SPSC 링 버퍼나 더블 버퍼에 의한 commit 프로토콜로 막고, 생 포인터나 HANDLE 같은 process-local 자원은 그대로 둘 수 없으므로 오프셋 참조로 바꾸며, control plane과 data plane을 분리해 알림은 별도 채널로 보내는 것이 권장됩니다. 이에 더해 ABI 고정, creator만 초기화하는 운영에 따른 초기화 레이스 회피, Windows의 abandoned mutex나 POSIX의 robust mutex를 사용한 크래시 복구 설계, 네임스페이스·권한 구성, 세대별 크기 고정이 사고율을 낮추는 실무상의 핵심입니다.
flowchart LR
accTitle: 공유 메모리 함정과 베스트 프랙티스 지식 맵
accDescr: 공유 메모리는 바이트열을 공유할 뿐 동기화 자체는 아니라는 점, 도중 상태 읽기·초기화 레이스·크래시 복구·ABI 불일치·false sharing 같은 함정, control plane과 data plane의 분리나 SPSC 링 버퍼·더블 버퍼·오프셋 참조 같은 대책의 관계를 보여주는 그림
shared_memory["공유 메모리"]
windows_createfilemapping["CreateFileMapping/MapViewOfFile"]
posix_shm_open["shm_open/ftruncate/mmap"]
memory_mapped_file["파일 매핑(메모리 매핑 파일)"]
torn_read["중간 상태 읽기"]
spsc_ring_buffer["SPSC 링 버퍼"]
double_buffering_commit["더블 버퍼링 기반 커밋 프로토콜"]
control_plane_data_plane_separation["control plane과 data plane 분리"]
shared_memory_header["공유 메모리 고정 헤더"]
offset_reference["오프셋 참조"]
process_local_resource["process-local 자원"]
shared_memory_abi["공유 메모리의 ABI 설계"]
initialization_race["초기화 레이스"]
creator_joiner_initialization["creator만 초기화하는 방식"]
crash_recovery_design["크래시 복구 설계"]
abandoned_mutex["abandoned 상태의 mutex(Windows)"]
robust_mutex["robust mutex(POSIX)"]
false_sharing["false sharing과 캐시 라인 경합"]
shared_memory_namespace_permission["네임스페이스와 권한(Global/Local)"]
fixed_generation_sizing["세대별 크기 고정 운용"]
remote_machine_sharing["다른 머신과의 공유"]
notification_channel_separation["알림을 별도 채널로 분리하는 설계"]
busy_loop_polling["busy loop 기반 폴링 알림"]
named_pipe["명명된 파이프"]
shared_memory -.->|"이용한다"| windows_createfilemapping
shared_memory -.->|"이용한다"| posix_shm_open
windows_createfilemapping -->|"이용한다"| memory_mapped_file
shared_memory -.->|"원인이 될 수 있다"| torn_read
spsc_ring_buffer -.->|"방지한다"| torn_read
double_buffering_commit -.->|"방지한다"| torn_read
control_plane_data_plane_separation -->|"권장되는 대응"| shared_memory
shared_memory_header -->|"권장되는 대응"| shared_memory
offset_reference -->|"권장되는 대응"| shared_memory
process_local_resource -->|"양립하지 않는다"| shared_memory
shared_memory -->|"전제로 한다"| shared_memory_abi
shared_memory -.->|"원인이 될 수 있다"| initialization_race
creator_joiner_initialization -->|"방지한다"| initialization_race
crash_recovery_design -->|"권장되는 대응"| shared_memory
crash_recovery_design -.->|"이용한다"| abandoned_mutex
crash_recovery_design -.->|"이용한다"| robust_mutex
shared_memory -.->|"원인이 될 수 있다"| false_sharing
shared_memory -.->|"에서 구성할 수 있다"| shared_memory_namespace_permission
fixed_generation_sizing -->|"권장되는 대응"| shared_memory
shared_memory -->|"양립하지 않는다"| remote_machine_sharing
windows_createfilemapping -->|"양립하지 않는다"| remote_machine_sharing
notification_channel_separation -->|"권장되는 대응"| shared_memory
busy_loop_polling -->|"사용은 비권장"| shared_memory
notification_channel_separation -.->|"이용한다"| named_pipe
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 24건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
2. 공유 메모리는 무엇을 공유하고, 무엇을 공유하지 않는가
공유 메모리는 대략 말해 같은 물리 페이지를 여러 프로세스의 가상 주소 공간에 매핑하는 구조입니다.
Windows에서는 file mapping object와 view를 사용하고, POSIX에서는 shared memory object를 mmap합니다.273
여기서 중요한 것은 두 가지입니다.
- 공유되는 것은 내용의 바이트열이지, 가상 주소 그 자체가 아니다
- coherent하다는 것과 동기화되어 있다는 것은 별개다
flowchart TB
accTitle: 같은 물리 페이지를 서로 다른 view로 본다
accDescr: 공유 메모리는 같은 물리 페이지를 여러 프로세스의 가상 주소 공간에 매핑하는 구조로, 공유되는 것은 내용의 바이트열이지 가상 주소 그 자체가 아님을 보여주는 그림.
pa["프로세스 A의 view"] --> pp["같은 물리 페이지"]
pb["프로세스 B의 view"] --> pp
pp -.-> pn["공유되는 것은 바이트열이며, 가상 주소가 아니다"]
그림 3: 공유되는 것은 같은 물리 페이지의 바이트열이며, 가상 주소 그 자체가 아니다.
Windows 문서에서도 같은 file mapping object로 만든 view는 같은 시점에 coherent하다고 되어 있습니다. 다만 그것은 reader가 항상 일관된 갱신 완료 레코드를 읽을 수 있다는 의미가 아닙니다.8
예를 들어,
- writer가
length - 이어서
payload - 이어서
ready flag
순서로 쓸 생각이어도, reader 쪽이 아무 동기 없이 읽으면 새로운 length와 오래된 payload를 조합해서 보는 경우가 있습니다.
공유 메모리는 이 부분을 자동으로 고쳐주지 않습니다.
즉, 공유 메모리가 공유하는 것은 바이트입니다. 공유하지 않는 것은 의미, 순서, 완료 통지, 복구 방침입니다. 이 부분은 전부 앱 쪽에서 설계해야 합니다.
flowchart TB
accTitle: 공유 메모리가 공유하는 것과 공유하지 않는 것
accDescr: 공유 메모리가 공유하는 것은 바이트뿐이며, 의미·순서·완료 통지·복구 방침은 공유되지 않아 앱 쪽에서 설계해야 함을 보여주는 그림.
s1["공유 메모리"] --> s2["공유하는 것은 바이트"]
s1 --> s3["공유하지 않는 것"]
s3 --> s4["의미·순서"]
s3 --> s5["완료 통지·복구 방침"]
s4 --> s6["앱 쪽에서 설계한다"]
s5 --> s6
그림 4: 바이트는 공유되지만, 의미·순서·완료 통지·복구 방침은 직접 설계해야 한다.
3. 공유 메모리가 어울리는 상황 / 어울리지 않는 상황
| 상황 | 적합 여부 | 이유 |
|---|---|---|
| 같은 머신 안에서 큰 프레임이나 버퍼를 전달한다 | 적합 | 복사 횟수를 줄이기 쉽다 |
| 고빈도 센서값, 이미지, 음성, 호가 정보 등 | 적합 | 저지연·고처리량을 노리기 쉽다 |
| 작은 커맨드나 응답만 주고받는다 | 별로 적합하지 않음 | 제어를 위한 동기 비용이 상대적으로 무겁다 |
| 다른 머신과 주고받는다 | 부적합 | 공유 메모리는 기본적으로 같은 호스트를 전제로 한다 |
| 서로 다른 언어·버전이 장기간 공존한다 | 어렵다 | ABI와 버저닝 설계가 필요하다 |
| 영속화도 필요하다 | 목적에 따라 다름 | file-backed mapping이 유력하지만, 영속화와 IPC의 책무가 뒤섞이기 쉽다 |
실무에서는 제어는 메시지계, 데이터 본체는 공유 메모리라는 분리가 꽤 강력합니다. 예를 들어,
- UI 프로세스 → worker 프로세스에 「다음 프레임을 사용해」라고 통지하는 것은 event / pipe / socket
- 실제 프레임 본체는 공유 메모리
라는 구성입니다. 이 편이 꽤 평화롭습니다.
flowchart TB
accTitle: 제어는 메시지계, 데이터 본체는 공유 메모리
accDescr: UI 프로세스에서 worker 프로세스로 보내는 다음 프레임을 사용하라는 통지는 event나 pipe나 socket으로 보내고, 실제 프레임 본체는 공유 메모리로 전달하는 분리 구성을 보여주는 그림.
ui["UI 프로세스"] -->|"통지(event / pipe / socket)"| wk["worker 프로세스"]
ui -.->|"프레임 본체를 쓴다"| shm["공유 메모리"]
wk -.->|"프레임 본체를 읽는다"| shm
그림 5: 통지는 메시지계로 보내고, 프레임 본체만 공유 메모리에 두는 구성.
4. 처음에 결정해야 할 4가지
공유 메모리를 설계할 때 처음에 결정해야 할 것은 다음 4가지입니다.
flowchart TB
accTitle: 처음에 결정할 4가지
accDescr: 공유 메모리를 설계할 때 처음에 결정해야 할 control plane과 data plane의 분리, 병렬 모델, 소유자와 수명, ABI와 버전이라는 4개 항목을 보여주는 그림.
d0["공유 메모리 설계"] --> d1["plane의 분리"]
d0 --> d2["병렬 모델"]
d0 --> d3["소유자와 수명"]
d0 --> d4["ABI와 버전"]
그림 6: 설계 초반에 분리·병렬 모델·소유자와 수명·ABI라는 4가지를 정한다.
4.1 control plane과 data plane을 나눈다
무엇을 shared memory에 둘지 먼저 정합니다.
- data plane: 이미지, 음성, 레코드 열, bulk 데이터
- control plane: 시작, 정지, 에러, 재접속, 재초기화, 통지
이 두 가지를 나누는 것만으로 shared memory 쪽의 설계가 꽤 단순해집니다.
4.2 병렬 모델을 좁힌다
- SPSC: 1 producer / 1 consumer
- MPSC: 다수 writer / 1 consumer
- SPMC: 1 writer / 다수 reader
- MPMC: 다수 writer / 다수 reader
난이도는 대체로 이 순서로 올라갑니다. 처음부터 MPMC로 가는 것은 그다지 권장하지 않습니다. writer끼리의 배타 제어와 메모리 순서를 동시에 다뤄야 하고, 테스트로는 재현하기 어려운 결함이 나중에 나타납니다.
flowchart TB
accTitle: 병렬 모델의 난이도
accDescr: SPSC, MPSC, SPMC, MPMC 순서로 대체로 난이도가 올라가며, 처음부터 MPMC로 가면 배타 제어와 메모리 순서를 동시에 다뤄야 함을 보여주는 그림.
m1["SPSC(1 쓰기 1 읽기)"] --> m2["MPSC(다수 쓰기 1 읽기)"]
m2 --> m3["SPMC(1 쓰기 다수 읽기)"]
m3 --> m4["MPMC(다수 쓰기 다수 읽기)"]
m4 -.-> m5["배타 제어와 메모리 순서를 동시에 다룬다"]
그림 7: 병렬 모델의 난이도는 SPSC에서 MPMC로 대체로 이 순서로 올라간다.
4.3 소유자와 수명을 정한다
- 누가 만드는가
- 누가 초기화하는가
- 누가 지우는가
- 참가자가 도중에 죽었을 때, 누가 복구시키는가
이 부분이 애매하면 기동 순서나 재기동할 때마다 동작이 달라져, 원인 파악이 어려워집니다.
4.4 ABI와 버전을 정한다
- 레이아웃
- 타입 크기
- alignment
- reserved 영역
- version / feature flags
- 호환성 유무
shared memory는 API가 아니라 ABI(binary interface)의 문제입니다. 여기를 대충 하면 소스 호환은 있는데 실행 시에만 망가지는 골치 아픈 사고가 됩니다.
flowchart TB
accTitle: shared memory는 ABI의 문제
accDescr: shared memory는 API가 아니라 바이너리 수준의 약속인 ABI의 문제이므로, 여기를 대충 하면 소스 호환은 있는데 실행 시에만 망가지는 사고가 됨을 보여주는 그림.
a1["shared memory"] --> a2["API가 아니라 ABI의 약속"]
a2 --> a3["여기를 대충 하면"]
a3 --> a4["소스 호환이어도 실행 시 망가진다"]
그림 8: shared memory는 ABI의 약속이며, 대충 하면 소스 호환이어도 실행 시에 망가진다.
5. 자주 있는 함정
5.1 동기화하지 않는다
가장 많은 것이 이것입니다.
「같은 메모리를 보고 있으니 쓰면 읽을 수 있겠지」
읽을 수 있는 경우는 있습니다. 하지만 그것은 올바른 타이밍에, 올바른 단위로, 올바른 순서로 읽을 수 있다는 뜻은 아닙니다.
Windows에서도 POSIX에서도, 공유 메모리 접근은 별도의 동기 수단과 함께 쓰는 것이 전제입니다. Windows 설명에서도 공유 view 접근은 mutex / semaphore / event 등으로 협조하도록 쓰여 있습니다.2 POSIX 설명에서도 shared memory 접근에는 동기화가 필요합니다.9
flowchart TB
accTitle: 「쓰면 읽을 수 있겠지」라는 함정
accDescr: 같은 메모리를 보고 있으니 읽을 수 있는 경우는 있어도, 올바른 타이밍·단위·순서로 읽을 수 있는 보증은 별개이며, 공유 메모리 접근은 동기 수단과 함께 쓰는 것이 전제임을 보여주는 그림.
g1["「쓰면 읽을 수 있겠지」"] --> g2["읽을 수 있는 경우는 있다"]
g2 --> g3["올바른 타이밍·단위·순서는 별개"]
g3 --> g4["동기 수단과 함께 쓰는 것이 전제"]
g4 -.-> g5["mutex / semaphore / event 등"]
그림 9: 읽을 수 있다는 것과 올바른 단위·순서로 읽을 수 있다는 것은 별개이며, 동기 수단이 전제가 된다.
5.2 volatile로 어떻게든 해보려 한다
volatile은 공유 메모리 설계를 구해주는 마법이 아닙니다.
적어도 atomicity와 mutual exclusion은 별개의 문제입니다.45
예를 들어 volatile bool ready;를 두고 busy loop로 감시하는 설계는
- CPU를 낭비한다
- payload와 ready의 순서 보증이 애매해진다
- portable하지 않다
- 도중 상태를 줍기 쉽다
는 식으로, 대체로 좋을 게 없습니다.
게다가 Windows의 WaitOnAddress는 같은 프로세스 내의 thread를 위한 것입니다.
cross-process 대기 기구로는 생각하지 않는 편이 안전합니다.10
flowchart TB
accTitle: volatile로 감시하는 설계의 문제
accDescr: volatile bool을 busy loop로 감시하는 설계는 CPU를 낭비하고 순서 보증이 애매하며 도중 상태를 줍기 쉬우므로 설계의 토대로 삼지 않는 편이 좋음을 보여주는 그림.
v1["volatile bool을 busy loop로 감시"] --> v2["CPU를 낭비한다"]
v1 --> v3["순서 보증이 애매하다"]
v1 --> v4["도중 상태를 줍기 쉽다"]
v2 --> v5["설계의 토대로 삼지 않는다"]
v3 --> v5
v4 --> v5
v5 -.-> v6["WaitOnAddress도 같은 프로세스 내 thread용"]
그림 10: volatile의 busy loop는 CPU·순서·도중 상태의 3가지 면에서 불리하다.
5.3 도중 상태를 읽게 한다
공유 메모리에서 사고가 날 때의 겉모습은 상당히 평범합니다.
- 헤더만 새롭다
- payload만 오래됐다
- 길이만 갱신되어 있다
- 두 필드의 조합이 깨져 있다
그림으로 그리면 사고가 나는 방식은 단순합니다. writer가 length와 payload를 다 쓰기 전에, reader가 그 틈으로 들어가는 것뿐입니다.
sequenceDiagram
participant W as writer 프로세스
participant M as 공유 메모리
participant R as reader 프로세스
W->>M: length에 1024를 쓴다
Note over M: length는 새롭지만<br/>payload는 아직 오래됐다
R->>M: length를 읽는다
M-->>R: 1024
R->>M: payload를 1024바이트 읽는다
M-->>R: 이전 세대의 내용
Note over R: 「헤더만 새롭다」는<br/>어중간한 상태를 붙잡는다
W->>M: payload를 쓴다
W->>M: ready flag를 세운다
그림 11: writer가 payload를 다 쓰기 전에 reader가 틈으로 들어가 도중 상태를 붙잡는다.
이 틈은 length와 payload의 쓰기가 「하나의 불가분한 조작이 아닌」 이상 반드시 존재합니다. 단일 scalar를 atomic으로 갱신하는 것만이라면 이야기가 비교적 단순하지만, 여러 필드로 이루어진 레코드를 공개하려면 commit 절차가 필요합니다.
전형적으로는 다음 중 하나입니다.
- mutex로 통째로 지킨다
- 더블 버퍼로 만들어 마지막에 「지금의 유효 버퍼 번호」를 전환한다
- 링 버퍼로 만들어 slot마다 state / sequence를 갖는다
- 1 writer / 다수 reader라면 sequence counter로 snapshot을 뜬다
「마지막에 ready flag를 세운다」만으로는, 그 flag를 어떤 메모리 순서로 쓰고 읽을지를 정하지 않으면 설계로서는 아직 허술합니다. 공유 메모리에서는 공개 타이밍 자체가 프로토콜입니다.
5.4 포인터나 복잡한 객체를 그대로 둔다
이것도 자주 나오는 패턴입니다.
- 생 포인터
HANDLE- file descriptor
std::stringstd::vectorstd::unordered_mapstd::mutexCRITICAL_SECTION
이런 것들을 shared memory에 그대로 두고, 다른 프로세스에서 사용하려는 패턴입니다. 읽는 쪽 프로세스에서는 거의 틀림없이 접근 위반이 나거나 무의미한 값이 됩니다.
이유는 단순합니다. 가상 주소나 process-local한 자원은 그 process의 문맥에서만 의미가 있기 때문입니다. Windows의 view도, 같은 mapping을 다른 process에서 map해도 가상 주소가 일치한다고는 할 수 없습니다.711
그래서 참조가 필요하다면 베이스 주소로부터의 offset으로 갖는 것이 기본입니다.
typedef struct ShmRef {
uint64_t offset; // 세그먼트 선두로부터의 상대 위치
uint32_t length;
uint32_t kind;
} ShmRef;
이렇게 하면 각 process가 base + offset으로 자신의 주소로 변환할 수 있습니다.
flowchart TB
accTitle: 포인터가 아니라 offset으로 참조한다
accDescr: 가상 주소나 process-local한 자원은 그 프로세스의 문맥에서만 의미가 있으므로 참조는 베이스 주소로부터의 offset으로 갖고, 각 프로세스가 base+offset으로 해석함을 보여주는 그림.
p1["생 포인터나 HANDLE을 둔다"] --> p2["다른 프로세스에서는 무의미한 값"]
p2 -.->|"대신"| p3["offset으로 갖는다"]
p3 --> p4["각 프로세스가 base+offset으로 해석"]
그림 12: 참조는 생 포인터가 아니라, 베이스 주소로부터의 offset으로 갖는다.
5.5 ABI가 깨진다
shared memory는 소스 코드가 아니라 바이너리의 약속입니다. 즉, 다음 차이가 전부 영향을 줍니다.
int/long의 크기bool의 표현enum의 underlying typewchar_t의 크기- 32bit / 64bit의 차이
#pragma pack- compiler / language의 차이
- alignment / padding
- little-endian / big-endian
같은 호스트 안이라면 endianness는 대체로 맞춰져 있지만, ARM64 대응이나 mixed toolchain이 들어가는 것만으로도 상당히 흔하게 어긋납니다.
그래서 shared memory에 두는 구조는 다음을 강하게 권장합니다.
uint32_t/uint64_t같은 고정 폭 정수- 명시적인 padding / reserved
- header에
version,header_size,record_size,total_size - 필요하면
static_assert(sizeof(...)) - non-trivial한 object를 두지 않는다
flowchart TB
accTitle: ABI를 깨는 차이와 방지 방법
accDescr: 타입 크기나 alignment, pack이나 padding, 32bit와 64bit의 차이 등이 바이너리의 약속을 깨뜨리므로, 고정 폭 정수와 명시적 레이아웃, 버전이 붙은 헤더로 기울여 방지함을 보여주는 그림.
b1["타입 크기의 차이"] --> b4["바이너리의 약속이 깨진다"]
b2["pack이나 padding의 차이"] --> b4
b3["32bit / 64bit의 차이"] --> b4
b4 --> b5["고정 폭 정수+명시 레이아웃"]
b5 --> b6["version과 size를 header에 둔다"]
그림 13: 타입 크기나 padding의 차이가 ABI를 깨뜨리므로, 고정 폭 정수와 명시 레이아웃으로 기울인다.
5.6 초기화 레이스
shared memory는 「만든 쪽이 초기화했을 것」이라는 생각으로 인해 깨지기 쉽습니다.
Windows에서는 CreateFileMapping이 기존 이름에 해당하면 기존 오브젝트를 반환하고, GetLastError()로 ERROR_ALREADY_EXISTS를 알 수 있습니다.
pagefile-backed한 mapping의 초기 페이지는 0으로 시작합니다.8
POSIX에서는 새로운 shared memory object는 처음에는 길이 0이며, ftruncate로 크기를 붙입니다. 새로 확보된 바이트는 0으로 초기화됩니다. O_CREAT | O_EXCL에 의한 create는 원자적입니다.3
이 차이를 모른 채,
- open하면 바로 사용한다
- 초기화 완료 플래그가 없다
- 참가자가 동시에 초기화한다
- version mismatch를 확인하지 않는다
를 하면 기동 순서에 따라 깨집니다.
최소한 선두 헤더에 다음 state를 두는 편이 좋습니다.
INITIALIZINGREADYBROKEN
그리고 creator만 초기화하고, joiner는 READY를 기다립니다.
이 규칙만으로도 상황이 꽤 조용해집니다.
flowchart TB
accTitle: 초기화 레이스를 피하는 방법
accDescr: 선두 헤더에 INITIALIZING, READY, BROKEN이라는 state를 두고, creator만 초기화해서 state를 READY로 세우며, joiner는 READY를 기다린 뒤 사용하는 방식으로 초기화 레이스를 피함을 보여주는 그림.
c1["creator가 만든다"] --> c2["creator만 초기화한다"]
c2 --> c3["state를 READY로 만든다"]
j1["joiner는 open해도 바로 쓰지 않는다"] --> j2["READY를 기다린다"]
c3 -.-> j2
j2 --> j3["사용을 시작한다"]
그림 14: creator만 초기화하고, joiner는 헤더의 READY를 기다린 뒤 사용한다.
5.7 크래시 복구를 생각하지 않는다
writer가 공유 데이터를 갱신하던 중에 죽으면 어떻게 할 것인가. 이 부분을 정의하지 않은 채로 프로덕션에 내놓으면, 장애가 났을 때의 상황이 갑자기 심각해집니다.
Windows의 mutex는 소유 thread가 release하지 않고 종료되면 abandoned 상태가 되고, wait 쪽은 WAIT_ABANDONED를 받을 수 있습니다. 이것은 공유 자원이 불확정 상태일 수도 있다는 뜻입니다.12
POSIX의 robust mutex에서도 owner가 죽었을 때 EOWNERDEAD가 반환되고, 복구 후 pthread_mutex_consistent()를 호출하는 흐름이 있습니다.1314
중요한 것은 여기서 「일단 계속 진행」하지 않는 것입니다. 복구에는 적어도 다음 중 하나가 필요합니다.
- generation 번호
- 마지막 commit 완료 sequence
- heartbeat
- dirty / clean flag
- journal 같은 2단계 commit
- 손상 시의 전체 재초기화 절차
flowchart TB
accTitle: writer가 갱신 중에 죽었을 때
accDescr: 소유자가 release하지 않고 종료되면 Windows에서는 WAIT_ABANDONED, POSIX의 robust mutex에서는 EOWNERDEAD가 반환되어 공유 자원이 불확정 상태일 수 있으므로, 일단 계속 진행하지 말고 복구 수단으로 넘어감을 보여주는 그림.
w1["writer가 갱신 중에 죽는다"] --> w2["WAIT_ABANDONED(Windows)"]
w1 --> w3["EOWNERDEAD(POSIX robust)"]
w2 --> w4["공유 자원이 불확정 상태일 수 있다"]
w3 --> w4
w4 --> w5["일단 계속 진행하지 않는다"]
w5 -.-> w6["generation / heartbeat / 2단계 commit / 재초기화"]
그림 15: 소유자가 죽으면 불확정 상태를 의심하고, 계속 진행하지 말고 복구 절차로 넘어간다.
5.8 false sharing과 캐시 라인 경합
shared memory는 빠르다고 이야기되기 쉽습니다. 하지만 hot한 카운터가 같은 cache line에 몰려 있으면 CPU 사이에서 line이 왔다 갔다 하면서, 무시할 수 없을 만큼 느려집니다.
전형적인 예는
- producer가
write_index를 갱신 - consumer가
read_index를 갱신 - 둘 다 같은 cache line에 올라가 있다
라는 경우입니다.
이런 경우에는
- hot한 field를 별도의 cache line으로 나눈다
- 갱신 빈도가 높은 field와 낮은 field를 나눈다
- 1 writer 1 cache line을 의식한다
만으로도 꽤 달라집니다. 64 bytes로 맞추는 이야기가 자주 나오지만, 64 bytes는 많은 CPU에서 흔히 쓰이는 값일 뿐 절대 법칙은 아니다 정도로 봐 주세요.
flowchart TB
accTitle: false sharing의 전형과 대책
accDescr: producer가 갱신하는 write_index와 consumer가 갱신하는 read_index가 같은 cache line에 올라가면 CPU 사이에서 line이 오가며 느려지므로, hot한 field를 별도의 cache line으로 나눔을 보여주는 그림.
fp["producer가 write_index를 갱신"] --> fl["같은 cache line"]
fc["consumer가 read_index를 갱신"] --> fl
fl --> fs["line이 CPU 사이를 오가며 느려진다"]
fs --> fx["hot한 field를 별도 cache line으로 나눈다"]
그림 16: hot한 카운터가 같은 cache line에 올라가면 느려지므로, 별도 line으로 나눈다.
5.9 이름·권한·보안을 가볍게 본다
named shared memory는 편리하지만, 이름과 권한을 대충 다루면 사고가 납니다.
Windows에서는
Global\와Local\이라는 namespace가 있다- session 0 이외에서
Global\의 file mapping을 새로 만들려면SeCreateGlobalPrivilege가 필요하다 - object name은 event / semaphore / mutex / waitable timer / job과 namespace를 공유한다
즉,
"Global\\MyApp"으로 하면 service와 desktop app에서 공유할 수 있을 것 같다- 그런데 권한 때문에 실패한다
- 게다가 같은 이름의 mutex를 먼저 만들어 놓아서
ERROR_INVALID_HANDLE이 된다
같은, 상당히 Windows다운 진흙탕에 빠지게 됩니다.
flowchart TB
accTitle: Global 네임스페이스의 진흙탕
accDescr: service와 desktop app에서 공유하려고 Global 이름을 쓰면 SeCreateGlobalPrivilege가 없어 권한으로 실패하거나, 네임스페이스를 공유하는 같은 이름의 mutex가 먼저 있어서 실패함을 보여주는 그림.
n1["Global 이름으로 공유하려 한다"] --> n2["권한으로 실패한다"]
n1 --> n3["같은 이름의 mutex와 충돌한다"]
n2 -.-> n4["새로 만들려면 SeCreateGlobalPrivilege가 필요"]
n3 -.-> n5["ERROR_INVALID_HANDLE이 된다"]
그림 17: Global 네임스페이스는 권한과 이름 충돌이라는 두 종류의 진흙탕에 빠지기 쉽다.
POSIX 쪽에서도 shm_open의 mode나 umask를 가볍게 보면 불필요하게 넓게 보이거나, 반대로 열리지 않기도 합니다.3
shared memory는 그냥 메모리라서 안전한 것이 아닙니다. 읽을 수 있는 권한이 있는 process에서는 상당히 순순히 보입니다. 기밀 정보를 두려면 보통의 메모리와 마찬가지로 paging / swap / dump / 권한의 맥락에서 생각해야 합니다.
5.10 크기 변경과 업그레이드를 대충 한다
공유 메모리를 「나중에 조금 넓히고 싶다」는 것은 꽤 위험한 요구입니다.
- Windows의 mapping object에는 작성 시점의 크기가 있습니다8
- POSIX에서도
ftruncate와mmap의 정합을 생각하지 않으면, 참가자 쪽의 map 길이와 맞지 않게 됩니다316
실무에서는 크기는 그 세대 안에서는 불변으로 하는 편이 안전합니다. 확장이 필요하다면
- 새로운 version / name / generation의 segment를 만든다
- 참가자를 전환한다
- 이전 segment를 닫는다
쪽이 사고율이 낮아집니다.
flowchart TB
accTitle: 크기 확장은 세대 전환으로 한다
accDescr: 공유 메모리의 크기는 그 세대 안에서는 불변으로 하고, 확장이 필요하면 새로운 세대의 segment를 만들어 참가자를 전환한 뒤 이전 segment를 닫는 절차가 사고율을 낮춤을 보여주는 그림.
z0["나중에 넓히고 싶다"] --> z1["새로운 세대의 segment를 만든다"]
z1 --> z2["참가자를 전환한다"]
z2 --> z3["이전 segment를 닫는다"]
z0 -.-> z4["resize in place는 위험하다"]
그림 18: 크기는 세대 안에서 고정하고, 확장은 새로운 세대의 segment로 전환해서 처리한다.
5.11 통지까지 전부 shared memory에 밀어넣는다
자주 보이는 것이
- 공유 메모리에
ready = 1 - 상대는
while (!ready) Sleep(1);
입니다.
이것, 처음에는 동작합니다. 하지만 나중에
- CPU를 낭비한다
Sleep(1)로 지연이 흔들린다- 놓친 것을 알아차리기 어렵다
- 타임아웃이나 종료 통지를 깔끔하게 쓰기 어렵다
라는 형태로 돌아옵니다.
공유 메모리는 데이터 면에 집중시키고, 통지는 기다릴 수 있는 primitive로 옮기는 편이 좋습니다.
- Windows: event / semaphore / mutex / named pipe 등217
- POSIX: semaphore / process-shared mutex + condvar 등1819
flowchart TB
accTitle: 통지는 기다릴 수 있는 primitive로 옮긴다
accDescr: 공유 메모리의 플래그를 Sleep으로 감시하는 통지는 CPU 낭비와 지연 흔들림, 놓침으로 이어지므로 공유 메모리는 데이터 면에 집중시키고 통지는 event나 semaphore 같은 기다릴 수 있는 primitive로 옮김을 보여주는 그림.
t1["ready flag를 Sleep으로 감시"] --> t2["CPU 낭비·지연 흔들림"]
t1 -.-> t6["놓침을 알아차리기 어렵다"]
t2 --> t3["통지는 기다릴 수 있는 primitive로"]
t3 --> t4["Windows는 event / semaphore 등"]
t3 --> t5["POSIX는 semaphore / condvar 등"]
그림 19: 플래그 감시가 아니라, 기다릴 수 있는 primitive로 통지를 받는다.
5.12 「이걸로 다른 머신과도 공유할 수 있다」고 생각한다
file-backed mapping을 써서 네트워크 너머의 공유 파일을 map하면, 다른 머신과도 shared memory처럼 갈 수 있지 않을까 생각하고 싶어지는 순간이 있습니다.
여기는 위험합니다.
Windows의 CreateFileMapping 설명에서도 remote file에 대해서는 coherence가 보증되지 않는다고 되어 있습니다.
같은 페이지를 두 대가 writable하게 map한 경우, 각각 자신의 쓰기만 보이고 디스크 갱신 시에 merge도 되지 않습니다.8
공유 메모리는 기본적으로 같은 호스트 안의 구조입니다. 머신을 넘나든다면 순순히 socket / RPC / message broker를 선택하는 편이 정신 건강을 유지하기 쉽습니다.
flowchart TB
accTitle: 다른 머신과의 공유는 하지 않는다
accDescr: 네트워크 너머의 공유 파일을 map해도 remote file에는 coherence가 보증되지 않으며, 공유 메모리는 같은 호스트 안의 구조이므로 머신을 넘나든다면 socket이나 RPC나 message broker를 선택함을 보여주는 그림.
r1["remote file을 map해서 공유하려 한다"] --> r2["coherence가 보증되지 않는다"]
r2 --> r3["공유 메모리는 같은 호스트 안의 구조"]
r3 --> r4["socket / RPC / message broker로"]
그림 20: 공유 메모리는 같은 호스트 안의 구조이며, 머신을 넘나든다면 메시지계를 선택한다.
6. 베스트 프랙티스
6.1 control plane과 data plane을 나눈다
4.1에서 정한 분리를, 구현 수준의 할당까지 내리면 이렇게 됩니다(실패 형태는 5.11을 참조).
- shared memory: frame, sample, batch, snapshot
- event / semaphore / pipe / socket: ready, consumed, stop, error, reconnect
이 분리는 성능보다 먼저 설계의 전망을 좋게 합니다.
6.2 선두에 고정 헤더를 둔다
최소한 선두에 이런 헤더를 두는 것을 강하게 권장합니다.
typedef struct SharedHeader {
uint32_t magic;
uint16_t abi_version;
uint16_t header_size;
uint32_t state; // 0=initializing, 1=ready, 2=broken
uint32_t flags;
uint64_t total_size;
uint64_t generation;
uint64_t heartbeat_ns;
uint64_t payload_offset;
uint64_t payload_size;
uint64_t write_seq;
uint64_t read_seq;
uint8_t reserved[64];
} SharedHeader;
포인트는
magic으로 다른 것이나 미초기화를 걸러낸다abi_version과header_size로 layout 차이를 걸러낸다state로 초기화 도중을 걸러낸다generation으로 재생성을 감지한다heartbeat로 생사를 확인한다reserved로 미래 확장의 여지를 만든다
입니다.
shared memory에서 힘든 것은 「무슨 일이 일어나고 있는지 보이지 않는다」는 점입니다. 그래서 관측용 metadata를 처음부터 갖추게 합니다.
flowchart TB
accTitle: 고정 헤더 각 필드의 역할
accDescr: 선두 헤더의 magic으로 다른 것이나 미초기화를 걸러내고, abi_version과 header_size로 layout 차이를 걸러내며, state로 초기화 도중을 걸러내고, generation으로 재생성을 감지하고, heartbeat로 생사를 확인하는 역할 분담을 보여주는 그림.
h0["선두의 고정 헤더"] --> h1["magic으로 다른 것을 걸러낸다"]
h0 --> h2["version으로 차이를 걸러낸다"]
h0 --> h3["state로 도중을 걸러낸다"]
h1 --> h4["관측용 metadata가 된다"]
h2 --> h4
h3 --> h4
h0 -.-> h5["generation으로 재생성 감지"]
h5 -.-> h6["heartbeat로 생사를 확인"]
그림 21: 고정 헤더의 각 필드가 각각 다른 것·차이·초기화 도중을 걸러낸다.
6.3 offset 참조로 만든다
참조는 pointer가 아니라 offset으로 갖습니다.
base + offset으로 해결한다offset + length의 범위 체크를 넣는다- invalid value용 sentinel을 정한다
이것만으로도 address mismatch 계통의 사고가 꽤 줄어듭니다.
6.4 병렬 모델을 좁힌다
4.2의 4가지 모델 중에서 처음에 선택해야 할 것은 다음 둘 중 하나입니다.
- SPSC ring buffer
- 1 writer / 다수 reader의 snapshot
SPSC 링 버퍼는 고정 길이 slot의 배열에 대해, producer가 write_seq 위치에 쓰고 consumer가 read_seq 위치부터 읽기만 하는 구조입니다. writer도 reader도 하나뿐이므로 진행 방향이 일방통행이 됩니다.
flowchart LR
subgraph ring["링 버퍼 8슬롯"]
direction LR
s0["slot 0<br/>읽기 완료"]
s1["slot 1<br/>읽기 완료"]
s2["slot 2<br/>미읽음"]
s3["slot 3<br/>미읽음"]
s4["slot 4<br/>쓰는 중"]
s5["slot 5<br/>비어 있음"]
s6["slot 6<br/>비어 있음"]
s7["slot 7<br/>비어 있음"]
end
C["consumer<br/>read_seq = 2<br/>다 읽고 나서 진행"] --> s2
P["producer<br/>write_seq = 4<br/>다 쓰고 나서 진행"] --> s4
s7 -.->|"끝까지 오면 slot 0으로 돌아간다"| s0
그림 22: SPSC 링 버퍼의 구조. producer와 consumer가 각자의 index를 일방통행으로 진행시킨다.
포인트는 「다 쓴 뒤에 index를 진행한다」, 「다 읽은 뒤에 index를 진행한다」는 순서를 무너뜨리지 않는 것입니다. 그리고 write_seq와 read_seq는 5.8과 마찬가지로 별도의 cache line에 둡니다.
다수 writer가 필요하다면
- enqueue만 lock-free / atomic으로 한다
- 실제 데이터 갱신은 consumer 하나로 집약한다
처럼 정합성 책임 지점을 줄이는 편이 대체로 잘 됩니다.
6.5 commit protocol을 명시한다
「어느 순간부터 읽어도 되는가」를 문장으로 설명할 수 없는 설계는 위험합니다.
예를 들어 더블 버퍼라면
- 비공개 쪽 버퍼에 쓴다
- 체크섬이나 길이를 확정한다
- release를 붙여 active buffer index를 전환한다
- reader는 acquire를 붙여 active index를 읽는다
- 다 읽은 뒤 index가 바뀌지 않았는지 확인한다
처럼 공개 의식을 정합니다. 그림으로 그리면 전환되는 순간이 단 한 곳뿐이라는 것이 분명해집니다.
sequenceDiagram
participant W as writer
participant BA as 버퍼 A
participant IX as active index
participant BB as 버퍼 B
participant R as reader
Note over IX: active index는 A
R->>IX: acquire로 읽는다
IX-->>R: A
R->>BA: 버퍼 A를 읽는다
W->>BB: 비공개 쪽인 B에 쓴다
W->>BB: 길이와 체크섬을 확정한다
W->>IX: release로 B로 전환한다
Note over IX: active index는 B
R->>IX: 다 읽고 나서 index를 다시 확인한다
IX-->>R: B로 바뀌어 있었다
Note over R: 읽은 내용은 버리고<br/>B부터 다시 읽는다
그림 23: 더블 버퍼의 공개 의식. reader는 다 읽은 뒤 active index를 다시 확인한다.
이 「다 읽은 뒤 index를 다시 확인한다」는 절차를 생략하면, reader가 읽는 도중에 writer가 같은 버퍼를 다음 쓰기에 재사용해 버려서 5.3과 같은 어중간한 상태가 됩니다. 다만 버퍼가 2면밖에 없으면 다시 읽는 도중에 또 전환될 수 있으므로, 갱신이 빠른 경우에는 면 수를 늘리거나 5.3에서 소개한 sequence counter 방식으로 옮겨 갑니다.
6.6 크기는 세대마다 고정한다
resize in place보다,
name = MyShm.v3abi_version = 3generation = 42
처럼 세대를 나누는 편이 유지보수하기 쉽습니다.
공유 메모리는 API처럼 「호출 시에 타입 체크」를 해주지 않습니다. 그래서 한 번 정한 ABI를 깨뜨리지 않는 것이 중요합니다.
6.7 관측 가능성을 넣는다
최소한 다음이 있으면 도움이 됩니다.
- 마지막 갱신 시각
- 마지막 성공 sequence
- drop 수 / overwrite 수
- version mismatch 수
- attach / detach 수
- last error code
- heartbeat
shared memory가 깨질 때는 대체로 로그가 부실합니다. 직접 counters를 두면 장애 대응이 꽤 편해집니다.
6.8 비정상 경로 테스트를 먼저 만든다
정상 경로만으로는 부족합니다. 적어도 다음은 확인하는 편이 좋습니다.
- writer 갱신 중에 강제 종료
- reader 지연으로 ring이 넘친다
- version mismatch로 접속
- 32bit / 64bit 혼재
- session을 넘나든 open
- 권한 부족
- 이전 프로세스가 오래된 세대를 가진 채 재기동
- huge data 연속 전송 시의 cache miss / NUMA 영향
shared memory는 정상 경로보다 망가뜨리는 방식 테스트 쪽이 가치가 큽니다.
6.9 최소한의 왕복 샘플
여기까지의 규칙을, 동작하는 최소 구성으로 내려 보겠습니다. Windows의 pagefile-backed file mapping에 고정 레이아웃 블록을 하나 두고, 통지는 auto-reset event 2개로 주고받는 구성입니다.
공통의 약속은 다음 4가지입니다.
- 블록은 고정 폭 정수와 고정 길이 배열뿐. 포인터도
HANDLE도 두지 않는다 - 선두에
magic/abi_version/block_size/state를 둔다 - 본체를 다 쓴 뒤에 길이를 확정하고, 그 다음에 event를 세운다
- event 이름은 file mapping과 다른 이름으로 한다(Windows에서는 event / semaphore / mutex / waitable timer / job / file mapping이 namespace를 공유하기 때문)815
sequenceDiagram
accTitle: 최소 샘플의 왕복
accDescr: 송신 측이 초기화를 마친 뒤 본체를 쓰고 길이를 확정해서 event를 세우면, 수신 측은 event를 기다린 뒤 ABI와 state를 확인하고 길이를 범위 체크해서 읽으며, 같은 순서로 응답을 돌려주는 왕복을 보여주는 그림.
participant W as 송신 측
participant M as 공유 블록
participant R as 수신 측
W->>M: 초기화하고 state를 READY로
W->>M: 본체를 다 쓴 뒤 길이를 확정
W->>R: request의 event를 세운다
R->>M: magic / version / state를 확인한다
R->>M: 길이를 범위 체크해서 읽는다
R->>M: 응답도 본체를 쓴 뒤 길이를 확정
R->>W: reply의 event를 세운다
그림 24: 최소 샘플의 왕복. 본체를 다 쓴 뒤 길이를 확정하고, 그 다음에 통지한다.
먼저 송신 측입니다.
/* shm_writer.c : 송신 측. 먼저 이쪽을 실행합니다.
* cl /W4 /nologo shm_writer.c (kernel32.lib는 기본으로 링크됩니다) */
#include <windows.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#define SHM_NAME L"Local\\KsShmDemo.v1.Block"
#define EVT_REQ L"Local\\KsShmDemo.v1.Request"
#define EVT_REP L"Local\\KsShmDemo.v1.Reply"
#define SHM_MAGIC 0x314D4853u /* 'S','H','M','1'을 little-endian으로 나열한 값 */
#define SHM_ABI 1u
#define STATE_INITIALIZING 0u
#define STATE_READY 1u
#pragma pack(push, 8)
typedef struct DemoBlock {
uint32_t magic;
uint32_t abi_version;
uint32_t block_size;
uint32_t state;
uint32_t request_len;
uint32_t reply_len;
char request[256];
char reply[256];
} DemoBlock; /* 24 + 256 + 256 = 536바이트 */
#pragma pack(pop)
int main(void)
{
HANDLE hMap = NULL, hReq = NULL, hRep = NULL;
DemoBlock *blk = NULL;
uint32_t len = 0;
DWORD waited = 0;
int rc = 1;
/* 1. pagefile-backed mapping을 만든다. 초기 페이지는 0으로 시작한다.
* 같은 이름이 이미 있으면 CreateFileMappingW는 「성공해서 기존 것을 반환」하므로,
* NULL 체크만으로는 2번째 송신 측을 막을 수 없다. 그대로 아래의 memset으로
* 넘어가면, 동작 중인 상대의 공유 블록을 지워 버린다.
* GetLastError()는 성공 시에도 설정되므로, 바로 뒤에서 읽을 것. */
hMap = CreateFileMappingW(INVALID_HANDLE_VALUE, NULL, PAGE_READWRITE,
0, (DWORD)sizeof(DemoBlock), SHM_NAME);
if (hMap == NULL) {
printf("CreateFileMapping failed: %lu\n", GetLastError());
goto cleanup;
}
if (GetLastError() == ERROR_ALREADY_EXISTS) {
printf("%ls은(는) 이미 사용 중입니다. 송신 측은 동시에 1개뿐입니다\n", SHM_NAME);
goto cleanup;
}
blk = (DemoBlock *)MapViewOfFile(hMap, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(DemoBlock));
if (blk == NULL) {
printf("MapViewOfFile failed: %lu\n", GetLastError());
goto cleanup;
}
/* 2. 통지용 event. mapping과는 다른 이름으로 한다 */
hReq = CreateEventW(NULL, FALSE, FALSE, EVT_REQ); /* auto-reset / 비시그널 */
hRep = CreateEventW(NULL, FALSE, FALSE, EVT_REP);
if (hReq == NULL || hRep == NULL) {
printf("CreateEvent failed: %lu\n", GetLastError());
goto cleanup;
}
/* 3. 만든 쪽만 초기화한다. state는 마지막에 세운다 */
memset(blk, 0, sizeof(*blk));
blk->magic = SHM_MAGIC;
blk->abi_version = SHM_ABI;
blk->block_size = (uint32_t)sizeof(DemoBlock);
blk->state = STATE_INITIALIZING;
MemoryBarrier();
blk->state = STATE_READY;
/* 4. 본체 → 배리어 → 길이 → 통지 순서를 무너뜨리지 않는다 */
strcpy_s(blk->request, sizeof(blk->request), "ping from writer");
MemoryBarrier();
blk->request_len = (uint32_t)strlen(blk->request);
if (!SetEvent(hReq)) {
printf("SetEvent failed: %lu\n", GetLastError());
goto cleanup;
}
/* 5. 응답을 기다린다. 무한 대기로 하지 않는다 */
waited = WaitForSingleObject(hRep, 5000);
if (waited == WAIT_TIMEOUT) {
printf("reader로부터 응답이 없습니다\n");
goto cleanup;
}
if (waited != WAIT_OBJECT_0) {
printf("WaitForSingleObject failed: %lu\n", GetLastError());
goto cleanup;
}
/* 6. 읽기 전에 길이를 범위 체크한다 */
len = blk->reply_len;
if (len > sizeof(blk->reply)) {
printf("reply_len이 범위를 벗어났습니다: %u\n", len);
goto cleanup;
}
printf("reply: %.*s\n", (int)len, blk->reply);
rc = 0;
cleanup:
/* view와 handle을 전부 닫으면 이름째로 사라진다. reader보다 먼저 죽지 않는다 */
if (hRep != NULL) CloseHandle(hRep);
if (hReq != NULL) CloseHandle(hReq);
if (blk != NULL) UnmapViewOfFile(blk);
if (hMap != NULL) CloseHandle(hMap);
return rc;
}
수신 측은 SHM_NAME부터 DemoBlock까지의 정의를 송신 측과 완전히 동일하게 맞춘 뒤, main만 교체합니다. 실무에서는 이 공통 부분을 헤더 파일로 분리합니다.
/* shm_reader.c : 수신 측. 상수와 DemoBlock의 정의는 shm_writer.c와 동일.
* cl /W4 /nologo shm_reader.c */
int main(void)
{
HANDLE hMap = NULL, hReq = NULL, hRep = NULL;
DemoBlock *blk = NULL;
uint32_t len = 0;
int rc = 1;
hMap = OpenFileMappingW(FILE_MAP_ALL_ACCESS, FALSE, SHM_NAME);
if (hMap == NULL) {
printf("OpenFileMapping failed: %lu / writer는 실행 중입니까\n", GetLastError());
goto cleanup;
}
blk = (DemoBlock *)MapViewOfFile(hMap, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(DemoBlock));
if (blk == NULL) {
printf("MapViewOfFile failed: %lu\n", GetLastError());
goto cleanup;
}
hReq = CreateEventW(NULL, FALSE, FALSE, EVT_REQ);
hRep = CreateEventW(NULL, FALSE, FALSE, EVT_REP);
if (hReq == NULL || hRep == NULL) {
printf("CreateEvent failed: %lu\n", GetLastError());
goto cleanup;
}
/* 1. 먼저 통지를 기다린다. writer는 초기화를 마친 뒤에만 세운다 */
if (WaitForSingleObject(hReq, 5000) != WAIT_OBJECT_0) {
printf("request가 오지 않았습니다\n");
goto cleanup;
}
/* 2. 건드리기 전에 ABI와 state를 확인한다 */
if (blk->magic != SHM_MAGIC || blk->abi_version != SHM_ABI ||
blk->block_size != (uint32_t)sizeof(DemoBlock)) {
printf("ABI가 일치하지 않습니다: magic=%08X abi=%u size=%u\n",
blk->magic, blk->abi_version, blk->block_size);
goto cleanup;
}
if (blk->state != STATE_READY) {
printf("아직 초기화가 끝나지 않았습니다: state=%u\n", blk->state);
goto cleanup;
}
/* 3. 길이를 범위 체크한 뒤 읽는다 */
len = blk->request_len;
if (len > sizeof(blk->request)) {
printf("request_len이 범위를 벗어났습니다: %u\n", len);
goto cleanup;
}
printf("request: %.*s\n", (int)len, blk->request);
/* 4. 본체 → 배리어 → 길이 → 통지는 writer와 같은 순서 */
strcpy_s(blk->reply, sizeof(blk->reply), "pong from reader");
MemoryBarrier();
blk->reply_len = (uint32_t)strlen(blk->reply);
if (!SetEvent(hRep)) {
printf("SetEvent failed: %lu\n", GetLastError());
goto cleanup;
}
rc = 0;
cleanup:
if (hRep != NULL) CloseHandle(hRep);
if (hReq != NULL) CloseHandle(hReq);
if (blk != NULL) UnmapViewOfFile(blk);
if (hMap != NULL) CloseHandle(hMap);
return rc;
}
MemoryBarrier는 본체를 다 쓰기 전에 길이나 플래그만 먼저 보여 버리는 재배치를 막기 위해 넣은 것입니다.5
레이아웃 어긋남을 빌드 시점에 잡아내고 싶다면, MSVC에 /std:c11을 붙이고 <assert.h>의 static_assert로 sizeof(DemoBlock)을 고정합니다.
같은 블록을 C# 쪽에서 다루면 이렇게 됩니다. offset을 상수로 명시하고 있는 것이 핵심으로, C 쪽 구조체와 1바이트도 어긋나지 않도록 작성했습니다. 이름 붙은 MemoryMappedFile과 EventWaitHandle은 Windows 전용입니다.1
// .NET 8 / Windows. 송신 측은 dotnet run -- write, 수신 측은 dotnet run -- read
using System.IO.MemoryMappedFiles;
using System.Text;
const string MapName = "Local\\KsShmDemo.v1.Block";
const string ReqName = "Local\\KsShmDemo.v1.Request";
const string RepName = "Local\\KsShmDemo.v1.Reply";
const uint Magic = 0x314D4853; // 'S','H','M','1'
const uint Abi = 1;
const int BlockSize = 536;
const int MaxBody = 256;
// C 쪽 DemoBlock과 같은 레이아웃을 offset 상수로 고정합니다
const int OffMagic = 0, OffAbi = 4, OffBlockSize = 8, OffState = 12;
const int OffRequestLen = 16, OffReplyLen = 20, OffRequest = 24, OffReply = 280;
bool isWriter = args.Length > 0 && args[0] == "write";
// 송신 측은 CreateNew를 사용한다. CreateOrOpen이면 2번째 송신 측이 동작 중인
// 블록을 그대로 열어 버려서, 아래의 초기화가 상대의 교신을 망가뜨린다.
// CreateNew는 같은 이름이 이미 있으면 IOException이 되므로 거기서 알아챌 수 있다
// (C 쪽의 ERROR_ALREADY_EXISTS 판정과 같은 취지입니다)
using var mmf = isWriter
? MemoryMappedFile.CreateNew(MapName, BlockSize)
: MemoryMappedFile.OpenExisting(MapName);
using var view = mmf.CreateViewAccessor(0, BlockSize);
using var reqEvent = new EventWaitHandle(false, EventResetMode.AutoReset, ReqName);
using var repEvent = new EventWaitHandle(false, EventResetMode.AutoReset, RepName);
if (isWriter)
{
view.Write(OffMagic, Magic);
view.Write(OffAbi, Abi);
view.Write(OffBlockSize, (uint)BlockSize);
Thread.MemoryBarrier();
view.Write(OffState, 1u); // READY
byte[] request = Encoding.UTF8.GetBytes("ping from C#");
view.WriteArray(OffRequest, request, 0, request.Length);
Thread.MemoryBarrier();
view.Write(OffRequestLen, (uint)request.Length);
reqEvent.Set();
if (!repEvent.WaitOne(TimeSpan.FromSeconds(5)))
{
Console.WriteLine("reader로부터 응답이 없습니다");
return 1;
}
return PrintBody(OffReplyLen, OffReply, "reply");
}
if (!reqEvent.WaitOne(TimeSpan.FromSeconds(5)))
{
Console.WriteLine("request가 오지 않았습니다");
return 1;
}
if (view.ReadUInt32(OffMagic) != Magic || view.ReadUInt32(OffAbi) != Abi
|| view.ReadUInt32(OffBlockSize) != BlockSize || view.ReadUInt32(OffState) != 1u)
{
Console.WriteLine("ABI가 일치하지 않거나, 아직 초기화가 끝나지 않았습니다");
return 1;
}
if (PrintBody(OffRequestLen, OffRequest, "request") != 0)
{
return 1;
}
byte[] reply = Encoding.UTF8.GetBytes("pong from C#");
view.WriteArray(OffReply, reply, 0, reply.Length);
Thread.MemoryBarrier();
view.Write(OffReplyLen, (uint)reply.Length);
repEvent.Set();
return 0;
int PrintBody(int lenOffset, int bodyOffset, string label)
{
uint length = view.ReadUInt32(lenOffset);
if (length > MaxBody)
{
Console.WriteLine($"{label}의 길이가 범위를 벗어났습니다: {length}");
return 1;
}
byte[] body = new byte[length];
view.ReadArray(bodyOffset, body, 0, body.Length);
Console.WriteLine($"{label}: {Encoding.UTF8.GetString(body)}");
return 0;
}
C 버전과 C# 버전은 이름도 레이아웃도 같으므로, 어느 한쪽을 송신 측, 다른 쪽을 수신 측으로 해도 왕복합니다. ABI를 고정한다는 것은 이런 것입니다.
이 샘플은 의도적으로 한 번의 왕복만 하도록 만들었습니다. 연속 전송으로 만들려면 6.4의 링 버퍼로, writer의 이상 종료를 견디게 하려면 5.7의 generation과 heartbeat로 각각 발전시켜 보세요.
7. Windows와 POSIX에서 보는 포인트
| 관점 | Windows | POSIX |
|---|---|---|
| 작성 / open | CreateFileMapping / OpenFileMapping / MapViewOfFile6 |
shm_open / ftruncate / mmap3 |
| 디스크와 연동하지 않는 공유 | INVALID_HANDLE_VALUE를 지정한 pagefile-backed mapping68 |
POSIX shared memory object + mmap3 |
| 초기값 | pagefile-backed pages는 0으로 초기화8 | 신규 object는 길이 0. 새로 확보된 바이트는 0으로 초기화3 |
| 동기 | mutex / semaphore / event / interlocked 등25 | process-shared mutex / condvar / semaphore2018 |
| cross-process에서 사용해서는 안 되는 것 | CRITICAL_SECTION, WaitOnAddress2110 |
PTHREAD_PROCESS_PRIVATE 그대로의 mutex / condvar2019 |
| owner death | WAIT_ABANDONED12 |
robust mutex + EOWNERDEAD / pthread_mutex_consistent()1314 |
| name 삭제 | 마지막 handle / view 해제로 사라진다28 | shm_unlink로 이름 삭제. 참조가 남아 있으면 실체는 끝까지 남는다2223 |
| namespace / 권한 | Global\ / Local\, ACL, SeCreateGlobalPrivilege1524 |
mode, umask, 네임스페이스, O_CREAT|O_EXCL3 |
C#의 MemoryMappedFile도 본질적으로는 Windows의 file mapping을 감싼 것입니다.
그래서
- 같은 이름으로 open한다
- 별도로 mutex / event를 사용한다
- view에 대해 명시 레이아웃으로 읽는다
- 오브젝트 참조를 그대로 두지 않는다
라는 기본은 변하지 않습니다.1
flowchart TB
accTitle: MemoryMappedFile도 같은 기본
accDescr: C#의 MemoryMappedFile은 본질적으로 Windows의 file mapping을 감싼 것이므로, 같은 이름으로 open하고 별도로 mutex나 event를 사용하며 명시 레이아웃으로 읽고 오브젝트 참조를 두지 않는다는 기본은 변하지 않음을 보여주는 그림.
cs["C#의 MemoryMappedFile"] --> fm["file mapping을 감싼 것"]
fm --> q1["같은 이름으로 open"]
fm --> q2["동기는 별도로 mutex / event"]
fm --> q3["명시 레이아웃으로 읽는다"]
q3 -.-> q4["오브젝트 참조는 그대로 두지 않는다"]
그림 25: C#의 MemoryMappedFile에서도, file mapping과 같은 기본이 그대로 통한다.
8. 먼저 확인할 체크리스트
- 정말로 공유 메모리가 필요한가. 같은 호스트에서 큰 데이터인가
- control plane과 data plane을 나눴는가
- 병렬 모델은 SPSC / 1 writer 다수 reader까지 낮출 수 없는가
- 선두 헤더에 magic / version / size / state / generation / heartbeat가 있는가
- pointer /
HANDLE/ fd / STL object /std::mutex를 두고 있지 않은가 - reader가 도중 상태를 보지 않는 commit protocol이 있는가
- 초기화하는 쪽이 한 사람으로 정해져 있는가
- 이상 종료 시의 복구 절차가 있는가
- 이름과 권한을 명시하고 있는가
Global\이 정말로 필요한가- resize in place를 전제로 하고 있지 않은가
- writer kill / reader stall / version mismatch / 권한 부족을 시험해 봤는가
9. 정리
공유 메모리는 잘 쓰면 상당히 강력합니다. 특히
- 이미지
- 음성
- 센서 배열
- 큰 배치
- 고빈도 snapshot
같은 같은 머신 안의 큰 데이터에서는 정말로 효과가 있습니다.
다만 공유 메모리의 본질은 「빠름」보다 책임의 이동입니다. 복사나 커널을 거치는 메시징을 줄이는 대신,
- 동기
- 가시성
- 초기화
- ABI
- 복구
- 권한
- 관측 가능성
을 앱 쪽이 떠맡게 됩니다.
flowchart TB
accTitle: 공유 메모리의 본질은 책임의 이동
accDescr: 복사와 커널을 거치는 메시징을 줄이는 대신 동기, 가시성, 초기화, ABI, 복구, 권한, 관측 가능성을 앱 쪽이 떠맡게 된다는 책임의 이동을 보여주는 그림.
e1["복사와 메시징을 줄인다"] --> e2["대신 떠맡는 것"]
e2 --> e3["동기·가시성·초기화"]
e2 --> e4["ABI·복구·권한"]
e2 --> e5["관측 가능성"]
그림 26: 공유 메모리의 본질은 「빠름」보다, 정합성의 책임을 이쪽으로 옮기는 데 있다.
그래서 첫 번째는 이렇게 하는 것이 안전합니다.
- SPSC ring buffer나 더블 버퍼
- 선두 고정 헤더
- offset 참조
- 별도 채널로 통지
- version / generation / heartbeat 있음
- 비정상 경로 테스트 있음
이 형태로 시작하면 shared memory는 꽤 순한 도구가 됩니다. 반대로 갑자기 「무엇이든 둘 수 있는 빠른 공용 메모리」로 다루면, 점점 앱이 아니라 고고학이 되어 버립니다.
10. 참고 자료
- Windows: file mapping과 named shared memory의 기본682
- Windows: namespace / security / synchronization1524512
- POSIX:
shm_open,shm_unlink,mmap, process-shared / robust synchronization322162013 - .NET:
MemoryMappedFile의 개요1
-
Microsoft Learn, “메모리 매핑 파일” https://learn.microsoft.com/en-us/dotnet/standard/io/memory-mapped-files / Microsoft Learn, “MemoryMappedFile 클래스” https://learn.microsoft.com/en-us/dotnet/api/system.io.memorymappedfiles.memorymappedfile?view=net-10.0 ↩ ↩2 ↩3 ↩4
-
man7.org, “shm_open(3)” https://man7.org/linux/man-pages/man3/shm_open.3.html ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11
-
Microsoft Learn, “/volatile (volatile Keyword Interpretation)” https://learn.microsoft.com/en-us/cpp/build/reference/volatile-volatile-keyword-interpretation?view=msvc-170 / Microsoft Learn, “volatile (C++)” https://learn.microsoft.com/en-us/cpp/cpp/volatile-cpp?view=msvc-170 ↩ ↩2
-
Microsoft Learn, “Interlocked Variable Access” https://learn.microsoft.com/en-us/windows/win32/sync/interlocked-variable-access / Microsoft Learn, “MemoryBarrier function” https://learn.microsoft.com/en-us/windows/win32/api/winnt/nf-winnt-memorybarrier ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, “Creating Named Shared Memory” https://learn.microsoft.com/en-us/windows/win32/memory/creating-named-shared-memory ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, “Scope of Allocated Memory” https://learn.microsoft.com/en-us/windows/win32/memory/scope-of-allocated-memory ↩ ↩2
-
Microsoft Learn, “CreateFileMappingA function” https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-createfilemappinga ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10
-
man7.org, “POSIX Shared Memory” training slides https://man7.org/training/download/ipc_pshm_slides-mkerrisk-man7.org.pdf ↩
-
Microsoft Learn, “WaitOnAddress function” https://learn.microsoft.com/en-us/windows/win32/api/synchapi/nf-synchapi-waitonaddress ↩ ↩2
-
Microsoft Learn, “MapViewOfFileEx function” https://learn.microsoft.com/en-us/windows/win32/api/memoryapi/nf-memoryapi-mapviewoffileex / Microsoft Learn, “MapViewOfFile function” https://learn.microsoft.com/en-us/windows/win32/api/memoryapi/nf-memoryapi-mapviewoffile ↩
-
Microsoft Learn, “Mutex Objects” https://learn.microsoft.com/en-us/windows/win32/sync/mutex-objects ↩ ↩2 ↩3
-
man7.org, “pthread_mutex_lock(3p)” https://man7.org/linux/man-pages/man3/pthread_mutex_lock.3p.html / man7.org, “pthread_mutexattr_setrobust(3)” https://man7.org/linux/man-pages/man3/pthread_mutexattr_setrobust.3.html ↩ ↩2 ↩3
-
man7.org, “pthread_mutex_consistent(3)” https://man7.org/linux/man-pages/man3/pthread_mutex_consistent.3.html / man7.org, “pthread_mutex_consistent(3p)” https://man7.org/linux/man-pages/man3/pthread_mutex_consistent.3p.html ↩ ↩2
-
Microsoft Learn, “Kernel object namespaces” https://learn.microsoft.com/en-us/windows/win32/termserv/kernel-object-namespaces ↩ ↩2 ↩3 ↩4
-
man7.org, “mmap(2)” https://man7.org/linux/man-pages/man2/mmap.2.html ↩ ↩2
-
Microsoft Learn, “Using Mutex Objects” https://learn.microsoft.com/en-us/windows/win32/sync/using-mutex-objects ↩
-
man7.org, “sem_init(3)” https://man7.org/linux/man-pages/man3/sem_init.3.html / man7.org, “sem_init(3p)” https://man7.org/linux/man-pages/man3/sem_init.3p.html ↩ ↩2
-
Microsoft Learn, “Critical Section Objects” https://learn.microsoft.com/en-us/windows/win32/sync/critical-section-objects ↩
-
man7.org, “shm_unlink(3p)” https://man7.org/linux/man-pages/man3/shm_unlink.3p.html ↩ ↩2
-
man7.org, “shm_open(3)” (shm_unlink semantics) https://man7.org/linux/man-pages/man3/shm_open.3.html ↩
-
Microsoft Learn, “File Mapping Security and Access Rights” https://learn.microsoft.com/en-us/windows/win32/memory/file-mapping-security-and-access-rights ↩ ↩2
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
네임드 파이프의 실무 ── Windows 프로세스 간 통신의 정석을 설계부터 보안까지
Windows 프로세스 간 통신의 정석인 네임드 파이프를 실무 관점에서 정리합니다. 바이트/메시지 모드 선택, 여러 클라이언트를 받는 서버 설계, ACL과 위장 보안, .NET NamedPipeStream까지 1차 정보를 바탕으로 설명합니다.
Windows 앱에서 자식 프로세스를 안전하게 다루기 위한 체크리스트 - Job Object, 종료 전파, 표준 입출력, watchdog의 베스트 프랙티스
Windows 앱이 자식 프로세스에 의존할 때, 기동 API보다 프로세스 트리의 소유권과 종료 절차의 설계가 안정성을 좌우합니다. Job Object로 수명을 묶고, 종료 전파를 분리하며, stdout/stderr를 비동기로 흘리고 watchdo...
C#을 Native AOT로 네이티브 DLL로 만드는 방법 - UnmanagedCallersOnly로 C/C++에서 호출하기
C# 클래스 라이브러리를 Native AOT로 네이티브 DLL로 발행하고, UnmanagedCallersOnly 엔트리 포인트를 C/C++에서 호출하는 구성을 활용 시점·구현 패턴·주의점 관점에서 정리합니다.
Win32 스레드 풀 API ── CreateThreadpoolWork로 「스레드를 만들지 않는」병렬 처리
네이티브 코드에서 CreateThread를 여기저기 만들고 있지는 않은가요. Vista에서 개편된 Win32 스레드 풀 API의 work·timer·wait·io 네 객체, 클린업 그룹, 콜백에서 해서는 안 되는 일까지 1차 정보를 바탕으로 해설...
DllMain과 로더 락 ── 「DLL 초기화에서는 아무것도 하지 말라」는 말의 진짜 이유
DllMain에서 LoadLibrary를 호출하거나 다른 스레드와 동기화해서는 안 되는 이유를 설명합니다. 로더 락이 모든 DLL 알림을 직렬화하는 구조, 데드락이 구조적으로 확정되는 고전적 시나리오, 초기화를 미루는 올바른 설계, 행(hang)...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- 공유 메모리에 쓴 값을 다른 프로세스에서 곧바로 정확하게 읽을 수 있습니까?
- 보이는 것과 안전하게 읽을 수 있는 것은 별개의 문제입니다. 공유 메모리는 같은 바이트열을 여러 프로세스에서 보여주는 구조일 뿐, 동기화 그 자체는 아닙니다. writer가 length, payload, ready flag 순서로 쓸 생각이어도 reader 쪽이 아무 동기 없이 읽으면 새로운 length와 오래된 payload를 조합해서 보는 경우가 있습니다. Windows에서도 POSIX에서도 공유 메모리 접근은 mutex·semaphore·event 같은 동기 수단과 함께 쓰는 것이 전제입니다.
- 공유 메모리에 포인터나 std::string, HANDLE을 두어도 됩니까?
- 두지 않는 편이 좋습니다. 가상 주소나 process-local한 자원은 그 프로세스의 문맥에서만 의미가 있고, 같은 mapping을 다른 프로세스에서 map해도 가상 주소가 일치한다고는 할 수 없습니다. std::vector나 std::mutex, CRITICAL_SECTION도 마찬가지입니다. 참조가 필요하다면 베이스 주소로부터의 offset으로 갖고, 공유 메모리에 두는 데이터는 고정 폭 정수 + 명시적 레이아웃 + 버전이 붙은 헤더로 기울이는 것이 안전합니다.
- volatile을 사용하면 공유 메모리의 동기화는 필요 없어집니까?
- 필요 없어지지 않습니다. volatile은 공유 메모리 설계를 구해주는 마법이 아니며, 적어도 atomicity와 mutual exclusion은 별개의 문제입니다. volatile bool을 busy loop로 감시하는 설계는 CPU를 낭비하고, payload와 ready flag의 순서 보증이 애매해지며, 도중 상태를 줍기 쉽다는 문제가 있습니다. 또한 Windows의 WaitOnAddress는 같은 프로세스 내의 thread를 위한 것이라 cross-process 대기 기구로는 생각하지 않는 편이 안전합니다. 통지는 event나 semaphore 같은 기다릴 수 있는 primitive로 옮깁니다.
- 공유 메모리 설계에서 가장 먼저 결정해야 할 것은 무엇입니까?
- 네 가지가 있습니다. 제어(시작·정지·통지)는 메시지계, 데이터 본체는 공유 메모리로 나누는 control plane과 data plane의 분리, 병렬 모델을 좁히는 것(처음에는 SPSC 링 버퍼나 더블 버퍼가 사고 나기 어렵습니다), 누가 만들고 초기화하고 지우고 복구시킬지를 정하는 소유자와 수명, 그리고 레이아웃과 버전을 포함한 ABI 설계입니다. 선두 헤더에 magic·version·size·state·generation·heartbeat를 두는 것만으로 사고 조사의 용이함이 크게 달라집니다.