使用共享記憶體時的陷阱與最佳實踐 - 先整理同步、可見性、壽命、ABI、安全性

· · Shared Memory, IPC, Concurrency, C++, C#, Windows 開發

影像 frame、檢查結果、時序日誌、行情資訊、巨大緩衝。 想在同一台機器內以低延遲交換大型資料時,共享記憶體相當有吸引力。

但是這裡稍微危險的是,共享記憶體以 「快速 IPC」 的面貌接近。 實際上共享記憶體是 「能減少複製代價但會把整合性責任推回應用程式側的 IPC」

  • 靈活
  • 但 protocol 是自製
  • 出事時症狀華麗

大致這 4 點套組。

本文以 Windows 的 file mapping 和 POSIX shm_open / mmap 為念頭,整理 實務上使用共享記憶體的卡點以及降低事故率的設計。 C/C++ 或 C# 的 MemoryMappedFile,本質幾乎相同。1

1. 先講結論(一句話)

先相當粗略但實務上有用的說法如下。

  • 共享記憶體是把 相同位元組序列 讓多個行程可見的機制,不是同步本身23
  • 快的是大型資料 在同一機器內交換時。只小型控制訊息的話,pipe / socket / named pipe / queue 更輕鬆的情況相當多
  • 共享記憶體中,可見可安全讀取 是不同問題
  • 不要把 volatile 當設計根基。原子性、順序、等待 要分開思考45
  • 原生指標、HANDLE、file descriptor、std::stringstd::vectorstd::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 用 file mapping object 和 view,POSIX 把 shared memory object mmap273

這裡重要的是 2 點。

  1. 共享的是內容的位元組序列,不是虛擬位址本身
  2. coherent同步 是不同事

Windows 的文件也說從同 file mapping object 做的 view 在同一時點 coherent。 但那不代表 讀者隨時都能讀到一致的更新完成記錄8

例如,

  • writer 打算寫 length
  • 接著寫 payload
  • 接著寫 ready flag

順序寫入,但 reader 側若無任何同步就讀,可能看到 新的 length 和舊的 payload 的組合。 共享記憶體不會自動修正。

也就是共享記憶體共享的是 位元組。 不共享的是 意義、順序、完成通知、復原方針。 這一帶全部需要自己設計。

3. 共享記憶體適合的場面 / 不適合的場面

場面 適合否 理由
在同一機器內傳遞大的 frame 或緩衝 適合 容易減少複製次數
高頻感測器值、影像、音訊、行情等 適合 容易瞄準低延遲・高吞吐
只交換小型命令或回應 不太適合 控制所需的同步成本相對重
與其他機器交換 不適合 共享記憶體基本上同機前提
不同語言、不同版本長期共存 困難 需要 ABI 和版本設計
也需要持久化 視目的 file-backed mapping 有力但持久化與 IPC 的職責易混

實務上 控制走訊息系、資料本體走共享記憶體 的分離相當強。 例如,

  • UI 行程 → worker 行程通知「使用下一張 frame」用 event / pipe / socket
  • 實際 frame 本體用共享記憶體

這種構成。 相當和平。

4. 最初該決定的 4 件事

設計共享記憶體時最初該決定的是以下 4 件。

4.1 分開 control plane 和 data plane

先決定放什麼到 shared memory。

  • data plane: 影像、音訊、記錄序列、批量資料
  • control plane: 開始、停止、錯誤、重連、重初始化、通知

光分開這 2 個,shared memory 側的設計就相當單純。

4.2 縮小並行模型

  • SPSC: 1 producer / 1 consumer
  • MPSC: 多 writer / 1 consumer
  • SPMC: 1 writer / 多 reader
  • MPMC: 多 writer / 多 reader

難度大致以此順序上升。 從一開始用 MPMC 相當勇猛。通常之後會出記憶體順序的妖怪。

4.3 決定擁有者和壽命

  • 誰建立
  • 誰初始化
  • 誰刪除
  • 參與者中途掉落時誰復原

這裡曖昧,每次啟動順或重啟空氣都變混濁。

4.4 決定 ABI 和版本

  • 佈局
  • 型大小
  • alignment
  • reserved 區域
  • version / feature flags
  • 相容性的有無

shared memory 不是 API 而是 ABI(binary interface) 的話題。 這裡粗略,就會源碼相容但只在執行時壞的討厭事故。

5. 常見陷阱

5.1 不同步

最多的是這個。

「都看同一個記憶體,寫了應該能讀」

讀得到的情況有。 但那不代表能以 正確的時機、正確的單位、正確的順序 讀到。

Windows 和 POSIX 都以 共享記憶體的存取與別的同步手段組合 為前提。 Windows 的說明也寫著對共享 view 的存取要用 mutex / semaphore / event 等協調。2 POSIX 的說明也說對 shared memory 的存取需要同步。9

