공유 메모리의 함정과 실무 베스트 프랙티스

· 업데이트: · · 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점 세트입니다.

공유 메모리의 두 얼굴공유 메모리는 빠른 IPC라는 얼굴로 다가오지만, 실제로는 복사를 줄이는 대신 정합성 책임을 앱 쪽으로 되돌려 주는 IPC임을 나타내는 그림.「빠른 IPC」라는 얼굴실제 모습복사를 줄일 수 있다정합성 책임은 앱 쪽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) 링 버퍼더블 버퍼부터 시작하는 것입니다

요컨대 공유 메모리는 빠르지만, 거칠게 쓰면 「제멋대로 동기되어 있는 기분이 드는 병」에 걸린다. 여기를 피하는 것이 첫 승부입니다.

보이는 것과 안전하게 읽을 수 있는 것의 차이공유 메모리는 같은 바이트 열을 여러 프로세스에서 보여주는 구조이지 동기 그 자체가 아니며, 보이는 것과 안전하게 읽을 수 있는 것은 별개 문제임을 나타내는 그림.같은 바이트 열을 보여주는 구조동기 그 자체가 아니다보이는 것안전하게 읽을 수 있는 것별개 문제로 설계한다

그림 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를 사용한 크래시 복구 설계, 네임스페이스·권한 구성, 세대별 크기 고정이 사고율을 낮추는 실무상의 핵심입니다.

공유 메모리 함정과 베스트 프랙티스 지식 맵공유 메모리는 바이트열을 공유할 뿐 동기화 자체는 아니라는 점, 도중 상태 읽기·초기화 레이스·크래시 복구·ABI 불일치·false sharing 같은 함정, control plane과 data plane의 분리나 SPSC 링 버퍼·더블 버퍼·오프셋 참조 같은 대책의 관계를 보여주는 그림이용한다이용한다이용한다원인이 될 수 있다방지한다방지한다권장되는 대응권장되는 대응권장되는 대응양립하지 않는다전제로 한다원인이 될 수 있다방지한다권장되는 대응이용한다이용한다원인이 될 수 있다에서 구성할 수 있다권장되는 대응양립하지 않는다양립하지 않는다권장되는 대응사용은 비권장이용한다공유 메모리CreateFileMapping/MapViewOfFileshm_open/ftruncate/mmap파일 매핑(메모리 매핑 파일)중간 상태 읽기SPSC 링 버퍼더블 버퍼링 기반 커밋 프로토콜control plane과 data plane 분리공유 메모리 고정 헤더오프셋 참조process-local 자원공유 메모리의 ABI 설계초기화 레이스creator만 초기화하는 방식크래시 복구 설계abandoned 상태의 mutex(Windows)robust mutex(POSIX)false sharing과 캐시 라인 경합네임스페이스와 권한(Global/Local)세대별 크기 고정 운용다른 머신과의 공유알림을 별도 채널로 분리하는 설계busy loop 기반 폴링 알림명명된 파이프

그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 24건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle

2. 공유 메모리는 무엇을 공유하고, 무엇을 공유하지 않는가

공유 메모리는 거칠게 말하면 같은 물리 페이지를 여러 프로세스의 가상 주소 공간에 매핑하는 구조입니다. Windows에서는 file mapping object와 view를 쓰고, POSIX에서는 shared memory object를 mmap합니다.273

여기서 중요한 것은 두 가지입니다.

  1. 공유되는 것은 내용의 바이트 열이지, 가상 주소 그 자체가 아니다
  2. coherent인 것동기되어 있는 것은 별개
같은 물리 페이지를 서로 다른 view로 본다공유 메모리는 같은 물리 페이지를 여러 프로세스의 가상 주소 공간에 매핑하는 구조이며, 공유되는 것은 내용의 바이트 열이지 가상 주소 그 자체가 아님을 나타내는 그림.프로세스 A의 view같은 물리 페이지프로세스 B의 view공유되는 것은 바이트 열이고, 가상 주소가 아니다

그림 3: 공유되는 것은 같은 물리 페이지의 바이트 열이지, 가상 주소 그 자체가 아니다.

Windows 문서에서도, 같은 file mapping object에서 만든 view는 같은 시점에 coherent라고 되어 있습니다. 다만 그것은 독자가 항상 일관된 갱신 완료 레코드를 읽을 수 있다는 의미가 아닙니다.8

