공유 메모리의 함정과 실무 베스트 프랙티스
· 업데이트: · Go Komura · Shared Memory, IPC, Concurrency, C++, C#, Windows 개발
수정 이력(9건, 최종 수정 2026년 09월 03일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- Codex 리뷰에 따라 素直의 직역(솔직)을 자연스러운 표현으로 고쳤습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
- permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
- 일본어 원문의 완역으로 다시 번역했습니다. 기존 한국어판은 원문의 일부만 옮긴 축약본이라 절, 표, Mermaid 그림, 그림 설명, FAQ가 빠져 있었습니다. 일본어 원문에 맞춰 이들을 모두 복원했고, 기술적인 주장은 일본어판과 같습니다. 수정 전 버전 보기 (DOI: 10.5281/zenodo.21635185)
- 공유 메모리의 양면성, 초기화 레이스를 피하는 법, 크래시 복구, 통지를 빼내는 법 등을 그림으로도 따라갈 수 있도록, Mermaid 그림을 23점 추가했습니다(본문 500〜750자당 1그림 규약에 맞춘 것입니다). 기존 그림 3개에도 캡션을 붙여 일련번호를 정리했습니다. 본문 문장은 바꾸지 않았습니다.
- 글 맨 앞에 「이 글의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
- 외부 리뷰(1283건) 대응으로 본문을 갱신했습니다. 개별 변경 내용은 아래 이력을 참조하십시오.
- C# 샘플이 `CreateOrOpen`을 쓰던 것을 고쳤습니다. 두 번째 보내는 쪽이, 동작 중인 블록을 그대로 열어 초기화해서, 상대의 주고받기를 깨뜨립니다. 보내는 쪽은 `CreateNew`, 받는 쪽은 `OpenExisting`으로 나눴습니다(C 쪽 `ERROR_ALREADY_EXISTS` 판정과 같은 취지입니다).
- C 샘플에서, `CreateFileMappingW`가 같은 이름의 기존 매핑을 「성공해서」 반환하는 경우를 걸러내지 않던 것을 고쳤습니다. NULL 검사만으로는 두 번째 보내는 쪽을 막지 못하고, 그대로 `memset`으로 진행해 동작 중인 상대의 공유 블록을 지워 버립니다. `ERROR_ALREADY_EXISTS` 판정을 추가했습니다.
- 중간 상태를 읽게 되는 흐름, SPSC 링 버퍼 구조, 더블 버퍼 전환의 그림 3개를 추가했습니다. 최소 왕복 샘플을 C(`CreateFileMapping`과 이벤트 2개)와 C#(`MemoryMappedFile`과 `EventWaitHandle`)으로 새로 넣고, 용어표와 전제를 맨 앞에 두었습니다. 5장과 6장의 중복은 참조로 압축하고, 비유로 적었던 4곳을 구체적인 서술로 고쳤습니다.
- 최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635184)
이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.
Go Komura (2026). 「공유 메모리의 함정과 실무 베스트 프랙티스」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635184 https://comcomponent.com/ko/blog/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가, 같은 시점에 같은 내용으로 보인다는 뜻입니다. 「독자가 항상 일관된 갱신 완료 레코드를 읽을 수 있다」는 의미가 아닙니다 |
| 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 | 락을 잡지 않고 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라고 되어 있습니다. 다만 그것은 독자가 항상 일관된 갱신 완료 레코드를 읽을 수 있다는 의미가 아닙니다.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. 처음에 정해야 할 네 가지
공유 메모리를 설계할 때, 처음에 정해야 할 것은 다음 네 가지입니다.
flowchart TB
accTitle: 처음에 정할 네 가지
accDescr: 공유 메모리를 설계할 때 처음에 정해야 할, control plane과 data plane의 분리, 병행 모델, 소유자와 수명, ABI와 버전의 네 항목을 나타내는 그림.
d0["공유 메모리 설계"] --> d1["plane 분리"]
d0 --> d2["병행 모델"]
d0 --> d3["소유자와 수명"]
d0 --> d4["ABI와 판"]
그림 6: 설계의 처음에, 분리·병행 모델·소유자와 수명·ABI 네 가지를 정한다.
4.1 control plane과 data plane을 나눈다
무엇을 shared memory에 둘지 먼저 정합니다.
- data plane: 이미지, 음성, 레코드 열, 벌크 데이터
- 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·순서·중간 상태 세 점에서 불리하다.
5.3 중간 상태를 읽게 한다
공유 메모리에서 사고 날 때의 겉모습은 꽤 평범합니다.
- 헤더만 새롭다
- 페이로드만 오래됐다
- 길이만 갱신됐다
- 두 필드의 짝이 깨져 있다
그림으로 그리면 사고 나는 방식은 단순합니다. 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: 포인터가 아니라 오프셋으로 참조한다
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(...)) - 비 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만 초기화해 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 오프셋 참조로 한다
참조는 pointer가 아니라 offset으로 둡니다.
base + offset으로 해석한다offset + length범위 검사를 넣는다- invalid value용 sentinel을 정한다
이것만으로 address mismatch 계열 사고가 꽤 줄어듭니다.
6.4 병행 모델을 좁힌다
4.2의 네 모델 중, 처음에 골라야 할 것은 둘 중 하나입니다.
- SPSC ring buffer
- 1 writer / 다 reader의 snapshot
SPSC 링 버퍼는, 고정 길이 슬롯 배열에 대해 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 두 개로 주고받는, 그것뿐입니다.
공통 약속은 이 네 가지입니다.
- 블록은 고정 폭 정수와 고정 길이 배열만. 포인터도
HANDLE도 두지 않는다 - 선두에
magic/abi_version/block_size/state를 둔다 - 본체를 다 쓴 뒤에 길이를 확정하고, 그다음에 event를 세운다
- event 이름은 file mapping과 다른 이름으로 한다(Windows에서는 event / semaphore / mutex / waitable timer / job / file mapping이 이름 공간을 공유하기 때문)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' 을 리틀 엔디언으로 나열한 값 */
#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 검사만으로는 두 번째 보내는 쪽을 걸러내지 못한다. 그대로 아래 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 는 이미 사용 중입니다. 보내는 쪽은 동시에 하나만입니다\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# 쪽에서 다루면 이렇게 됩니다. 오프셋을 상수로 명시하는 것이 요점이며, 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 과 같은 레이아웃을, 오프셋 상수로 고정합니다
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 이면, 두 번째 보내는 쪽이 동작 중인
// 블록을 그대로 열어 버리고, 아래 초기화로 상대의 왕복을 깨뜨린다.
// 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를 고정한다는 것은 이런 뜻입니다.
이 샘플은 의도적으로 1왕복만입니다. 연속 전송으로 만들려면 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를 쓴다
- 뷰에 대해 명시 레이아웃으로 읽는다
- 객체 참조를 그대로 두지 않는다
는 기본은 바뀌지 않습니다.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, “메모리 맵트 파일” / Microsoft Learn, “MemoryMappedFile 클래스” ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, “/volatile (volatile Keyword Interpretation)” / Microsoft Learn, “volatile (C++)” ↩ ↩2
-
Microsoft Learn, “Interlocked Variable Access” / Microsoft Learn, “MemoryBarrier function” ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, “Creating Named Shared Memory” / Microsoft Learn, “이름 있는 공유 메모리 만들기” ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, “Scope of Allocated Memory” ↩ ↩2
-
Microsoft Learn, “CreateFileMappingA function” ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10
-
man7.org, “POSIX Shared Memory” training slides ↩
-
Microsoft Learn, “WaitOnAddress function” ↩ ↩2
-
Microsoft Learn, “MapViewOfFileEx function” / Microsoft Learn, “MapViewOfFile function” ↩
-
Microsoft Learn, “Mutex Objects” ↩ ↩2 ↩3
-
man7.org, “pthread_mutex_lock(3p)” / man7.org, “pthread_mutexattr_setrobust(3)” ↩ ↩2 ↩3
-
man7.org, “pthread_mutex_consistent(3)” / man7.org, “pthread_mutex_consistent(3p)” ↩ ↩2
-
Microsoft Learn, “Kernel object namespaces” ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, “Using Mutex Objects” ↩
-
man7.org, “sem_init(3)” / man7.org, “sem_init(3p)” ↩ ↩2
-
Microsoft Learn, “Critical Section Objects” ↩
-
man7.org, “shm_unlink(3p)” ↩ ↩2
-
man7.org, “shm_open(3)” (shm_unlink semantics) ↩
-
Microsoft Learn, “파일 매핑의 보안과 액세스 권한” / Microsoft Learn, “File Mapping Security and Access Rights” ↩ ↩2
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
Named Pipe 실무 ── Windows 프로세스 간 통신의 정석을 설계부터 보안까지
Windows의 프로세스 간 통신의 정석인 Named Pipe를 실무 관점에서 해설합니다. 바이트/메시지 모드 선택, 여러 클라이언트를 처리하는 서버 설계, ACL과 impersonation 보안, .NET NamedPipeStream까지 1차 ...
Windows 앱에서 자식 프로세스를 안전하게 다루는 체크리스트
Windows 앱에서 자식 프로세스를 안전하게 다루려면, 시작 API보다 프로세스 트리의 소유권과 종료 절차 설계가 중요합니다. Job Object, 종료 전파, 표준 입출력, watchdog을 정리합니다.
C# Native AOT DLL을 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 조사 절차까지를 1차 정...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
공유 메모리, file mapping, MemoryMappedFile을 쓴 대용량 데이터 연계나 프로세스 분리 설계는, Windows 앱 개발과 직결되는 주제입니다.
기술 상담 & 설계 리뷰
동기 방식, ABI 설계, 복구 전략, control plane과 data plane의 분리 등, 사고율을 낮추는 설계 정리는 기술 상담·설계 리뷰와 잘 맞습니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- 공유 메모리에 쓴 값은 다른 프로세스에서 바로 올바르게 읽을 수 있나요?
- 보이는 것과 안전하게 읽을 수 있는 것은 별개입니다. 공유 메모리는 같은 바이트 열을 여러 프로세스에서 보여주는 구조이지, 동기 그 자체가 아닙니다. 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를 두는 것만으로도 사고 조사가 훨씬 쉬워집니다.