5.2 想用 volatile 解決

volatile 不是拯救共享記憶體設計的魔法。 至少 atomicitymutual exclusion 是不同問題。45

例如放 volatile bool ready; 用 busy loop 的設計,

  • 浪費 CPU
  • payload 和 ready 的順序保證曖昧
  • 不可攜
  • 易撈到中途狀態

通常沒好事。

再者 Windows 的 WaitOnAddress同行程內 thread 用。 作為跨行程等待機制不應考慮,比較安全。10

5.3 讓人讀到中途狀態

共享記憶體出事時的外觀相當平常。

  • 只標頭新
  • 只 payload 舊
  • 只長度更新完成
  • 2 個欄位的組合壞了

只原子更新單一 scalar 的話事情相對單純,但公開 由多欄位構成的記錄 的話,需要 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;   // 從 segment 開頭的相對位置
    uint32_t length;
    uint32_t kind;
} ShmRef;

這樣各 process 可以 base + 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

5.6 初始化競爭

共享記憶體容易被「製作側應該已初始化」的想法搞壞。

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。 光這作法世界就會安靜很多。

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
  • 損壞時的全重初始化步驟

5.8 false sharing 與快取行競爭

常說共享記憶體快。 但 hot 計數器擠在同個 cache line,CPU 間 line 跑來跑去,會景氣地變慢。

典型例是,

  • producer 更新 write_index
  • consumer 更新 read_index
  • 兩者在同一 cache line

這個。

此情況,

  • hot field 分到別的 cache line
  • 高更新頻率和低更新頻率的欄位分開
  • 意識 1 writer 1 cache line

光這些就相當改變。 常出現對齊 64 bytes 的話題,但 64 bytes 在多數 CPU 上常見只是常見值,不是絕對法則 的心情看就好。

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 風的泥濘會出。

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

事故率比較低。

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

5.12 以為「這樣也能跟其他機器共享」

會有想用 file-backed mapping map 網路上的共享檔,這樣也許能跨機器 shared memory 化的瞬間。

這裡危險。

Windows 的 CreateFileMapping 的說明也說 對 remote file 不保證 coherence。 同頁 2 台 writable map,各自只看得到自己的寫,磁碟更新時也不 merge。8

共享記憶體基本上是 同機 的機制。 跨機器的話直接選 socket / RPC / message broker 比較保持理智。

6. 最佳實踐

6.1 分開 control plane 和 data plane

shared memory 只放 批量資料,通知和狀態轉移逃到別通道。

  • 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 擋掉佈局差異
  • state 擋掉初始化中
  • generation 偵測重建
  • heartbeat 看生死
  • reserved 為將來擴充留退路

shared memory 辛苦的是「難看見發生什麼」。 所以 觀測用 metadata 從最初就持有。

6.3 用偏移參照

參照不用 pointer 用 offset 持有。

  • base + offset 解析
  • offset + length 的範圍檢查
  • 決定 invalid value 的 sentinel

光這些 address mismatch 系的事故就大幅減少。

6.4 縮小並行模型

shared memory 的 writer 多,突然就難。 所以最初這兩個較強。

  • SPSC ring buffer
  • 1 writer / 多 reader 的 snapshot

需要多 writer 的話,

  • 只 enqueue 用 lock-free / atomic
  • 實資料更新集中到 1 個 consumer

這樣 減少整合性的責任點 通常較成功。

6.5 明示 commit protocol

「從哪個瞬間可以讀」用文字說明不了的設計很危險。

例如雙緩衝,

  1. 寫到非公開側緩衝
  2. 確定校驗和或長度
  3. 以 release 切換 active buffer index
  4. reader 以 acquire 讀 active index
  5. 讀完後確認 index 沒變

這樣決定 公開的儀式

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 比起正常系,破壞方式測試 的價值更大。

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
不該跨行程用的 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

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
  • 初始化者是否定在 1 人
  • 異常終止時的 復原步驟 是否有
  • 名稱和權限是否明示
  • Global\ 是否真的必要
  • 是否以 resize in place 為前提
  • 是否試過 writer kill / reader stall / version mismatch / 權限不足

9. 總結

共享記憶體好好用相當強。 特別,

  • 影像
  • 音訊
  • 感測器序列
  • 大型批次
  • 高頻 snapshot

這類 同機大型資料 真的有效。

但共享記憶體的本體比起「快」更是 責任的移轉。 減少複製或跨核心訊息的代價是,

  • 同步
  • 可見性
  • 初始化
  • ABI
  • 復原
  • 權限
  • 可觀測性

要這邊承擔。

所以最初的 1 個這樣做比較安全。

  • 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

  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

  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

  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

  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

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