예를 들어,

  • writer가 length
  • 이어서 payload
  • 이어서 ready flag

순으로 쓸 생각이어도, reader 쪽이 아무 동기도 없이 읽으면 새로운 length와 오래된 payload를 조합해 보게 될 수 있습니다. 공유 메모리는 여기를 자동으로 고쳐 주지 않습니다.

즉, 공유 메모리가 공유하는 것은 바이트입니다. 공유하지 않는 것은 의미, 순서, 완료 통지, 복구 방침입니다. 이 부분은 전부 이쪽에서 설계해야 합니다.

공유 메모리가 공유하는 것·하지 않는 것공유 메모리가 공유하는 것은 바이트뿐이고, 의미·순서·완료 통지·복구 방침은 공유되지 않으므로 앱 쪽에서 설계해야 함을 나타내는 그림.공유 메모리공유하는 것은 바이트공유하지 않는 것의미·순서완료 통지·복구 방침이쪽에서 설계한다

그림 4: 바이트는 공유되지만, 의미·순서·완료 통지·복구 방침은 직접 설계한다.

3. 공유 메모리가 맞는 장면 / 맞지 않는 장면

장면 맞음·맞지 않음 이유
같은 머신 안에서 큰 프레임이나 버퍼를 넘긴다 맞다 복사 횟수를 줄이기 쉽다
고빈도 센서 값, 이미지, 음성, 호가 정보 등 맞다 낮은 레이턴시·높은 처리량을 노리기 쉽다
작은 커맨드나 응답만 주고받는다 그다지 맞지 않는다 제어용 동기 비용이 상대적으로 무겁다
다른 머신과 주고받는다 맞지 않는다 공유 메모리는 기본적으로 같은 호스트가 전제다
다른 언어·다른 버전이 장기 공존한다 어렵다 ABI와 버전 설계가 필요하다
영속화도 필요하다 목적에 따라 file-backed mapping은 유력하지만, 영속화와 IPC 책임이 섞이기 쉽다

실무에서는 제어는 메시지 계열, 데이터 본체는 공유 메모리라는 분리가 꽤 강합니다. 예를 들어,

  • UI 프로세스 → worker 프로세스로 「다음 프레임을 써라」고 알리는 것은 event / pipe / socket
  • 실제 프레임 본체는 공유 메모리

라는 구성입니다. 이쪽이 꽤 평화롭습니다.

제어는 메시지 계열, 데이터 본체는 공유 메모리UI 프로세스에서 worker 프로세스로 「다음 프레임을 써라」는 통지는 event나 pipe나 socket으로 보내고, 실제 프레임 본체는 공유 메모리로 넘기는 분리 구성을 나타내는 그림.통지(event / pipe / socket)프레임 본체를 쓴다프레임 본체를 읽는다UI 프로세스worker 프로세스공유 메모리

그림 5: 통지는 메시지 계열로 보내고, 프레임 본체만 공유 메모리에 두는 구성.

4. 처음에 정해야 할 네 가지

공유 메모리를 설계할 때, 처음에 정해야 할 것은 다음 네 가지입니다.

처음에 정할 네 가지공유 메모리를 설계할 때 처음에 정해야 할, control plane과 data plane의 분리, 병행 모델, 소유자와 수명, ABI와 버전의 네 항목을 나타내는 그림.공유 메모리 설계plane 분리병행 모델소유자와 수명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끼리의 배타와 메모리 순서를 동시에 상대하게 되어, 테스트에서 재현하기 어려운 버그가 나중에 나옵니다.

병행 모델의 난이도SPSC, MPSC, SPMC, MPMC 순으로 대체로 난이도가 올라가며, 처음부터 MPMC로 가면 배타와 메모리 순서를 동시에 상대하게 됨을 나타내는 그림.SPSC(1쓰기 1읽기)MPSC(다쓰기 1읽기)SPMC(1쓰기 다읽기)MPMC(다쓰기 다읽기)배타와 메모리 순서를 동시에 상대한다

그림 7: 병행 모델 난이도는 SPSC에서 MPMC로, 대체로 이 순으로 올라간다.

