共用記憶體的陷阱與實務最佳實踐
· 更新日期: · Go Komura · Shared Memory, IPC, Concurrency, C++, C#, Windows 開發
更新紀錄(3 筆,最後更新 2026年09月04日)
本文的修改紀錄。已保存的更新前版本,可透過附有 DOI 的永久連結閱讀。
- 修正兩則參考文獻的連結:標示為繁體中文的項目原本指向英文版的同一個頁面,等於同一個網址出現兩次。現已改指 Microsoft Learn 的繁體中文版,標題也改用該頁的正式譯名。技術內容沒有改動。
- 已將繁體中文版改寫為日文原文的完整翻譯。先前的繁體中文版只譯出日文原文的一部分,遺漏了章節、表格、Mermaid 圖、圖說與 FAQ。本次依日文原文將這些內容全部補回,並新增本文的知識地圖章節。技術主張與日文版一致。 查看更新前的版本 (DOI: 10.5281/zenodo.22279449)
- 補上了日文原文中已有的諮詢引導(consultation_services)。內文沒有改動。 查看更新前的版本 (DOI: 10.5281/zenodo.21616321)
- 初次發布
引用本文(DOI(已登錄存檔): 10.5281/zenodo.21616320)
以下 DOI 指向先前登錄的存檔,內容可能與目前正文不同。引用目前正文時,請使用本頁網址。
Go Komura(2026)。〈共用記憶體的陷阱與實務最佳實踐〉。小村軟體有限公司。 https://comcomponent.com/zh-TW/blog/2026/03/18/000-shared-memory-pitfalls-best-practices/
- DOI(已登錄存檔)
- 10.5281/zenodo.21616320
- DOI(上次登錄版本)
- 10.5281/zenodo.22297163
影像影格、檢查結果、時序日誌、委託單簿資訊、巨大的緩衝區。 想在同一台機器內以低延遲交換大型資料時,共用記憶體相當有吸引力。
不過稍微危險的是,共用記憶體是以 「快速的 IPC」 這張臉靠過來的。 實際上,共用記憶體是 「能減少複製,但會把一致性的責任推回應用程式這一側的 IPC」。
- 快
- 靈活
- 但 protocol 要自己寫
- 一出事,症狀就很誇張
大致就是這 4 點一組。
flowchart TB
accTitle: 共用記憶體的兩張臉
accDescr: 共用記憶體以「快速的 IPC」這張臉靠過來,實際上卻是用減少複製來換取、把一致性責任推回應用程式側的 IPC 的圖。
f1["「快速的 IPC」這張臉"] --> f2["實際的樣貌"]
f2 --> f3["能減少複製"]
f2 --> f4["一致性的責任在應用程式側"]
f4 -.-> f5["protocol 要自己寫,出事時症狀誇張"]
圖 1: 共用記憶體以「快速的 IPC」這張臉靠近,把一致性的責任推回應用程式側。
本文以 Windows 的 file mapping 與 POSIX 的 shm_open / mmap 為背景,梳理 實務上使用共用記憶體時的卡關處,以及降低出錯機率的設計。
不論是 C/C++ 還是 C# 的 MemoryMappedFile,本質幾乎相同。1
目標讀者與前提
本文寫給 正要決定如何在同一台機器的處理程序之間傳遞大型資料的開發者。主要設想的是用 C / C++ 直接操作 Windows file mapping 或 POSIX shm_open 的人,不過從 C# 的 MemoryMappedFile 入門的人也會踩到同樣的陷阱。陷阱與設計方針這兩章(第 5 章、第 6 章)的內容與程式語言無關。
可以實際跑起來的範例放在 6.9,C(Windows / MSVC)與 C# 兩種版本都有。POSIX 這一側則只在第 7 章的表格列出 API 名稱的對照關係。
先弄清楚的用語
本文有幾個用語會直接以英文出現。為了不在第一次看到時卡住,先整理如下。
| 用語 | 意義 |
|---|---|
| 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/mmap63 - 最不容易出事的做法,是從 SPSC(single-producer single-consumer)的環形緩衝區 或 雙緩衝區 開始
總之,共用記憶體很快,但隨手亂用就會得到「總覺得已經自動同步好了」的毛病。避開這一點,是第一場硬仗。
flowchart TB
accTitle: 看得到與能安全讀取的差別
accDescr: 共用記憶體是把同一段位元組序列讓多個處理程序看見的機制而不是同步本身,看得到與能安全讀取是兩個不同問題的圖。
k1["把同一段位元組序列給多方看的機制"] --> k2["不是同步本身"]
k2 --> k3["看得到"]
k2 --> k4["能安全讀取"]
k3 --> k5["當成不同問題來設計"]
k4 --> k5
圖 2: 在共用記憶體裡,「看得到」與「能安全讀取」是兩個不同的問題。
圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 24 條,附依據與可信度)以及主要概念的定義,彙整在知識地圖詳細頁面(日文)。資料:JSON-LD / Turtle
2. 共用記憶體會共用哪些東西,又不會共用哪些東西
粗略地說,共用記憶體是把 同一個實體分頁 映射到多個處理程序的虛擬位址空間的機制。
Windows 使用 file mapping object 與 view,POSIX 則對 shared memory object 做 mmap。273
這裡重要的有 2 點。
- 共用的是內容的位元組序列,而不是虛擬位址本身
- 是 coherent 與 有同步 是兩回事
flowchart TB
accTitle: 用不同的view看同一個實體分頁
accDescr: 共用記憶體是把同一個實體分頁映射到多個處理程序虛擬位址空間的機制,共用的是內容的位元組序列而不是虛擬位址本身的圖。
pa["處理程序 A 的 view"] --> pp["同一個實體分頁"]
pb["處理程序 B 的 view"] --> pp
pp -.-> pn["共用的是位元組序列,不是虛擬位址"]
圖 3: 共用的是同一個實體分頁的位元組序列,而不是虛擬位址本身。
Windows 的文件也寫到,從同一個 file mapping object 建立的 view 在同一時點是 coherent 的。 但那並不代表 讀者隨時都能讀到一致的、已更新完成的記錄。8
舉例來說,writer 打算依照
length- 接著
payload - 接著
ready flag
的順序寫入,但只要 reader 端沒有任何同步就去讀,就可能看到 新的 length 配上舊的 payload 這種組合。
共用記憶體不會自動幫忙修正這一點。
也就是說,共用記憶體共用的是 位元組。 不共用的是 意義、順序、完成通知、復原方針。 這些全部都得由我們自己設計。
flowchart TB
accTitle: 共用記憶體會共用與不會共用的東西
accDescr: 共用記憶體共用的只有位元組,意義、順序、完成通知與復原方針都不會被共用而必須由應用程式側設計的圖。
s1["共用記憶體"] --> s2["共用的是位元組"]
s1 --> s3["不會共用的東西"]
s3 --> s4["意義、順序"]
s3 --> s5["完成通知、復原方針"]
s4 --> s6["由我們自己設計"]
s5 --> s6
圖 4: 位元組會被共用,但意義、順序、完成通知與復原方針要自己設計。
3. 共用記憶體適合的場面與不適合的場面
| 場面 | 適不適合 | 理由 |
|---|---|---|
| 在同一台機器內傳遞大型影格或緩衝區 | 適合 | 容易減少複製次數 |
| 高頻率的感測器數值、影像、音訊、委託單簿資訊等 | 適合 | 容易瞄準低延遲與高吞吐量 |
| 只交換小型命令或回應 | 不太適合 | 為了控制而付出的同步成本相對沉重 |
| 與其他機器交換資料 | 不適合 | 共用記憶體基本上以同一台主機為前提 |
| 不同語言、不同版本要長期並存 | 困難 | 需要 ABI 與版本管理的設計 |
| 也需要持久化 | 看目的而定 | file-backed mapping 是有力選項,但持久化與 IPC 的職責容易混在一起 |
在實務上,控制走訊息系,資料本體走共用記憶體 這種分離相當有力。 舉例來說,
- 從 UI 處理程序通知 worker 處理程序「請使用下一張影格」,走 event / pipe / socket
- 實際的影格本體放在共用記憶體
就是這樣的組合。 這樣做相對平靜。
flowchart TB
accTitle: 控制走訊息系、資料本體走共用記憶體
accDescr: 從UI處理程序送往worker處理程序的「請使用下一張影格」通知走event或pipe或socket,實際的影格本體則透過共用記憶體傳遞的分離架構的圖。
ui["UI 處理程序"] -->|"通知(event / pipe / socket)"| wk["worker 處理程序"]
ui -.->|"寫入影格本體"| shm["共用記憶體"]
wk -.->|"讀取影格本體"| shm
圖 5: 通知走訊息系,只把影格本體放進共用記憶體的架構。
4. 最先要決定的 4 件事
設計共用記憶體時,最先要決定的是下面這 4 件事。
flowchart TB
accTitle: 最先要決定的4件事
accDescr: 設計共用記憶體時最先要決定的control plane與data plane分離、並行模型、擁有者與生命週期、ABI與版本這4個項目的圖。
d0["共用記憶體的設計"] --> d1["plane 的分離"]
d0 --> d2["並行模型"]
d0 --> d3["擁有者與生命週期"]
d0 --> d4["ABI 與版本"]
圖 6: 設計一開始就先決定分離、並行模型、擁有者與生命週期、ABI 這 4 件事。
4.1 分開 control plane 與 data plane
先決定要把什麼放進 shared memory。
- data plane:影像、音訊、記錄序列、大量資料
- control plane:啟動、停止、錯誤、重新連線、重新初始化、通知
光是把這兩者分開,shared memory 那一側的設計就會單純很多。
4.2 縮小並行模型
- SPSC:1 producer / 1 consumer
- MPSC:多 writer / 1 consumer
- SPMC:1 writer / 多 reader
- MPMC:多 writer / 多 reader
難度大致就依這個順序往上升。 一開始就衝 MPMC 不太建議。那等於要同時面對 writer 之間的互斥與記憶體順序,之後就會冒出在測試裡難以重現的問題。
flowchart TB
accTitle: 並行模型的難度
accDescr: 難度大致依SPSC、MPSC、SPMC、MPMC的順序上升,一開始就採用MPMC等於要同時面對互斥與記憶體順序的圖。
m1["SPSC(1 寫 1 讀)"] --> m2["MPSC(多寫 1 讀)"]
m2 --> m3["SPMC(1 寫多讀)"]
m3 --> m4["MPMC(多寫多讀)"]
m4 -.-> m5["要同時面對互斥與記憶體順序"]
圖 7: 並行模型的難度從 SPSC 到 MPMC,大致依這個順序上升。
4.3 決定擁有者與生命週期
- 誰來建立
- 誰來初始化
- 誰來刪除
- 參與者中途當掉時,誰負責復原
這裡如果含糊不清,行為就會隨著啟動順序或重新啟動而改變,原因也很難縮小範圍。
4.4 決定 ABI 與版本
- 佈局
- 型別大小
- alignment
- reserved 區域
- version / feature flags
- 是否相容
shared memory 講的不是 API,而是 ABI(binary interface)。 這裡做得隨便,就會出現原始碼明明相容,執行階段卻壞掉的討厭問題。
flowchart TB
accTitle: shared memory講的是ABI
accDescr: shared memory講的不是API而是二進位層級的約定也就是ABI,這裡做得隨便就會變成原始碼相容卻只在執行階段壞掉的圖。
a1["shared memory"] --> a2["不是 API,是 ABI 的約定"]
a2 --> a3["這裡做得隨便"]
a3 --> a4["原始碼相容也會在執行階段壞掉"]
圖 8: shared memory 是 ABI 的約定,做得隨便就算原始碼相容也會在執行階段壞掉。
5. 常見的陷阱
5.1 不做同步
最常見的就是這個。
「反正大家看的是同一塊記憶體,寫了應該就讀得到吧」
有時候確實讀得到。 但那不代表能在 正確的時機、以正確的單位、依正確的順序 讀到。
不論 Windows 還是 POSIX,存取共用記憶體都 以搭配另外的同步手段為前提。 Windows 的說明也寫著,對共用 view 的存取要用 mutex / semaphore / event 等機制來協調。2 POSIX 的說明同樣指出,存取 shared memory 需要同步。9
flowchart TB
accTitle: 「寫了就讀得到吧」的陷阱
accDescr: 因為看的是同一塊記憶體所以有時讀得到,但能否以正確的時機、單位、順序讀到是另一回事,存取共用記憶體以搭配同步手段為前提的圖。
g1["「寫了就讀得到吧」"] --> g2["有時確實讀得到"]
g2 --> g3["正確的時機、單位、順序是另一回事"]
g3 --> g4["以搭配同步手段為前提"]
g4 -.-> g5["mutex / semaphore / event 等"]
圖 9: 讀得到,和以正確的單位、順序讀到,是兩回事,同步手段是前提。
5.2 想用 volatile 蒙混過去
volatile 不是能拯救共用記憶體設計的魔法。
至少 atomicity 與 mutual exclusion 是另外的問題。45
舉例來說,放一個 volatile bool ready; 再用 busy loop 去盯的設計,
- 白白吃掉 CPU
- payload 與 ready 之間的順序保證變得模糊
- 不具可攜性
- 很容易撈到中途狀態
大致上沒什麼好處。
再者,Windows 的 WaitOnAddress 是 給同一個處理程序內的 thread 用的。
不要把它當成 cross-process 的等待機制比較安全。10
flowchart TB
accTitle: 用volatile盯著看的設計有什麼問題
accDescr: 用busy loop盯著volatile bool的設計會白白吃掉CPU、順序保證模糊、容易撈到中途狀態,因此不該當成設計基礎的圖。
v1["用 busy loop 盯著 volatile bool"] --> v2["白白吃掉 CPU"]
v1 --> v3["順序保證模糊"]
v1 --> v4["容易撈到中途狀態"]
v2 --> v5["不要當成設計的基礎"]
v3 --> v5
v4 --> v5
v5 -.-> v6["WaitOnAddress 也是給同處理程序內的 thread 用"]
圖 10: volatile 的 busy loop 在 CPU、順序、中途狀態這 3 點上都不利。
5.3 讓 reader 讀到中途狀態
共用記憶體出事時的外觀,其實相當平凡。
- 只有標頭是新的
- 只有 payload 是舊的
- 只有長度已更新
- 兩個欄位的組合對不上
畫成圖之後,出事的方式很單純。就只是在 writer 寫完 length 與 payload 之前,reader 剛好插進那個空隙而已。
sequenceDiagram
participant W as writer 處理程序
participant M as 共用記憶體
participant R as reader 處理程序
W->>M: 把 1024 寫進 length
Note over M: length 是新的<br/>payload 還是舊的
R->>M: 讀取 length
M-->>R: 1024
R->>M: 讀取 1024 位元組的 payload
M-->>R: 前一個世代的內容
Note over R: 拿到「只有標頭是新的」<br/>這種半吊子狀態
W->>M: 寫入 payload
W->>M: 立起 ready flag
圖 11: writer 還沒寫完 payload,reader 就插進空隙,抓到中途狀態。
只要 length 與 payload 的寫入「不是一個不可分割的操作」,這個空隙就必然存在。如果只是把單一 scalar 以 atomic 方式更新,事情還算單純;但要公開 由多個欄位組成的記錄,就需要 commit 的步驟。
典型上是下面其中一種。
- 用 mutex 整個保護起來
- 改成 雙緩衝區,最後再切換「目前有效的緩衝區編號」
- 改成 環形緩衝區,讓每個 slot 各自帶 state / sequence
- 若是 1 writer / 多 reader,就用 sequence counter 取 snapshot
即使做到「最後才立起 ready flag」,只要沒有決定 這個 flag 要用什麼記憶體順序寫入 / 讀取,設計上就還不夠嚴謹。 在共用記憶體裡,公開的時機本身就是 protocol。
5.4 把指標或複雜物件直接放進去
這也是常出現的模式。
- 原生指標
HANDLE- file descriptor
std::stringstd::vectorstd::unordered_mapstd::mutexCRITICAL_SECTION
就是把這些東西直接放進 shared memory,再從別的處理程序去用。在讀取端的處理程序裡,幾乎必然會變成存取違規或無意義的值。
理由很單純:虛擬位址與 process-local 的資源,只在該 process 的脈絡裡才有意義。 Windows 的 view 也一樣,同一個 mapping 在別的 process map 之後,虛擬位址未必一致。711
所以需要參照時,基本做法是用 相對於基底位址的 offset 來持有。
typedef struct ShmRef {
uint64_t offset; // 相對於區段開頭的位置
uint32_t length;
uint32_t kind;
} ShmRef;
這樣一來,各個 process 都能用 base + offset 換算成自己的位址。
flowchart TB
accTitle: 用offset而不是指標來參照
accDescr: 虛擬位址與process-local的資源只在該處理程序的脈絡裡有意義,因此參照要用相對於基底位址的offset持有,各處理程序再以base+offset解析的圖。
p1["放進原生指標或 HANDLE"] --> p2["在別的處理程序裡是無意義的值"]
p2 -.->|"改成"| p3["用 offset 持有"]
p3 --> p4["各處理程序以 base+offset 解析"]
圖 12: 參照不要用原生指標,而是用相對於基底位址的 offset 持有。
5.5 ABI 壞掉
shared memory 不是原始碼層級的東西,而是 二進位層級的約定。 也就是說,下面這些差異全部都會發生作用。
int/long的大小bool的表示方式enum的 underlying typewchar_t的大小- 32 位元 / 64 位元的差異
#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 的物件
flowchart TB
accTitle: 會破壞ABI的差異與防範方式
accDescr: 型別大小、alignment、pack與padding、32位元與64位元的差異會破壞二進位層級的約定,因此要靠固定寬度整數、明確佈局與帶版本的標頭來防範的圖。
b1["型別大小的差異"] --> b4["二進位層級的約定壞掉"]
b2["pack 或 padding 的差異"] --> b4
b3["32 位元 / 64 位元的差異"] --> b4
b4 --> b5["固定寬度整數 + 明確佈局"]
b5 --> b6["把 version 與 size 放進 header"]
圖 13: 型別大小與 padding 的差異會破壞 ABI,所以要靠固定寬度整數與明確佈局。
5.6 初始化競爭
shared memory 很容易因為「建立的那一方應該已經初始化過了」這種想當然耳而壞掉。
在 Windows 上,CreateFileMapping 碰到已存在的名稱時會 傳回既有的物件,可以用 GetLastError() 得知 ERROR_ALREADY_EXISTS。
pagefile-backed 的 mapping,初始頁面的內容都是 0。8
在 POSIX 上,新的 shared memory object 一開始長度是 0,要用 ftruncate 給它大小。新配置到的位元組會被初始化為 0。用 O_CREAT | O_EXCL 建立是原子操作。3
不知道這些差異,就直接
- open 之後立刻拿來用
- 沒有初始化完成的旗標
- 參與者同時做初始化
- 不檢查 version mismatch
這樣做的話,就會隨著啟動順序而壞掉。
至少要在開頭的標頭放上下面這些 state。
INITIALIZINGREADYBROKEN
然後 只有 creator 做初始化,joiner 則等待 READY。
光是這個作法,世界就會安靜很多。
flowchart TB
accTitle: 避開初始化競爭的方法
accDescr: 在開頭的標頭放上INITIALIZING、READY、BROKEN的state,只由creator做初始化並立起READY,joiner等到READY才開始使用,以此避開初始化競爭的圖。
c1["creator 建立"] --> c2["只有 creator 做初始化"]
c2 --> c3["把 state 設為 READY"]
j1["joiner 就算 open 了也不立刻使用"] --> j2["等待 READY"]
c3 -.-> j2
j2 --> j3["開始使用"]
圖 14: 只有 creator 做初始化,joiner 等到標頭的 READY 之後才開始使用。
5.7 沒有考慮當機後的復原
writer 在更新共用資料的途中當掉時該怎麼辦。 這件事沒有定義就上線,故障時的局面會突然變得很嚴重。
Windows 的 mutex,如果持有它的 thread 沒有 release 就結束,就會變成 abandoned,等待端會收到 WAIT_ABANDONED。這代表 共用資源可能處於不確定的狀態。12
POSIX 的 robust mutex 也一樣,owner 死掉時會傳回 EOWNERDEAD,修復之後再呼叫 pthread_mutex_consistent()。1314
重要的是,這時候不要「先繼續跑再說」。 復原至少需要下面其中一項。
- generation 編號
- 最後已 commit 的 sequence
- heartbeat
- dirty / clean flag
- 類似 journal 的兩階段 commit
- 毀損時的整體重新初始化步驟
flowchart TB
accTitle: writer在更新途中當掉時
accDescr: 持有者沒有release就結束時Windows會回WAIT_ABANDONED、POSIX的robust mutex會回EOWNERDEAD,共用資源可能處於不確定狀態,所以不要先繼續跑而要進入復原手段的圖。
w1["writer 在更新途中當掉"] --> w2["WAIT_ABANDONED(Windows)"]
w1 --> w3["EOWNERDEAD(POSIX robust)"]
w2 --> w4["共用資源可能處於不確定狀態"]
w3 --> w4
w4 --> w5["不要先繼續跑再說"]
w5 -.-> w6["generation / heartbeat / 兩階段 commit / 重新初始化"]
圖 15: 持有者當掉就要懷疑不確定狀態,不要繼續跑而是進入復原步驟。
5.8 false sharing 與快取行競爭
大家常說 shared memory 很快。 但如果 hot 的計數器全擠在同一條 cache line 上,line 就會在 CPU 之間來回搬動,慢到不能忽視。
典型的例子是,
- producer 更新
write_index - consumer 更新
read_index - 兩者剛好落在同一條 cache line 上
就是這種情況。
這種情況下,只要
- 把 hot field 分到不同的 cache line
- 把更新頻率高的 field 與低的 field 分開
- 有意識地做到 1 writer 1 cache line
就會差很多。 常聽到要對齊到 64 bytes 的說法,不過請抱著 64 bytes 只是在很多 CPU 上常見的值,並不是絕對法則 這樣的態度來看。
flowchart TB
accTitle: false sharing的典型情況與對策
accDescr: producer更新的write_index與consumer更新的read_index落在同一條cache line上時line會在CPU之間來回而變慢,所以要把hot的field分到別條cache line的圖。
fp["producer 更新 write_index"] --> fl["同一條 cache line"]
fc["consumer 更新 read_index"] --> fl
fl --> fs["line 在 CPU 之間來回而變慢"]
fs --> fx["把 hot field 分到別條 cache line"]
圖 16: hot 的計數器落在同一條 cache line 上會變慢,所以要分到別條 line。
5.9 輕忽名稱、權限與安全性
named shared memory 很方便,但名稱與權限處理得隨便就會出事。
在 Windows 上,
- 有
Global\與Local\兩個 namespace - 要從 session 0 以外的地方 新建
Global\的 file mapping,需要SeCreateGlobalPrivilege - object name 與 event / semaphore / mutex / waitable timer / job 共用同一個 namespace
也就是說,
- 覺得取名成
"Global\\MyApp"就能讓 service 與 desktop app 共用 - 但權限不足而失敗
- 而且因為先建立了同名的 mutex,結果變成
ERROR_INVALID_HANDLE
就會冒出這種非常有 Windows 風格的麻煩。
flowchart TB
accTitle: Global命名空間的麻煩
accDescr: 想用Global的名稱讓service與desktop app共用時,可能因為沒有SeCreateGlobalPrivilege而權限失敗,也可能因為共用命名空間的同名mutex已經先存在而失敗的圖。
n1["想用 Global 的名稱共用"] --> n2["權限不足而失敗"]
n1 --> n3["與同名的 mutex 衝突"]
n2 -.-> n4["新建需要 SeCreateGlobalPrivilege"]
n3 -.-> n5["變成 ERROR_INVALID_HANDLE"]
圖 17: Global 命名空間容易踩到權限與名稱衝突這兩種麻煩。
POSIX 這一側也一樣,輕忽 shm_open 的 mode 或 umask,就會不必要地開放給太多人看,或者反過來根本打不開。3
shared memory 並不是 反正只是記憶體所以安全。 對有讀取權限的 process 來說,內容看得相當清楚。 要放機密資訊的話,就得像對待一般記憶體一樣,從 paging / swap / dump / 權限的角度來思考。
5.10 隨便處理尺寸變更與升級
「之後想把共用記憶體稍微擴大一點」,是相當危險的要求。
實務上,讓 大小在該世代裡維持不變 比較安全。 需要擴充的話,
- 建立新的 version / name / generation 的 segment
- 把參與者切換過去
- 關閉舊的 segment
這樣做比較不容易出事。
flowchart TB
accTitle: 尺寸擴充要靠世代切換
accDescr: 共用記憶體的大小在該世代裡維持不變,需要擴充時就建立新世代的segment、把參與者切換過去並關閉舊segment,這樣比較不容易出事的圖。
z0["之後想擴大"] --> z1["建立新世代的 segment"]
z1 --> z2["把參與者切換過去"]
z2 --> z3["關閉舊的 segment"]
z0 -.-> z4["resize in place 很危險"]
圖 18: 大小在世代內固定,擴充則透過切換到新世代的 segment 來完成。
5.11 連通知都塞進 shared memory
常見的是,
- 在共用記憶體裡設
ready = 1 - 對方則是
while (!ready) Sleep(1);
這種寫法。
這樣一開始是會動的。 但之後會以
- 白白吃掉 CPU
Sleep(1)讓延遲浮動- 漏接了也不容易察覺
- 逾時與結束通知很難寫得乾淨
這些形式回過頭來咬你一口。
共用記憶體最好靠向 資料面,通知則移到 可以等待的 primitive 上。
- Windows:event / semaphore / mutex / named pipe 等217
- POSIX:semaphore / process-shared mutex + condvar 等1819
flowchart TB
accTitle: 把通知移到可以等待的primitive
accDescr: 用Sleep盯著共用記憶體旗標的通知方式會浪費CPU、讓延遲浮動並導致漏接,所以共用記憶體要靠向資料面,通知則移到event或semaphore這類可以等待的primitive的圖。
t1["用 Sleep 盯著 ready flag"] --> t2["浪費 CPU、延遲浮動"]
t1 -.-> t6["漏接了也不容易察覺"]
t2 --> t3["通知移到可等待的 primitive"]
t3 --> t4["Windows 用 event / semaphore 等"]
t3 --> t5["POSIX 用 semaphore / condvar 等"]
圖 19: 不要盯著旗標看,而是用可以等待的 primitive 接收通知。
5.12 以為「這樣就能跟其他機器共用了」
有時候會很想這樣想:只要用 file-backed mapping 去 map 網路上的共用檔案,是不是就能像 shared memory 一樣跨機器使用。
這裡很危險。
Windows CreateFileMapping 的說明也寫著,對 remote file 並不保證 coherence。
兩台機器把同一個分頁以 writable 方式 map 時,各自只看得到自己的寫入,寫回磁碟時也不會做 merge。8
共用記憶體基本上是 同一台主機內 的機制。 要跨機器的話,直接選 socket / RPC / message broker 比較能保持理智。
flowchart TB
accTitle: 不要拿來跟其他機器共用
accDescr: 就算map網路上的共用檔案,remote file也不保證coherence,共用記憶體是同一台主機內的機制,跨機器時應該選socket或RPC或message broker的圖。
r1["想 map remote file 來共用"] --> r2["不保證 coherence"]
r2 --> r3["共用記憶體是同一台主機內的機制"]
r3 --> r4["改用 socket / RPC / message broker"]
圖 20: 共用記憶體是同一台主機內的機制,跨機器時要選訊息系。
6. 最佳實踐
6.1 分開 control plane 與 data plane
把 4.1 決定的分離落到實作層級的分配,就會變成這樣(失敗的形式請參考 5.11)。
- shared memory:frame、sample、batch、snapshot
- event / semaphore / pipe / socket:ready、consumed、stop、error、reconnect
這個分離首先改善的不是效能,而是 讓設計更容易看清全貌。
6.2 在開頭放固定標頭
至少強烈建議在開頭放上這樣的標頭。
typedef struct SharedHeader {
uint32_t magic;
uint16_t abi_version;
uint16_t header_size;
uint32_t state; // 0=initializing, 1=ready, 2=broken
uint32_t flags;
uint64_t total_size;
uint64_t generation;
uint64_t heartbeat_ns;
uint64_t payload_offset;
uint64_t payload_size;
uint64_t write_seq;
uint64_t read_seq;
uint8_t reserved[64];
} SharedHeader;
重點是,
- 用
magic擋掉不同的東西或未初始化的狀態 - 用
abi_version與header_size擋掉 layout 的差異 - 用
state擋掉還在初始化的狀態 - 用
generation偵測重新建立 - 用
heartbeat觀察存活狀況 - 用
reserved留下未來擴充的後路
就這些。
shared memory 麻煩的地方在於「很難看清楚到底發生了什麼事」。 正因如此,一開始就要帶上 供觀測用的 metadata。
flowchart TB
accTitle: 固定標頭各欄位的職責
accDescr: 開頭標頭的magic擋掉不同的東西或未初始化狀態,abi_version與header_size擋掉layout差異,state擋掉初始化途中,generation偵測重新建立,heartbeat觀察存活狀況的職責分工的圖。
h0["開頭的固定標頭"] --> h1["用 magic 擋掉不同的東西"]
h0 --> h2["用 version 擋掉差異"]
h0 --> h3["用 state 擋掉中途狀態"]
h1 --> h4["成為觀測用的 metadata"]
h2 --> h4
h3 --> h4
h0 -.-> h5["用 generation 偵測重新建立"]
h5 -.-> h6["用 heartbeat 觀察存活狀況"]
圖 21: 固定標頭的各欄位分別擋掉不同的東西、差異與初始化途中的狀態。
6.3 改用 offset 參照
參照不要用 pointer,而是用 offset 持有。
- 用
base + offset解析 - 加入
offset + length的範圍檢查 - 決定代表 invalid value 的 sentinel
光是這樣,address mismatch 這一類的事故就會少很多。
6.4 縮小並行模型
4.2 的 4 種模型當中,一開始應該選的是下面兩者之一。
- SPSC ring buffer
- 1 writer / 多 reader 的 snapshot
SPSC 環形緩衝區的結構很單純:對一個由固定長度 slot 組成的陣列,producer 往 write_seq 的位置寫,consumer 從 read_seq 的位置讀。writer 與 reader 都只有 1 個,所以前進方向是單行道。
flowchart LR
subgraph ring["環形緩衝區 8 個 slot"]
direction LR
s0["slot 0<br/>已讀完"]
s1["slot 1<br/>已讀完"]
s2["slot 2<br/>未讀"]
s3["slot 3<br/>未讀"]
s4["slot 4<br/>寫入中"]
s5["slot 5<br/>空的"]
s6["slot 6<br/>空的"]
s7["slot 7<br/>空的"]
end
C["consumer<br/>read_seq = 2<br/>讀完之後才往前推"] --> s2
P["producer<br/>write_seq = 4<br/>寫完之後才往前推"] --> s4
s7 -.->|"走到結尾就繞回 slot 0"| s0
圖 22: SPSC 環形緩衝區的結構。producer 與 consumer 各自把不同的 index 單向往前推。
重點是不要打亂 「寫完之後才推進 index」「讀完之後才推進 index」 這個順序。而且如同 5.8 所說,write_seq 與 read_seq 要放在不同的 cache line 上。
如果需要多個 writer,
- 只有 enqueue 用 lock-free / atomic
- 實際的資料更新集中到 1 個 consumer
像這樣 減少負責一致性的地方,大致上會比較順利。
6.5 明確寫出 commit protocol
如果沒辦法用文字說明「從哪一刻起可以開始讀」,這樣的設計就很危險。
以雙緩衝區為例,
- 往未公開的那一側緩衝區寫入
- 確定 checksum 與長度
- 以 release 語意切換 active buffer index
- reader 以 acquire 語意讀取 active index
- 讀完之後檢查 index 有沒有變過
像這樣決定 公開的儀式。畫成圖之後就很清楚,切換的瞬間只有 1 個地方。
sequenceDiagram
participant W as writer
participant BA as 緩衝區 A
participant IX as active index
participant BB as 緩衝區 B
participant R as reader
Note over IX: active index 是 A
R->>IX: 以 acquire 讀取
IX-->>R: A
R->>BA: 讀取緩衝區 A
W->>BB: 往未公開的 B 寫入
W->>BB: 確定長度與 checksum
W->>IX: 以 release 切換到 B
Note over IX: active index 是 B
R->>IX: 讀完之後重新確認 index
IX-->>R: 已經變成 B
Note over R: 丟掉讀到的內容<br/>從 B 重新讀一次
圖 23: 雙緩衝區的公開儀式。reader 在讀完之後要重新確認 active index。
省略「讀完之後重新確認 index」這個步驟的話,reader 還在讀的期間,writer 就可能把同一塊緩衝區拿去做下一次寫入,於是變成和 5.3 一樣的半吊子狀態。另外,緩衝區只有 2 面時,重新讀取的過程中可能又被切換掉,所以更新很快的情況下,就要增加面數,或者靠向 5.3 提到的 sequence counter 方式。
6.6 大小依世代固定
比起 resize in place,像
name = MyShm.v3abi_version = 3generation = 42
這樣切出世代比較好維護。
共用記憶體不會像 API 那樣「在呼叫時做型別檢查」。 所以 不要破壞一旦定下的 ABI 這件事很重要。
6.7 加入可觀測性
至少有下面這些會很有幫助。
- 最後更新時間
- 最後成功的 sequence
- drop 次數 / overwrite 次數
- version mismatch 次數
- attach / detach 次數
- last error code
- heartbeat
shared memory 壞掉的時候,日誌通常都很單薄。 自己放上 counters,處理故障時會輕鬆很多。
6.8 先寫異常情境的測試
只有正常情境是不夠的。至少下面這些應該要看過。
- writer 在更新途中被強制結束
- reader 延遲導致 ring 溢位
- 以 version mismatch 的狀態連上
- 32 位元 / 64 位元混用
- 跨 session 的 open
- 權限不足
- 先啟動的處理程序還抓著舊世代就重新啟動
- 連續傳送 huge data 時的 cache miss / NUMA 影響
對 shared memory 來說,比起正常情境,測試怎麼把它弄壞 的價值更大。
6.9 最小的往返範例
把前面的作法落到一個能跑的最小組態。就是在 Windows 的 pagefile-backed file mapping 上放一個固定佈局的區塊,通知則用 2 個 auto-reset event 來交換,僅此而已。
共通的約定有這 4 條。
- 區塊裡 只放固定寬度整數與固定長度的陣列。不放指標,也不放
HANDLE - 開頭放上
magic/abi_version/block_size/state - 先寫完本體再確定長度,之後才立起 event
- event 的名稱要和 file mapping 取不同的名字(因為在 Windows 上,event / semaphore / mutex / waitable timer / job / file mapping 共用同一個命名空間)815
sequenceDiagram
accTitle: 最小範例的往返
accDescr: 送出端完成初始化後寫入本體、確定長度並立起event,接收端等到event才確認ABI與state,對長度做範圍檢查再讀取,並以相同順序回覆的往返的圖。
participant W as 送出端
participant M as 共用區塊
participant R as 接收端
W->>M: 初始化並把 state 設為 READY
W->>M: 先寫完本體再確定長度
W->>R: 立起 request 的 event
R->>M: 確認 magic / version / state
R->>M: 對長度做範圍檢查後讀取
R->>M: 回覆也是先寫本體再確定長度
R->>W: 立起 reply 的 event
圖 24: 最小範例的往返。先寫完本體再確定長度,之後才發出通知。
先看送出端。
/* shm_writer.c:送出端。請先啟動這一支。
* cl /W4 /nologo shm_writer.c (kernel32.lib 預設就會連結進來) */
#include <windows.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#define SHM_NAME L"Local\\KsShmDemo.v1.Block"
#define EVT_REQ L"Local\\KsShmDemo.v1.Request"
#define EVT_REP L"Local\\KsShmDemo.v1.Reply"
#define SHM_MAGIC 0x314D4853u /* 把 'S','H','M','1' 依 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 檢查擋不住第二支送出端。就這樣往下走到 memset,
* 會把正在運作的對方的共用區塊清掉。
* GetLastError() 在成功時也會被設定,所以要緊接著讀。 */
hMap = CreateFileMappingW(INVALID_HANDLE_VALUE, NULL, PAGE_READWRITE,
0, (DWORD)sizeof(DemoBlock), SHM_NAME);
if (hMap == NULL) {
printf("CreateFileMapping failed: %lu\n", GetLastError());
goto cleanup;
}
if (GetLastError() == ERROR_ALREADY_EXISTS) {
printf("%ls 已經被使用了。送出端同時只能有 1 支\n", SHM_NAME);
goto cleanup;
}
blk = (DemoBlock *)MapViewOfFile(hMap, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(DemoBlock));
if (blk == NULL) {
printf("MapViewOfFile failed: %lu\n", GetLastError());
goto cleanup;
}
/* 2. 通知用的 event。要和 mapping 取不同的名字 */
hReq = CreateEventW(NULL, FALSE, FALSE, EVT_REQ); /* auto-reset/非訊號狀態 */
hRep = CreateEventW(NULL, FALSE, FALSE, EVT_REP);
if (hReq == NULL || hRep == NULL) {
printf("CreateEvent failed: %lu\n", GetLastError());
goto cleanup;
}
/* 3. 只有建立的那一方做初始化。state 最後才立起來 */
memset(blk, 0, sizeof(*blk));
blk->magic = SHM_MAGIC;
blk->abi_version = SHM_ABI;
blk->block_size = (uint32_t)sizeof(DemoBlock);
blk->state = STATE_INITIALIZING;
MemoryBarrier();
blk->state = STATE_READY;
/* 4. 本體 → 屏障 → 長度 → 通知 的順序不能亂 */
strcpy_s(blk->request, sizeof(blk->request), "ping from writer");
MemoryBarrier();
blk->request_len = (uint32_t)strlen(blk->request);
if (!SetEvent(hReq)) {
printf("SetEvent failed: %lu\n", GetLastError());
goto cleanup;
}
/* 5. 等待回覆。不要做無限等待 */
waited = WaitForSingleObject(hRep, 5000);
if (waited == WAIT_TIMEOUT) {
printf("沒有收到 reader 的回應\n");
goto cleanup;
}
if (waited != WAIT_OBJECT_0) {
printf("WaitForSingleObject failed: %lu\n", GetLastError());
goto cleanup;
}
/* 6. 讀取之前先對長度做範圍檢查 */
len = blk->reply_len;
if (len > sizeof(blk->reply)) {
printf("reply_len 超出範圍:%u\n", len);
goto cleanup;
}
printf("reply: %.*s\n", (int)len, blk->reply);
rc = 0;
cleanup:
/* view 與 handle 全部關掉,名稱也會跟著消失。不要比 reader 先結束 */
if (hRep != NULL) CloseHandle(hRep);
if (hReq != NULL) CloseHandle(hReq);
if (blk != NULL) UnmapViewOfFile(blk);
if (hMap != NULL) CloseHandle(hMap);
return rc;
}
接收端把從 SHM_NAME 到 DemoBlock 的定義寫得和送出端完全相同,只替換掉 main。實務上會把這段共通部分抽到標頭檔。
/* shm_reader.c:接收端。常數與 DemoBlock 的定義和 shm_writer.c 完全相同。
* cl /W4 /nologo shm_reader.c */
int main(void)
{
HANDLE hMap = NULL, hReq = NULL, hRep = NULL;
DemoBlock *blk = NULL;
uint32_t len = 0;
int rc = 1;
hMap = OpenFileMappingW(FILE_MAP_ALL_ACCESS, FALSE, SHM_NAME);
if (hMap == NULL) {
printf("OpenFileMapping failed: %lu / writer 有啟動嗎\n", GetLastError());
goto cleanup;
}
blk = (DemoBlock *)MapViewOfFile(hMap, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(DemoBlock));
if (blk == NULL) {
printf("MapViewOfFile failed: %lu\n", GetLastError());
goto cleanup;
}
hReq = CreateEventW(NULL, FALSE, FALSE, EVT_REQ);
hRep = CreateEventW(NULL, FALSE, FALSE, EVT_REP);
if (hReq == NULL || hRep == NULL) {
printf("CreateEvent failed: %lu\n", GetLastError());
goto cleanup;
}
/* 1. 先等通知。writer 只會在初始化結束之後才立起它 */
if (WaitForSingleObject(hReq, 5000) != WAIT_OBJECT_0) {
printf("沒有收到 request\n");
goto cleanup;
}
/* 2. 動它之前先確認 ABI 與 state */
if (blk->magic != SHM_MAGIC || blk->abi_version != SHM_ABI ||
blk->block_size != (uint32_t)sizeof(DemoBlock)) {
printf("ABI 不一致:magic=%08X abi=%u size=%u\n",
blk->magic, blk->abi_version, blk->block_size);
goto cleanup;
}
if (blk->state != STATE_READY) {
printf("初始化還沒完成:state=%u\n", blk->state);
goto cleanup;
}
/* 3. 先對長度做範圍檢查再讀 */
len = blk->request_len;
if (len > sizeof(blk->request)) {
printf("request_len 超出範圍:%u\n", len);
goto cleanup;
}
printf("request: %.*s\n", (int)len, blk->request);
/* 4. 本體 → 屏障 → 長度 → 通知,順序和 writer 相同 */
strcpy_s(blk->reply, sizeof(blk->reply), "pong from reader");
MemoryBarrier();
blk->reply_len = (uint32_t)strlen(blk->reply);
if (!SetEvent(hRep)) {
printf("SetEvent failed: %lu\n", GetLastError());
goto cleanup;
}
rc = 0;
cleanup:
if (hRep != NULL) CloseHandle(hRep);
if (hReq != NULL) CloseHandle(hReq);
if (blk != NULL) UnmapViewOfFile(blk);
if (hMap != NULL) CloseHandle(hMap);
return rc;
}
放上 MemoryBarrier 是為了防止一種重排:本體還沒寫完,長度或旗標卻先被看見。5
如果想在建置階段就擋下佈局的偏差,就給 MSVC 加上 /std:c11,用 <assert.h> 的 static_assert 把 sizeof(DemoBlock) 固定住。
同一個區塊改從 C# 這一側處理,會變成這樣。重點在於 用常數明確寫出 offset,寫法上要做到和 C 那邊的結構一個位元組都不差。具名的 MemoryMappedFile 與 EventWaitHandle 是 Windows 專用的。1
// .NET 8 / Windows。送出端是 dotnet run -- write,接收端是 dotnet run -- read
using System.IO.MemoryMappedFiles;
using System.Text;
const string MapName = "Local\\KsShmDemo.v1.Block";
const string ReqName = "Local\\KsShmDemo.v1.Request";
const string RepName = "Local\\KsShmDemo.v1.Reply";
const uint Magic = 0x314D4853; // 'S','H','M','1'
const uint Abi = 1;
const int BlockSize = 536;
const int MaxBody = 256;
// 用 offset 常數固定住和 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 初始化為 08 | 新的 object 長度為 0。新配置到的位元組初始化為 03 |
| 同步 | mutex / semaphore / event / interlocked 等25 | process-shared mutex / condvar / semaphore2018 |
| cross-process 不能使用的東西 | CRITICAL_SECTION、WaitOnAddress2110 |
維持 PTHREAD_PROCESS_PRIVATE 的 mutex / condvar2019 |
| owner death | WAIT_ABANDONED12 |
robust mutex + EOWNERDEAD / pthread_mutex_consistent()1314 |
| name 的刪除 | 最後一個 handle / view 釋放後就消失28 | 用 shm_unlink 刪除名稱。只要還有參照,實體就會留到最後2223 |
| namespace / 權限 | Global\ / Local\、ACL、SeCreateGlobalPrivilege1524 |
mode、umask、命名空間、O_CREAT|O_EXCL3 |
C# 的 MemoryMappedFile 本質上也是 Windows file mapping 的包裝層。
所以,
- 用相同的名稱 open
- 另外使用 mutex / event
- 對 view 以明確佈局讀取
- 不要直接放物件參照
這些基本原則都不會變。1
flowchart TB
accTitle: MemoryMappedFile也是同樣的基本原則
accDescr: C#的MemoryMappedFile本質上是Windows file mapping的包裝層,所以用相同名稱open、另外使用mutex或event、以明確佈局讀取、不直接放物件參照這些基本原則都不會變的圖。
cs["C# 的 MemoryMappedFile"] --> fm["file mapping 的包裝層"]
fm --> q1["用相同名稱 open"]
fm --> q2["同步另外用 mutex / event"]
fm --> q3["以明確佈局讀取"]
q3 -.-> q4["不要直接放物件參照"]
圖 25: 用 C# 的 MemoryMappedFile 時,file mapping 的那套基本原則同樣適用。
8. 先檢查的清單
- 真的需要共用記憶體嗎。是不是 同一台主機內的大型資料
- 有沒有分開 control plane 與 data plane
- 並行模型能不能縮到 SPSC / 1 writer 多 reader
- 開頭的標頭有沒有 magic / version / size / state / generation / heartbeat
- 有沒有放進 pointer /
HANDLE/ fd / STL object /std::mutex - 有沒有讓 reader 看不到中途狀態的 commit protocol
- 初始化的人有沒有固定成 1 個
- 有沒有異常結束時的 復原步驟
- 名稱與權限有沒有明確寫出來
- 真的需要
Global\嗎 - 有沒有把 resize in place 當成前提
- 有沒有試過 writer kill / reader stall / version mismatch / 權限不足
9. 總結
共用記憶體只要用得好,威力相當強。 特別是,
- 影像
- 音訊
- 感測器序列
- 大型批次
- 高頻率的 snapshot
這類 同一台機器內的大型資料,真的派得上用場。
不過,共用記憶體的本質與其說是「快」,不如說是 責任的轉移。 用減少複製與跨核心的訊息傳遞來換取,
- 同步
- 可見性
- 初始化
- ABI
- 復原
- 權限
- 可觀測性
這些就得由我們自己承擔。
flowchart TB
accTitle: 共用記憶體的本質是責任的轉移
accDescr: 用減少複製與跨核心訊息傳遞來換取,同步、可見性、初始化、ABI、復原、權限、可觀測性都要由應用程式側承擔的責任轉移的圖。
e1["減少複製與訊息傳遞"] --> e2["換來要自己承擔的東西"]
e2 --> e3["同步、可見性、初始化"]
e2 --> e4["ABI、復原、權限"]
e2 --> e5["可觀測性"]
圖 26: 共用記憶體的本質與其說是「快」,不如說是把一致性的責任移到我們這一側。
所以,第一次做的時候這樣走比較安全。
- SPSC ring buffer 或雙緩衝區
- 開頭的固定標頭
- offset 參照
- 用另一條通道通知
- 具備 version / generation / heartbeat
- 具備異常情境測試
從這個形狀開始,shared memory 會變成相當直接了當的工具。 反過來說,一上來就把它當成「什麼都能放的高速共用記憶體」,做出來的東西會漸漸不像應用程式,而像考古學。
10. 參考資料
- Windows:file mapping 與 named shared memory 的基本682
- Windows:namespace / security / synchronization1524512
- POSIX:
shm_open、shm_unlink、mmap、process-shared / robust synchronization322162013 - .NET:
MemoryMappedFile概觀1
-
Microsoft Learn, “記憶體對應檔案” / Microsoft Learn, “MemoryMappedFile 類別” ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, “/volatile (volatile Keyword Interpretation)” / Microsoft Learn, “volatile (C++)” ↩ ↩2
-
Microsoft Learn, “Interlocked Variable Access” / Microsoft Learn, “MemoryBarrier function” ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, “Creating Named Shared Memory” / Microsoft Learn, “建立具名共享記憶體” ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, “Scope of Allocated Memory” ↩ ↩2
-
Microsoft Learn, “CreateFileMappingA function” ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10
-
man7.org, “POSIX Shared Memory” 教學投影片 ↩
-
Microsoft Learn, “WaitOnAddress function” ↩ ↩2
-
Microsoft Learn, “MapViewOfFileEx function” / Microsoft Learn, “MapViewOfFile function” ↩
-
Microsoft Learn, “Mutex Objects” ↩ ↩2 ↩3
-
man7.org, “pthread_mutex_lock(3p)” / man7.org, “pthread_mutexattr_setrobust(3)” ↩ ↩2 ↩3
-
man7.org, “pthread_mutex_consistent(3)” / man7.org, “pthread_mutex_consistent(3p)” ↩ ↩2
-
Microsoft Learn, “Kernel object namespaces” ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, “Using Mutex Objects” ↩
-
man7.org, “sem_init(3)” / man7.org, “sem_init(3p)” ↩ ↩2
-
Microsoft Learn, “Critical Section Objects” ↩
-
man7.org, “shm_unlink(3p)” ↩ ↩2
-
man7.org, “shm_open(3)” (shm_unlink semantics) ↩
-
Microsoft Learn, “檔案對映安全性和存取控制權” / Microsoft Learn, “File Mapping Security and Access Rights” ↩ ↩2
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
引數為什麼會壞掉 ── Windows 命令列引數的規則
在 Windows 上並不存在引數的陣列,傳給 CreateProcess 的是一條字串,分割由接收端負責。本文說明 CommandLineToArgvW・CRT・.NET 的分割規則,以及 .NET 的 ArgumentList 與 C++ 中正確的組裝方式。
父處理程序消失之後還剩下什麼 —— 用 Job Object 圈養子處理程序
為什麼強制結束 UI 之後,SDK 的輔助處理程序仍然殘留,一直佔著攝影機或 COM 連接埠?本文從量測應用的角度,說明如何用 Job Object 把處理程序樹變成一個單位,並借助 KillOnJobClose 與完成埠來設計子處理程序的壽命。
具名管道實務 ── 從設計到安全,看懂 Windows 行程間通訊的標準做法
以實務角度說明 Windows 行程間通訊的標準做法——具名管道。依據一手資料整理位元組模式與訊息模式的取捨、同時接受多個用戶端的伺服器結構、ACL 與模擬的安全設計,以及 .NET 的具名管道串流。
Windows 應用程式安全處理子處理程序的檢查清單
在 Windows 應用程式中安全處理子處理程序,比起挑選啟動 API,更重要的是處理程序樹的所有權與結束流程的設計。本文梳理 Job Object、結束傳播、標準輸入輸出與 watchdog。
從 C/C++ 呼叫 C# Native AOT DLL 的方法
本文說明如何以 Native AOT 把 C# 的類別庫發行成原生 DLL,並從 C/C++ 呼叫 UnmanagedCallersOnly 的進入點,從適用時機、實作模式與注意事項梳理這套構成。
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
與本主題相關的服務
本文連結到以下服務頁面,歡迎從最接近的入口查看。
Windows 應用程式開發
使用共用記憶體、file mapping 與 MemoryMappedFile 的大容量資料介接,以及處理程序分離設計,是與 Windows 應用程式開發直接相關的主題。
技術諮詢 & 設計審查
同步方式、ABI 設計、復原策略、control plane 與 data plane 的分離等等,這類降低出錯機率的設計梳理,很適合用技術諮詢與設計審查的形式來處理。
常見問題
整理諮詢這個主題時常見的問題。
- 寫進共用記憶體的值,其他處理程序馬上就能正確讀到嗎?
- 看得到和能安全讀取是兩個不同的問題。共用記憶體是把同一段位元組序列讓多個處理程序看見的機制,本身並不是同步。就算 writer 打算依照 length、payload、ready flag 的順序寫,只要 reader 端沒有任何同步就去讀,就可能看到新的 length 搭配舊的 payload。不論 Windows 還是 POSIX,存取共用記憶體都以搭配 mutex、semaphore、event 等同步手段為前提。
- 可以把指標、std::string 或 HANDLE 放進共用記憶體嗎?
- 最好不要。虛擬位址與 process-local 的資源只在該處理程序的脈絡裡才有意義,同一個 mapping 在別的處理程序 map 之後,虛擬位址也未必一致。std::vector、std::mutex、CRITICAL_SECTION 也一樣。需要參照時就用相對於基底位址的 offset 來持有;放進共用記憶體的資料,靠向固定寬度整數加上明確佈局與帶版本的標頭比較安全。
- 只要用 volatile,共用記憶體就不需要同步了嗎?
- 不會。volatile 不是能拯救共用記憶體設計的魔法,至少 atomicity 和 mutual exclusion 是另外的問題。用 busy loop 盯著 volatile bool 的設計,會白白吃掉 CPU,payload 與 ready flag 之間的順序保證也很模糊,而且很容易撈到中途狀態。另外 Windows 的 WaitOnAddress 是給同一個處理程序內的執行緒用的,不要把它當成跨處理程序的等待機制比較安全。通知要移到 event、semaphore 這類可以等待的 primitive 上。
- 設計共用記憶體時,最先要決定的是什麼?
- 有四件事。第一是 control plane 與 data plane 的分離,也就是控制(啟動、停止、通知)走訊息系、資料本體走共用記憶體;第二是縮小並行模型(一開始選 SPSC 環形緩衝區或雙緩衝區最不容易出事);第三是擁有者與生命週期,也就是誰建立、誰初始化、誰刪除、誰負責復原;第四是包含佈局與版本在內的 ABI 設計。光是在開頭的標頭放上 magic、version、size、state、generation、heartbeat,事後調查的難易度就差很多。