공유 메모리를 사용할 때의 함정과 베스트 프랙티스 - 동기, 가시성, 수명, 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가지 세트입니다.

공유 메모리의 두 얼굴공유 메모리는 빠른 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가, 같은 시점에 같은 내용으로 보이는 것입니다. 「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)의 링 버퍼더블 버퍼부터 시작하는 것입니다

요컨대 공유 메모리는 빠르지만, 대충 쓰면 「알아서 동기화되고 있는 것 같은 착각」에 빠지는 병에 걸립니다. 이것을 피하는 것이 첫 승부입니다.

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

그림 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하다고 되어 있습니다. 다만 그것은 reader가 항상 일관된 갱신 완료 레코드를 읽을 수 있다는 의미가 아닙니다.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. 처음에 결정해야 할 4가지

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

처음에 결정할 4가지공유 메모리를 설계할 때 처음에 결정해야 할 control plane과 data plane의 분리, 병렬 모델, 소유자와 수명, ABI와 버전이라는 4개 항목을 보여주는 그림.공유 메모리 설계plane의 분리병렬 모델소유자와 수명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끼리의 배타 제어와 메모리 순서를 동시에 다뤄야 하고, 테스트로는 재현하기 어려운 결함이 나중에 나타납니다.

병렬 모델의 난이도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·순서·도중 상태의 3가지 면에서 불리하다.

5.3 도중 상태를 읽게 한다

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

  • 헤더만 새롭다
  • payload만 오래됐다
  • 길이만 갱신되어 있다
  • 두 필드의 조합이 깨져 있다

그림으로 그리면 사고가 나는 방식은 단순합니다. 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으로 자신의 주소로 변환할 수 있습니다.

포인터가 아니라 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(...))
  • non-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만 초기화해서 state를 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 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도 하나뿐이므로 진행 방향이 일방통행이 됩니다.

링 버퍼 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 2개로 주고받는 구성입니다.

공통의 약속은 다음 4가지입니다.

  • 블록은 고정 폭 정수와 고정 길이 배열뿐. 포인터도 HANDLE도 두지 않는다
  • 선두에 magic / abi_version / block_size / state를 둔다
  • 본체를 다 쓴 뒤에 길이를 확정하고, 그 다음에 event를 세운다
  • event 이름은 file mapping과 다른 이름으로 한다(Windows에서는 event / semaphore / mutex / waitable timer / job / file mapping이 namespace를 공유하기 때문)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'을 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_assertsizeof(DemoBlock)을 고정합니다.

같은 블록을 C# 쪽에서 다루면 이렇게 됩니다. offset을 상수로 명시하고 있는 것이 핵심으로, 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과 같은 레이아웃을 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

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, “메모리 매핑 파일” 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

  2. Microsoft Learn, “Sharing Files and Memory” https://learn.microsoft.com/en-us/windows/win32/memory/sharing-files-and-memory  2 3 4 5 6 7 8

  3. 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

  4. 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

  5. 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

  6. Microsoft Learn, “Creating Named Shared Memory” https://learn.microsoft.com/en-us/windows/win32/memory/creating-named-shared-memory  2 3 4

  7. Microsoft Learn, “Scope of Allocated Memory” https://learn.microsoft.com/en-us/windows/win32/memory/scope-of-allocated-memory  2

  8. 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

  9. man7.org, “POSIX Shared Memory” training slides https://man7.org/training/download/ipc_pshm_slides-mkerrisk-man7.org.pdf 

  10. Microsoft Learn, “WaitOnAddress function” https://learn.microsoft.com/en-us/windows/win32/api/synchapi/nf-synchapi-waitonaddress  2

  11. 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 

  12. Microsoft Learn, “Mutex Objects” https://learn.microsoft.com/en-us/windows/win32/sync/mutex-objects  2 3

  13. 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

  14. 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

  15. Microsoft Learn, “Kernel object namespaces” https://learn.microsoft.com/en-us/windows/win32/termserv/kernel-object-namespaces  2 3 4

  16. man7.org, “mmap(2)” https://man7.org/linux/man-pages/man2/mmap.2.html  2

  17. Microsoft Learn, “Using Mutex Objects” https://learn.microsoft.com/en-us/windows/win32/sync/using-mutex-objects 

  18. 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

  19. man7.org, “pthread_condattr_setpshared(3p)” https://man7.org/linux/man-pages/man3/pthread_condattr_setpshared.3p.html / man7.org, “pthread_condattr_getpshared(3p)” https://man7.org/linux/man-pages/man3/pthread_condattr_getpshared.3p.html  2

  20. man7.org, “pthread_mutexattr_getpshared(3)” https://man7.org/linux/man-pages/man3/pthread_mutexattr_getpshared.3.html / man7.org, “pthread_mutexattr_getpshared(3p)” https://man7.org/linux/man-pages/man3/pthread_mutexattr_getpshared.3p.html  2 3

  21. Microsoft Learn, “Critical Section Objects” https://learn.microsoft.com/en-us/windows/win32/sync/critical-section-objects 

  22. Microsoft Learn, “File Mapping Security and Access Rights” https://learn.microsoft.com/en-us/windows/win32/memory/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 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기