4.3 소유자와 수명을 정한다

  • 누가 만드는가
  • 누가 초기화하는가
  • 누가 지우는가
  • 참가자가 중간에 죽었을 때, 누가 회복시키는가

여기가 모호하면 기동 순서나 재기동마다 동작이 바뀌어, 원인 분리가 어려워집니다.

4.4 ABI와 버전을 정한다

  • 레이아웃
  • 형 크기
  • alignment
  • reserved 영역
  • version / feature flags
  • 호환성 유무

shared memory는 API가 아니라 ABI(binary interface) 이야기입니다. 여기를 대충 하면, 소스 호환은 있는데 실행 때만 깨지는, 싫은 사고가 됩니다.

shared memory는 ABI 이야기shared memory는 API가 아니라 바이너리 수준의 약속인 ABI 이야기이므로, 여기를 대충 하면 소스 호환은 있는데 실행 때만 깨지는 사고가 됨을 나타내는 그림.shared memoryAPI가 아니라 ABI 약속여기를 대충 하면소스 호환이어도 실행 때 깨진다

그림 8: shared memory는 ABI 약속이며, 대충 하면 소스 호환이어도 실행 때 깨진다.

5. 흔한 함정

5.1 동기하지 않는다

가장 많은 것은 이것입니다.

「같은 메모리를 보고 있으니, 쓰면 읽을 수 있겠지」

읽을 수는 있습니다. 하지만 그것은 올바른 타이밍에, 올바른 단위로, 올바른 순서로 읽을 수 있다는 뜻이 아닙니다.

Windows든 POSIX든, 공유 메모리 접근은 별도의 동기 수단과 조합하는 것이 전제입니다. Windows 설명에서도, 공유 view 접근은 mutex / semaphore / event 등으로 협력하라고 적혀 있습니다.2 POSIX 설명에서도, shared memory 접근에는 동기가 필요합니다.9

「쓰면 읽을 수 있겠지」의 함정같은 메모리를 보고 있으니 읽을 수는 있어도, 올바른 타이밍·단위·순서로 읽는 보장은 별개이며, 공유 메모리 접근은 동기 수단과 조합하는 것이 전제임을 나타내는 그림.「쓰면 읽을 수 있겠지」읽을 수는 있다올바른 타이밍·단위·순서는 별개동기 수단과 조합하는 것이 전제mutex / semaphore / event 등

그림 9: 읽을 수 있는 것과, 올바른 단위·순서로 읽는 것은 별개이며, 동기 수단이 전제가 된다.

5.2 volatile로 어떻게든 해 보려 한다

volatile은 공유 메모리 설계를 구해 주는 마법이 아닙니다. 적어도 atomicitymutual exclusion은 별개 문제입니다.45

예를 들어 volatile bool ready;를 두고 busy loop하는 설계는,

  • CPU를 낭비한다
  • payload와 ready의 순서 보장이 모호해진다
  • portable하지 않다
  • 중간 상태를 집어 올리기 쉽다

로, 대체로 좋은 일이 없습니다.

게다가 Windows의 WaitOnAddress같은 프로세스 안의 thread용입니다. cross-process 대기 기구로는 보지 않는 편이 안전합니다.10

volatile로 감시하는 설계의 문제volatile bool을 busy loop로 감시하는 설계는 CPU를 낭비하고, 순서 보장이 모호하며, 중간 상태를 집어 올리기 쉬우므로 설계의 토대로 두지 않는 편이 좋음을 나타내는 그림.volatile bool을 busy loop로 감시한다CPU를 낭비한다순서 보장이 모호하다중간 상태를 집어 올리기 쉽다설계의 토대로 두지 않는다WaitOnAddress도 같은 프로세스 안의 thread용

그림 10: volatile의 busy loop는 CPU·순서·중간 상태 세 점에서 불리하다.

5.3 중간 상태를 읽게 한다

공유 메모리에서 사고 날 때의 겉모습은 꽤 평범합니다.

  • 헤더만 새롭다
  • 페이로드만 오래됐다
  • 길이만 갱신됐다
  • 두 필드의 짝이 깨져 있다

그림으로 그리면 사고 나는 방식은 단순합니다. writer가 lengthpayload를 다 쓰기 전에, reader가 그 틈으로 들어가는 것뿐입니다.

reader 프로세스공유 메모리writer 프로세스reader 프로세스공유 메모리writer 프로세스length는 새것payload는 아직 옛것「헤더만 새롭다」어중간한 상태를 잡는다length에 1024를 쓴다length를 읽는다1024payload를 1024바이트 읽는다이전 세대의 내용payload를 쓴다ready flag를 세운다

그림 11: writer가 payload를 다 쓰기 전에 reader가 틈으로 들어와, 중간 상태를 잡는다.

이 틈은 lengthpayload 쓰기가 「하나의 불가분 조작이 아닌」 이상 반드시 존재합니다. 단일 scalar를 atomic으로 갱신하는 것뿐이라면 이야기는 비교적 단순하지만, 여러 필드로 이루어진 레코드를 공개한다면 commit 절차가 필요합니다.

전형적으로는 다음 중 하나입니다.

  • mutex로 통째로 지킨다
  • 더블 버퍼로 만들고 마지막에 「지금 유효한 버퍼 번호」를 전환한다
  • 링 버퍼로 만들고 slot마다 state / sequence를 둔다
  • 1 writer / 다 reader라면 sequence counter로 snapshot을 잡는다

「마지막에 ready flag를 세운다」만으로도, 그 flag를 어떤 메모리 순서로 쓸지 / 읽을지를 정하지 않으면 설계로서는 아직 헐겁습니다. 공유 메모리에서는 공개 타이밍 그 자체가 프로토콜입니다.

5.4 포인터나 복잡한 객체를 그대로 둔다

이것도 자주 나오는 패턴입니다.

  • 생 포인터
  • HANDLE
  • file descriptor
  • std::string
  • std::vector
  • std::unordered_map
  • std::mutex
  • CRITICAL_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으로 자기 주소로 고칠 수 있습니다.

포인터가 아니라 오프셋으로 참조한다가상 주소나 process-local 자원은 그 프로세스 문맥에서만 의미가 있으므로, 참조는 베이스 주소로부터의 offset으로 두고 각 프로세스가 base+offset으로 해석함을 나타내는 그림.대신에생 포인터나 HANDLE을 둔다다른 프로세스에서는 무의미한 값offset으로 둔다각 프로세스가 base+offset으로 해석

그림 12: 참조는 생 포인터가 아니라, 베이스 주소로부터의 offset으로 둔다.

5.5 ABI가 깨진다

shared memory는 소스 코드가 아니라 바이너리 약속입니다. 즉 다음 차이가 전부 먹힙니다.

  • int / long의 크기
  • bool의 표현
  • enum의 underlying type
  • wchar_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를 두지 않는다
ABI를 깨는 차이와 막는 법형 크기나 alignment, pack이나 padding, 32bit와 64bit 차이 등이 바이너리 약속을 깨므로, 고정 폭 정수와 명시적 레이아웃, 버전 있는 헤더로 맞춰 막음을 나타내는 그림.형 크기 차이바이너리 약속이 깨진다pack이나 padding 차이32bit / 64bit 차이고정 폭 정수+명시 레이아웃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를 두는 편이 좋습니다.

  • INITIALIZING
  • READY
  • BROKEN

그리고 creator만 초기화하고, joiner는 READY를 기다립니다. 이 방식만으로도 세상이 꽤 조용해집니다.

초기화 레이스를 피하는 법선두 헤더에 INITIALIZING·READY·BROKEN state를 두고, creator만 초기화해 READY를 세우며, joiner는 READY를 기다린 뒤 쓰는 방식으로 초기화 레이스를 피함을 나타내는 그림.creator가 만든다creator만 초기화한다state를 READY로 한다joiner는 open해도 바로 쓰지 않는다READY를 기다린다쓰기 시작한다

그림 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
  • 파손 시 전체 재초기화 절차
writer가 갱신 중에 죽었을 때소유자가 release하지 않고 종료하면 Windows에서는 WAIT_ABANDONED, POSIX robust mutex에서는 EOWNERDEAD가 돌아오고, 공유 자원이 부정 상태일 수 있으므로 일단 계속하지 않고 복구 수단으로 나아감을 나타내는 그림.writer가 갱신 중에 죽는다WAIT_ABANDONED(Windows)EOWNERDEAD(POSIX robust)공유 자원은 부정 상태일 수 있다일단 계속하지 않는다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에서 흔한 값이지 절대 법칙은 아니다, 정도의 마음으로 봐 주세요.

false sharing의 전형과 대책producer가 갱신하는 write_index와 consumer가 갱신하는 read_index가 같은 cache line에 올라 있으면 CPU 사이에서 line이 오가며 느려지므로, hot한 field를 다른 cache line으로 나눔을 나타내는 그림.producer가 write_index를 갱신같은 cache lineconsumer가 read_index를 갱신line이 CPU 사이에서 오가며 느려진다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를 공유한다

는 버릇이 있습니다.1582

즉,

  • "Global\\MyApp"으로 하면 service와 desktop app이 공유할 수 있을 것 같다
  • 그런데 권한에서 실패한다
  • 게다가 같은 이름의 mutex를 먼저 만들어 두어 ERROR_INVALID_HANDLE이 된다

같은, 아주 Windows다운 수렁이 나옵니다.

Global 이름 공간의 수렁service와 desktop app에서 공유하려고 Global 이름을 쓰면, SeCreateGlobalPrivilege가 없어 권한에서 실패하거나, 이름 공간을 공유하는 같은 이름 mutex가 먼저 있어 실패함을 나타내는 그림.Global 이름으로 공유하고 싶다권한에서 실패같은 이름 mutex와 충돌새로 만들려면 SeCreateGlobalPrivilege가 필요하다ERROR_INVALID_HANDLE이 된다

그림 17: Global 이름 공간은 권한과 이름 충돌이라는 두 종류의 수렁을 밟기 쉽다.

POSIX 쪽에서도 shm_openmodeumask를 가볍게 보면, 불필요하게 넓게 보이거나 반대로 열리지 않습니다.3

shared memory는 그냥 메모리라서 안전하지 않습니다. 읽을 권한이 있는 process에서는 꽤 쉽게 보입니다. 기밀 정보를 둔다면, 보통 메모리와 같이 paging / swap / dump / 권한 문맥에서 생각할 필요가 있습니다.

5.10 크기 변경과 업그레이드를 대충 한다

공유 메모리를 「나중에 조금 넓히고 싶다」는, 꽤 위험한 요구입니다.

  • Windows mapping object에는 생성 시 크기가 있다8
  • POSIX에서도 ftruncatemmap의 정합을 생각하지 않으면, 참가자 쪽 map 길이와 맞지 않게 됩니다316

실무에서는 크기는 그 세대에서는 불변으로 두는 편이 안전합니다. 확장이 필요하면,

  1. 새 version / name / generation의 segment를 만든다
  2. 참가자를 전환한다
  3. 옛 segment를 닫는다

쪽이 사고율은 내려갑니다.

크기 확장은 세대 전환으로공유 메모리 크기는 그 세대에서는 불변으로 두고, 확장이 필요하면 새 세대 segment를 만들어 참가자를 전환한 뒤 옛 segment를 닫는 절차 쪽이 사고율이 내려감을 나타내는 그림.나중에 넓히고 싶다새 세대 segment를 만든다참가자를 전환한다옛 segment를 닫는다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
통지는 기다릴 수 있는 primitive로 빼낸다공유 메모리 플래그를 Sleep으로 감시하는 통지는 CPU 낭비나 레이턴시 흔들림, 놓침으로 이어지므로, 공유 메모리는 데이터 면으로 맞추고 통지는 event나 semaphore처럼 기다릴 수 있는 primitive로 빼냄을 나타내는 그림.ready flag를 Sleep으로 감시한다CPU 낭비·레이턴시 흔들림놓친 것을 알아채기 어렵다통지는 기다릴 수 있는 primitive로Windows는 event / semaphore 등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를 고르는 편이 정신을 지키기 쉽습니다.

다른 머신과의 공유는 하지 않는다네트워크 너머 공유 파일을 map해도 remote file에는 coherence가 보장되지 않으며, 공유 메모리는 같은 호스트 안의 구조이므로 머신을 넘긴다면 socket이나 RPC나 message broker를 고름을 나타내는 그림.remote file을 map해서 공유하고 싶다coherence가 보장되지 않는다공유 메모리는 같은 호스트 안의 구조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_versionheader_size로 layout 차이를 걸러낸다
  • state로 초기화 중간을 걸러낸다
  • generation으로 재작성을 감지한다
  • heartbeat로 생사를 본다
  • reserved로 장래 확장의 도망길을 만든다

입니다.

shared memory에서 힘든 것은 「무슨 일이 일어나는지 보이기 어렵다」는 점입니다. 그래서 관측용 metadata를 처음부터 갖게 합니다.

고정 헤더 각 필드의 역할선두 헤더의 magic으로 다른 것이나 미초기화를 걸러내고, abi_version과 header_size로 layout 차이를 걸러내고, state로 초기화 중간을 걸러내고, generation으로 재작성을 감지하며, heartbeat로 생사를 보는 역할 분담을 나타내는 그림.선두의 고정 헤더magic으로 다른 것을 걸러낸다version으로 차이를 걸러낸다state로 중간을 걸러낸다관측용 metadata가 된다generation으로 재작성 감지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도 하나이므로, 진행 방향이 일방통행이 됩니다.

링 버퍼 8 슬롯끝까지 오면 slot 0으로 돌아간다slot 0읽기 끝slot 1읽기 끝slot 2미읽음slot 3미읽음slot 4쓰는 중slot 5빈 자리slot 6빈 자리slot 7빈 자리consumerread_seq = 2읽고 나서 진행한다producerwrite_seq = 4쓰고 나서 진행한다

그림 22: SPSC 링 버퍼의 구조. producer와 consumer가 서로 다른 index를 일방통행으로 진행한다.

포인트는 「쓰고 나서 index를 진행한다」「읽고 나서 index를 진행한다」 순서를 깨지 않는 것입니다. 그리고 write_seqread_seq는 5.8대로, 다른 cache line에 둡니다.

다 writer가 필요하면,

  • enqueue만 lock-free / atomic
  • 실제 데이터 갱신은 consumer 하나로 모은다

처럼 정합성 책임 지점을 줄이는 쪽이 대체로 잘 됩니다.

6.5 commit protocol을 명시한다

「어느 순간부터 읽어도 되는가」를 글로 설명할 수 없는 설계는 위험합니다.

예를 들어 더블 버퍼라면,

  1. 비공개 쪽 버퍼에 쓴다
  2. 체크섬이나 길이를 확정한다
  3. release를 붙여 active buffer index를 전환한다
  4. reader는 acquire를 붙여 active index를 읽는다
  5. 읽고 나서 index가 바뀌지 않았는지 확인한다

처럼 공개의 의식을 정합니다. 그림으로 그리면, 전환 순간이 한곳뿐임이 분명해집니다.

reader버퍼 Bactive index버퍼 Awriterreader버퍼 Bactive index버퍼 Awriteractive index는 Aactive index는 B읽은 내용은 버리고B부터 다시 읽는다acquire로 읽는다A버퍼 A를 읽는다비공개 쪽 B에 쓴다길이와 체크섬을 확정한다release로 B로 전환한다읽고 나서 index를 재확인한다B로 바뀌어 있었다

그림 23: 더블 버퍼의 공개 의식. reader는 읽고 난 뒤에 active index를 재확인한다.

이 「읽고 난 뒤에 index를 재확인한다」 절차를 빼면, reader가 읽는 중에 writer가 같은 버퍼를 다음 쓰기에 재사용해, 5.3과 같은 어중간한 상태가 됩니다. 또한 버퍼가 2면뿐이면 다시 읽는 중에 또 전환될 수 있으므로, 갱신이 빠르면 면 수를 늘리거나 5.3에 든 sequence counter 방식으로 맞춥니다.

6.6 크기는 세대마다 고정한다

resize in place보다,

  • name = MyShm.v3
  • abi_version = 3
  • generation = 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
최소 샘플의 왕복보내는 쪽이 초기화를 끝낸 뒤 본체를 쓰고, 길이를 확정해 event를 세우며, 받는 쪽은 event를 기다린 뒤 ABI와 state를 확인하고, 길이를 범위 검사해 읽은 다음, 같은 순서로 답을 돌려보내는 왕복을 나타내는 그림.받는 쪽공유 블록보내는 쪽받는 쪽공유 블록보내는 쪽초기화하고 state를 READY로본체를 다 쓴 뒤에 길이를 확정request의 event를 세운다magic / version / state를 확인한다길이를 범위 검사하고 읽는다답도 본체를 쓴 뒤에 길이를 확정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_assertsizeof(DemoBlock)을 고정합니다.

같은 블록을 C# 쪽에서 다루면 이렇게 됩니다. 오프셋을 상수로 명시하는 것이 요점이며, C 쪽 구조체와 1바이트도 어긋나지 않게 적었습니다. 이름 있는 MemoryMappedFileEventWaitHandle은 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

MemoryMappedFile도 같은 기본C#의 MemoryMappedFile은 본질적으로 Windows file mapping의 래퍼이므로, 같은 이름으로 open하고, 별도로 mutex나 event를 쓰며, 명시 레이아웃으로 읽고, 객체 참조를 두지 않는다는 기본은 바뀌지 않음을 나타내는 그림.C#의 MemoryMappedFilefile mapping의 래퍼같은 이름으로 open동기는 별도 mutex / event명시 레이아웃으로 읽는다객체 참조는 그대로 두지 않는다

그림 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
  • 복구
  • 권한
  • 관측 가능성

을 이쪽에서 떠맡게 됩니다.

공유 메모리의 본체는 책임의 이동복사나 커널 너머 메시징을 줄이는 대신, 동기·가시성·초기화·ABI·복구·권한·관측 가능성을 앱 쪽에서 떠맡게 된다는 책임의 이동을 나타내는 그림.복사와 메시징을 줄인다대신 떠맡는 것동기·가시성·초기화ABI·복구·권한관측 가능성

그림 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
  1. Microsoft Learn, “메모리 맵트 파일” / Microsoft Learn, “MemoryMappedFile 클래스”  2 3 4

  2. Microsoft Learn, “Sharing Files and Memory”  2 3 4 5 6 7 8

  3. man7.org, “shm_open(3)”  2 3 4 5 6 7 8 9 10 11

  4. Microsoft Learn, “/volatile (volatile Keyword Interpretation)” / Microsoft Learn, “volatile (C++)”  2

  5. Microsoft Learn, “Interlocked Variable Access” / Microsoft Learn, “MemoryBarrier function”  2 3 4 5

  6. Microsoft Learn, “Creating Named Shared Memory” / Microsoft Learn, “이름 있는 공유 메모리 만들기”  2 3 4

  7. Microsoft Learn, “Scope of Allocated Memory”  2

  8. Microsoft Learn, “CreateFileMappingA function”  2 3 4 5 6 7 8 9 10

  9. man7.org, “POSIX Shared Memory” training slides 

  10. Microsoft Learn, “WaitOnAddress function”  2

  11. Microsoft Learn, “MapViewOfFileEx function” / Microsoft Learn, “MapViewOfFile function” 

  12. Microsoft Learn, “Mutex Objects”  2 3

  13. man7.org, “pthread_mutex_lock(3p)” / man7.org, “pthread_mutexattr_setrobust(3)”  2 3

  14. man7.org, “pthread_mutex_consistent(3)” / man7.org, “pthread_mutex_consistent(3p)”  2

  15. Microsoft Learn, “Kernel object namespaces”  2 3 4

  16. man7.org, “mmap(2)”  2

  17. Microsoft Learn, “Using Mutex Objects” 

  18. man7.org, “sem_init(3)” / man7.org, “sem_init(3p)”  2

  19. man7.org, “pthread_condattr_setpshared(3p)” / man7.org, “pthread_condattr_getpshared(3p)”  2

  20. man7.org, “pthread_mutexattr_getpshared(3)” / man7.org, “pthread_mutexattr_getpshared(3p)”  2 3

  21. Microsoft Learn, “Critical Section Objects” 

  22. Microsoft Learn, “파일 매핑의 보안과 액세스 권한” / Microsoft Learn, “File Mapping Security and Access Rights”  2

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

자주 묻는 질문

이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.

공유 메모리에 쓴 값은 다른 프로세스에서 바로 올바르게 읽을 수 있나요?
보이는 것과 안전하게 읽을 수 있는 것은 별개입니다. 공유 메모리는 같은 바이트 열을 여러 프로세스에서 보여주는 구조이지, 동기 그 자체가 아닙니다. 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를 두는 것만으로도 사고 조사가 훨씬 쉬워집니다.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기