共享内存的陷阱与实务最佳实践
· 更新日期: · 小村 豪 · Shared Memory, IPC, Concurrency, C++, C#, Windows 开发
引用本文(DOI: 10.5281/zenodo.21615502)
本文保存于 Zenodo。以下同时提供始终指向最新版本的 DOI,以及固定于您正在阅读版本的 DOI。
小村 豪(2026)。《共享内存的陷阱与实务最佳实践》。小村软件有限公司。https://doi.org/10.5281/zenodo.21615502 https://comcomponent.com/zh-CN/blog/2026/03/18/000-shared-memory-pitfalls-best-practices/
- DOI(最新版本)
- 10.5281/zenodo.21615502
- DOI(此版本)
- 10.5281/zenodo.22282023
图像帧、检测结果、时间序列日志、盘口信息、巨大缓冲区。 想在同一台机器内以低延迟交换大数据时,共享内存相当有吸引力。
不过这里有点危险的是,共享内存常以 “快速 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
这里有两点很重要。
- 共享的是内容的字节序列,而不是虚拟地址本身
- 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与版本。
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 之间的顺序保证变得模糊
- 不具备可移植性(portable)
- 容易读到中间状态
基本上没什么好处。
而且 Windows 的 WaitOnAddress 是 面向同一进程内线程(thread) 的机制。
不应该把它当作跨进程(cross-process)的等待机制来使用,这样更安全。10
flowchart TB
accTitle: 用volatile监视的设计有什么问题
accDescr: 说明用volatile bool配合busy loop监视的设计会浪费CPU、顺序保证模糊、容易读到中间状态,因此不应该把它当作设计的基础。
v1["用busy loop监视volatile bool"] --> v2["白白浪费CPU"]
v1 --> v3["顺序保证模糊"]
v1 --> v4["容易读到中间状态"]
v2 --> v5["不作为设计的基础"]
v3 --> v5
v4 --> v5
v5 -.-> v6["WaitOnAddress也是面向同一进程内线程的"]
图10:volatile 的 busy loop 在 CPU、顺序、中间状态这三点上都不利。
5.3 让人读到中间状态
共享内存出事时,外观往往相当“普通”。
- 只有头部是新的
- 只有 payload 是旧的
- 只有长度字段更新完成
- 两个字段的组合坏掉了
画成图之后,问题的发生方式其实很简单:在 writer 写完 length 和 payload 之前,reader 恰好挤进了这个空隙。
sequenceDiagram
participant W as writer 进程
participant M as 共享内存
participant R as reader 进程
W->>M: 向 length 写入 1024
Note over M: length 是新的<br/>payload 还是旧的
R->>M: 读取 length
M-->>R: 1024
R->>M: 读取 1024 字节的 payload
M-->>R: 上一代的内容
Note over R: 抓到“只有头部是新的”<br/>这种中间状态
W->>M: 写入 payload
W->>M: 立起 ready flag
图11:writer 写完 payload 之前 reader 挤进空隙,抓到了中间状态。
只要 length 和 payload 的写入“不是一个不可分割的操作”,这个空隙就一定存在。如果只是原子地更新单个 scalar,事情还比较简单;但如果要公开一条 由多个字段组成的记录,就需要有 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(进程本地)的资源,只在该进程自己的上下文中才有意义。 即便是 Windows 的 view,同一个 mapping 在不同的 process 中 map 出来,虚拟地址也未必一致。711
所以,如果需要引用,基本做法是以 相对于基址的 offset 来持有。
typedef struct ShmRef {
uint64_t offset; // 相对于 segment 起始位置的偏移
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 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 对象
flowchart TB
accTitle: 破坏ABI的差异与防范办法
accDescr: 说明类型大小、alignment、pack与padding、32bit与64bit的差异都会破坏二进制约定,应以固定宽度整数、显式布局和带版本号的头部来防范。
b1["类型大小的差异"] --> b4["二进制约定被破坏"]
b2["pack与padding的差异"] --> b4
b3["32bit / 64bit的差异"] --> b4
b4 --> b5["固定宽度整数+显式布局"]
b5 --> b6["把version与size放进header"]
图13:类型大小与 padding 的差异会破坏 ABI,所以要靠固定宽度整数和显式布局。
5.6 初始化竞争
shared memory 很容易因为“创建方应该已经初始化好了”这种想当然而出问题。
在 Windows 上,如果 CreateFileMapping 遇到已存在的名称,会 返回已有的对象,可以通过 GetLastError() 得知 ERROR_ALREADY_EXISTS。
pagefile-backed(以页面文件为后备)的 mapping 的初始页面以 0 开始。8
在 POSIX 上,新建的 shared memory object 最初长度为 0,要用 ftruncate 来设定大小。新分配出来的字节会以 0 初始化。通过 O_CREAT | O_EXCL 进行的 create 是原子的。3
如果不了解这个差异,而是:
- open 之后立刻就用
- 没有初始化完成的标志
- 多个参与者同时进行初始化
- 不检查 version mismatch(版本不匹配)
这样做,就会随着启动顺序的不同而出问题。
至少应该在头部放入以下几种 state:
INITIALIZING(初始化中)READY(就绪)BROKEN(损坏)
然后规定 只有 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 与缓存行争用
人们常说共享内存很快。 但如果 hot(高频更新)的计数器挤在同一条 cache line(缓存行)里,CPU 之间就会不断地来回争夺这条 line,速度会下降到不能忽视的程度。
典型的例子是:
- producer 更新
write_index - consumer 更新
read_index - 这两者恰好落在同一条 cache line 上
这种情况。
遇到这种情况时,只要做到:
- 把 hot field 分散到不同的 cache line
- 把更新频率高的字段和更新频率低的字段分开
- 留意“1 个 writer 对应 1 条 cache line”
就能有相当大的改善。 经常会看到“对齐到 64 字节”的说法,但请把 64 字节理解为“在很多 CPU 上比较常见的数值”,而不是绝对的法则。
flowchart TB
accTitle: false sharing的典型情形与对策
accDescr: 说明producer更新的write_index与consumer更新的read_index落在同一条cache line上时,line会在CPU之间来回搬运而变慢,应把hot字段分到不同的cache line。
fp["producer更新write_index"] --> fl["同一条cache line"]
fc["consumer更新read_index"] --> fl
fl --> fs["line在CPU之间来回搬运而变慢"]
fs --> fx["把hot字段分到不同的cache line"]
图16:hot 的计数器落在同一条 cache line 上会变慢,要分到不同的 line。
5.9 轻视名称、权限与安全性
named shared memory(具名共享内存)很方便,但名称和权限处理得粗糙就会出事。
在 Windows 上,
- 存在
Global\和Local\两种 namespace(命名空间) - 从 session 0 以外 新建
Global\的 file mapping,需要SeCreateGlobalPrivilege权限 - object name(对象名称)会与 event / semaphore / mutex / waitable timer / job 共享同一个 namespace
也就是说,会出现这样的情况:
- 以为取名
"Global\\MyApp"就能让 service 和 desktop app 共享 - 结果却因为权限问题失败
- 而且之前已经用同一个名字先建了一个 mutex,于是变成
ERROR_INVALID_HANDLE
这类相当具有 Windows 特色的坑。
flowchart TB
accTitle: Global命名空间的泥潭
accDescr: 说明为了让service与desktop app共享而使用Global名称时,可能因为缺少SeCreateGlobalPrivilege而在权限上失败,也可能因为共享命名空间的同名mutex已存在而失败。
n1["想用Global的名称共享"] --> n2["因权限失败"]
n1 --> n3["与同名的mutex冲突"]
n2 -.-> n4["新建需要SeCreateGlobalPrivilege"]
n3 -.-> n5["变成ERROR_INVALID_HANDLE"]
图17:Global 命名空间容易踩到权限和名称冲突这两种泥潭。
在 POSIX 一侧,如果轻视 shm_open 的 mode 或 umask,也会出现权限设得过于宽松,或者反而打不开的情况。3
shared memory 并不是 “只要是内存就安全”。 从拥有读权限的 process 来看,内容是相当直白可见的。 如果要存放机密信息,就需要和普通内存一样,在 paging / swap / dump / 权限的语境下加以考虑。
5.10 随意地改变大小与升级
“事后想稍微扩大一下”共享内存,其实是相当危险的需求。
- Windows 的 mapping object 在创建时就已经确定了大小8
- POSIX 上如果不考虑
ftruncate与mmap之间的一致性,也会导致与参与者一侧的 map 长度不匹配316
在实务中,同一世代内保持大小不变 会更安全。 如果确实需要扩展,
- 创建新 version / name / generation 的 segment
- 切换参与者
- 关闭旧的 segment
这样做,出问题的概率会更低。
flowchart TB
accTitle: 扩容要靠切换世代
accDescr: 说明共享内存的大小在同一世代内保持不变,需要扩容时应创建新世代的segment并切换参与者、关闭旧segment,这样出问题的概率更低。
z0["事后想扩大"] --> z1["创建新世代的segment"]
z1 --> z2["切换参与者"]
z2 --> z3["关闭旧的segment"]
z0 -.-> z4["resize in place很危险"]
图18:大小在世代内固定,扩容通过切换到新世代的 segment 来完成。
5.11 把通知也全部塞进 shared memory
常见的做法是:
- 在共享内存里写
ready = 1 - 另一方用
while (!ready) Sleep(1);去等
这样做,一开始能跑起来。 但之后往往会以这些形式反噬:
- 白白浪费 CPU
Sleep(1)导致延迟出现波动- 难以察觉数据被漏掉
- 超时或结束通知很难写得干净
共享内存最好只用于 数据面,通知则交给 可以等待的 primitive 去处理。
- Windows:event / semaphore / mutex / named pipe 等217
- POSIX:semaphore / process-shared mutex + condvar 等1819
flowchart TB
accTitle: 把通知交给可以等待的primitive
accDescr: 说明用Sleep监视共享内存中的flag会浪费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:不要靠监视 flag,而要用可以等待的 primitive 接收通知。
5.12 以为“这样也能跟其他机器共享”
有时会突然想到:如果用 file-backed mapping 去 map 一个跨网络的共享文件,是不是也能实现跨机器的“类共享内存”呢?
这里是很危险的想法。
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远程文件来共享"] --> 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 在头部放置固定的 header
至少强烈建议在头部放置这样的 header。
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: 固定header各字段的职责
accDescr: 说明头部固定header中magic挡掉不相关对象与未初始化、abi_version与header_size挡掉布局差异、state挡掉初始化未完成、generation检测重新创建、heartbeat观察存活的职责分工。
h0["头部的固定header"] --> h1["用magic挡掉不相关的对象"]
h0 --> h2["用version挡掉差异"]
h0 --> h3["用state挡掉初始化未完成"]
h1 --> h4["成为用于观测的metadata"]
h2 --> h4
h3 --> h4
h0 -.-> h5["用generation检测重新创建"]
h5 -.-> h6["用heartbeat观察存活"]
图21:固定 header 的各个字段分别挡掉不相关对象、布局差异和初始化未完成的状态。
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
如果一个设计无法用文字说清楚“从哪一刻开始可以读”,那就很危险。
比如双缓冲的话,可以这样定义:
- 写入非公开一侧的缓冲区
- 确定校验和或长度
- 带 release 语义切换 active buffer index(当前有效缓冲区编号)
- reader 带 acquire 语义读取 active index
- 读取完成后确认 index 是否发生变化
像这样,把 公开的仪式 明确定义下来。画成图之后可以清楚看到,切换的瞬间只有一处。
sequenceDiagram
participant W as writer
participant BA as 缓冲区 A
participant IX as active index
participant BB as 缓冲区 B
participant R as reader
Note over IX: active index 是 A
R->>IX: 带 acquire 读取
IX-->>R: A
R->>BA: 读取缓冲区 A
W->>BB: 写入非公开一侧的 B
W->>BB: 确定长度与校验和
W->>IX: 带 release 切换到 B
Note over IX: active index 是 B
R->>IX: 读完之后再确认一次 index
IX-->>R: 已经变成 B
Note over R: 丢弃读到的内容<br/>从 B 重新读取
图23:双缓冲的公开仪式。reader 在读完之后要再确认一次 active index。
如果省掉“读完之后再确认一次 index”这一步,writer 就可能在 reader 正在读的过程中把同一个缓冲区拿去做下一次写入,于是又出现和 5.3 一样的中间状态。另外,如果只有 2 面缓冲区,重新读取的过程中还可能再次被切换,所以更新很快时要么增加面数,要么改用 5.3 中提到的 sequence counter 方式。
6.6 按世代固定大小
相比 resize in place(原地调整大小),不如像这样按世代划分:
name = MyShm.v3abi_version = 3generation = 42
这样更便于维护。
共享内存不像 API 那样会在调用时帮你做“类型检查”。 所以 不破坏一旦确定下来的 ABI 就显得格外重要。
6.7 加入可观测性
至少具备以下这些,会有很大帮助。
- 最后一次更新时间
- 最后一次成功的 sequence
- drop 次数 / overwrite 次数
- version mismatch 次数
- attach / detach 次数
- last error code(最后一次错误码)
- heartbeat
shared memory 出问题时,日志往往会显得很单薄。 自己加入 counters(计数器),故障处理会轻松很多。
6.8 先做异常路径测试
只测正常路径是不够的。至少应该看看以下这些情况:
- writer 在更新过程中被强制终止
- reader 延迟导致 ring 溢出
- 以 version mismatch 的状态连接
- 32bit / 64bit 混用
- 跨 session 的 open
- 权限不足
- 先行进程持有旧世代数据的情况下重启
- huge data 连续传输时的 cache miss / NUMA 影响
对 shared memory 而言,比起正常路径,“怎么把它弄坏”的测试 价值更大。
6.9 最小的往返示例
把上面这些做法落到一个能跑起来的最小结构里。整体只是:在 Windows 的 pagefile-backed file mapping 中放一个固定布局的块,通知则用 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' 按小端序排列而成的值 */
#define SHM_ABI 1u
#define STATE_INITIALIZING 0u
#define STATE_READY 1u
#pragma pack(push, 8)
typedef struct DemoBlock {
uint32_t magic;
uint32_t abi_version;
uint32_t block_size;
uint32_t state;
uint32_t request_len;
uint32_t reply_len;
char request[256];
char reply[256];
} DemoBlock; /* 24 + 256 + 256 = 536 字节 */
#pragma pack(pop)
int main(void)
{
HANDLE hMap = NULL, hReq = NULL, hRep = NULL;
DemoBlock *blk = NULL;
uint32_t len = 0;
DWORD waited = 0;
int rc = 1;
/* 1. 创建 pagefile-backed 的 mapping。初始页面以 0 开始。
* 如果同名的对象已经存在,CreateFileMappingW 会“成功并返回已有对象”,
* 因此只判断 NULL 是挡不住第二个发送方的。就这样往下走到 memset,
* 会把正在工作的对端的共享块清掉。
* GetLastError() 在成功时也会被设置,所以要紧接着读取。 */
hMap = CreateFileMappingW(INVALID_HANDLE_VALUE, NULL, PAGE_READWRITE,
0, (DWORD)sizeof(DemoBlock), SHM_NAME);
if (hMap == NULL) {
printf("CreateFileMapping failed: %lu\n", GetLastError());
goto cleanup;
}
if (GetLastError() == ERROR_ALREADY_EXISTS) {
printf("%ls 已经在使用中。发送方同时只能有一个\n", SHM_NAME);
goto cleanup;
}
blk = (DemoBlock *)MapViewOfFile(hMap, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(DemoBlock));
if (blk == NULL) {
printf("MapViewOfFile failed: %lu\n", GetLastError());
goto cleanup;
}
/* 2. 用于通知的 event。要和 mapping 取不同的名字 */
hReq = CreateEventW(NULL, FALSE, FALSE, EVT_REQ); /* auto-reset / 非信号态 */
hRep = CreateEventW(NULL, FALSE, FALSE, EVT_REP);
if (hReq == NULL || hRep == NULL) {
printf("CreateEvent failed: %lu\n", GetLastError());
goto cleanup;
}
/* 3. 只有创建方才做初始化。state 最后才置位 */
memset(blk, 0, sizeof(*blk));
blk->magic = SHM_MAGIC;
blk->abi_version = SHM_ABI;
blk->block_size = (uint32_t)sizeof(DemoBlock);
blk->state = STATE_INITIALIZING;
MemoryBarrier();
blk->state = STATE_READY;
/* 4. 本体 → 屏障 → 长度 → 通知,这个顺序不能打乱 */
strcpy_s(blk->request, sizeof(blk->request), "ping from writer");
MemoryBarrier();
blk->request_len = (uint32_t)strlen(blk->request);
if (!SetEvent(hReq)) {
printf("SetEvent failed: %lu\n", GetLastError());
goto cleanup;
}
/* 5. 等待回复。不要做无限等待 */
waited = WaitForSingleObject(hRep, 5000);
if (waited == WAIT_TIMEOUT) {
printf("没有收到来自 reader 的响应\n");
goto cleanup;
}
if (waited != WAIT_OBJECT_0) {
printf("WaitForSingleObject failed: %lu\n", GetLastError());
goto cleanup;
}
/* 6. 读取之前先对长度做范围检查 */
len = blk->reply_len;
if (len > sizeof(blk->reply)) {
printf("reply_len 超出范围: %u\n", len);
goto cleanup;
}
printf("reply: %.*s\n", (int)len, blk->reply);
rc = 0;
cleanup:
/* view 和 handle 全部关闭后名称也会随之消失。不要比 reader 先退出 */
if (hRep != NULL) CloseHandle(hRep);
if (hReq != NULL) CloseHandle(hReq);
if (blk != NULL) UnmapViewOfFile(blk);
if (hMap != NULL) CloseHandle(hMap);
return rc;
}
接收方把从 SHM_NAME 到 DemoBlock 的定义写得和发送方完全一致,只替换 main。在实务中会把这些公共部分抽到一个头文件里。
/* shm_reader.c : 接收方。常量与 DemoBlock 的定义和 shm_writer.c 完全相同。
* cl /W4 /nologo shm_reader.c */
int main(void)
{
HANDLE hMap = NULL, hReq = NULL, hRep = NULL;
DemoBlock *blk = NULL;
uint32_t len = 0;
int rc = 1;
hMap = OpenFileMappingW(FILE_MAP_ALL_ACCESS, FALSE, SHM_NAME);
if (hMap == NULL) {
printf("OpenFileMapping failed: %lu / writer 启动了吗\n", GetLastError());
goto cleanup;
}
blk = (DemoBlock *)MapViewOfFile(hMap, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(DemoBlock));
if (blk == NULL) {
printf("MapViewOfFile failed: %lu\n", GetLastError());
goto cleanup;
}
hReq = CreateEventW(NULL, FALSE, FALSE, EVT_REQ);
hRep = CreateEventW(NULL, FALSE, FALSE, EVT_REP);
if (hReq == NULL || hRep == NULL) {
printf("CreateEvent failed: %lu\n", GetLastError());
goto cleanup;
}
/* 1. 先等待通知。writer 只有在初始化结束之后才会立起它 */
if (WaitForSingleObject(hReq, 5000) != WAIT_OBJECT_0) {
printf("没有收到 request\n");
goto cleanup;
}
/* 2. 动手之前先确认 ABI 与 state */
if (blk->magic != SHM_MAGIC || blk->abi_version != SHM_ABI ||
blk->block_size != (uint32_t)sizeof(DemoBlock)) {
printf("ABI 不一致: magic=%08X abi=%u size=%u\n",
blk->magic, blk->abi_version, blk->block_size);
goto cleanup;
}
if (blk->state != STATE_READY) {
printf("初始化还没有完成: state=%u\n", blk->state);
goto cleanup;
}
/* 3. 对长度做范围检查之后再读取 */
len = blk->request_len;
if (len > sizeof(blk->request)) {
printf("request_len 超出范围: %u\n", len);
goto cleanup;
}
printf("request: %.*s\n", (int)len, blk->request);
/* 4. 本体 → 屏障 → 长度 → 通知,顺序与 writer 相同 */
strcpy_s(blk->reply, sizeof(blk->reply), "pong from reader");
MemoryBarrier();
blk->reply_len = (uint32_t)strlen(blk->reply);
if (!SetEvent(hRep)) {
printf("SetEvent failed: %lu\n", GetLastError());
goto cleanup;
}
rc = 0;
cleanup:
if (hRep != NULL) CloseHandle(hRep);
if (hReq != NULL) CloseHandle(hReq);
if (blk != NULL) UnmapViewOfFile(blk);
if (hMap != NULL) CloseHandle(hMap);
return rc;
}
放置 MemoryBarrier 是为了防止在本体写完之前,长度或标志先被对方看到的重排。5
如果想在编译期就发现布局偏差,可以给 MSVC 加上 /std:c11,用 <assert.h> 的 static_assert 把 sizeof(DemoBlock) 固定下来。
同一个块从 C# 一侧处理时是下面这样。要点是 用常量显式写出偏移量,写法上保证和 C 一侧的结构体一个字节都不差。带名称的 MemoryMappedFile 和 EventWaitHandle 是 Windows 专用的。1
// .NET 8 / Windows。发送方用 dotnet run -- write,接收方用 dotnet run -- read
using System.IO.MemoryMappedFiles;
using System.Text;
const string MapName = "Local\\KsShmDemo.v1.Block";
const string ReqName = "Local\\KsShmDemo.v1.Request";
const string RepName = "Local\\KsShmDemo.v1.Reply";
const uint Magic = 0x314D4853; // 'S','H','M','1'
const uint Abi = 1;
const int BlockSize = 536;
const int MaxBody = 256;
// 用偏移量常量固定住与 C 一侧 DemoBlock 相同的布局
const int OffMagic = 0, OffAbi = 4, OffBlockSize = 8, OffState = 12;
const int OffRequestLen = 16, OffReplyLen = 20, OffRequest = 24, OffReply = 280;
bool isWriter = args.Length > 0 && args[0] == "write";
// 发送方要用 CreateNew。用 CreateOrOpen 的话,第二个发送方会直接打开
// 正在工作的块,并在下面的初始化中破坏对端的交互。
// CreateNew 在同名对象已存在时会抛出 IOException,因此能及时发现
// (与 C 一侧判断 ERROR_ALREADY_EXISTS 是同样的用意)
using var mmf = isWriter
? MemoryMappedFile.CreateNew(MapName, BlockSize)
: MemoryMappedFile.OpenExisting(MapName);
using var view = mmf.CreateViewAccessor(0, BlockSize);
using var reqEvent = new EventWaitHandle(false, EventResetMode.AutoReset, ReqName);
using var repEvent = new EventWaitHandle(false, EventResetMode.AutoReset, RepName);
if (isWriter)
{
view.Write(OffMagic, Magic);
view.Write(OffAbi, Abi);
view.Write(OffBlockSize, (uint)BlockSize);
Thread.MemoryBarrier();
view.Write(OffState, 1u); // READY
byte[] request = Encoding.UTF8.GetBytes("ping from C#");
view.WriteArray(OffRequest, request, 0, request.Length);
Thread.MemoryBarrier();
view.Write(OffRequestLen, (uint)request.Length);
reqEvent.Set();
if (!repEvent.WaitOne(TimeSpan.FromSeconds(5)))
{
Console.WriteLine("没有收到来自 reader 的响应");
return 1;
}
return PrintBody(OffReplyLen, OffReply, "reply");
}
if (!reqEvent.WaitOne(TimeSpan.FromSeconds(5)))
{
Console.WriteLine("没有收到 request");
return 1;
}
if (view.ReadUInt32(OffMagic) != Magic || view.ReadUInt32(OffAbi) != Abi
|| view.ReadUInt32(OffBlockSize) != BlockSize || view.ReadUInt32(OffState) != 1u)
{
Console.WriteLine("ABI 不一致,或者初始化还没有完成");
return 1;
}
if (PrintBody(OffRequestLen, OffRequest, "request") != 0)
{
return 1;
}
byte[] reply = Encoding.UTF8.GetBytes("pong from C#");
view.WriteArray(OffReply, reply, 0, reply.Length);
Thread.MemoryBarrier();
view.Write(OffReplyLen, (uint)reply.Length);
repEvent.Set();
return 0;
int PrintBody(int lenOffset, int bodyOffset, string label)
{
uint length = view.ReadUInt32(lenOffset);
if (length > MaxBody)
{
Console.WriteLine($"{label} 的长度超出范围: {length}");
return 1;
}
byte[] body = new byte[length];
view.ReadArray(bodyOffset, body, 0, body.Length);
Console.WriteLine($"{label}: {Encoding.UTF8.GetString(body)}");
return 0;
}
C 版和 C# 版用的是相同的名称、相同的布局,所以把其中一个当发送方、另一个当接收方,同样能完成往返。所谓固定 ABI,就是这个意思。
这个示例刻意只做 一次往返。如果要做成连续传输,请往 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 页面以 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 |
| 名称的删除 | 最后一个 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 对象 /std::mutex - 是否有确保 reader 不会看到中间状态的 commit protocol
- 初始化者是否唯一确定
- 是否有异常终止时的 恢复流程
- 名称与权限是否已经明确
Global\是否真的有必要- 是否没有以 resize in place 为前提
- 是否试过 writer kill / reader stall / version mismatch / 权限不足等情况
9. 总结
共享内存如果用得好,是相当强大的工具。 尤其是在,
- 图像
- 音频
- 传感器序列
- 大批量数据
- 高频 snapshot
这类 同一台机器内的大数据 场景下,真的很有效。
不过,共享内存真正的本质,与“速度”相比更多是 责任的转移。 在减少复制和跨内核消息传递的同时,
- 同步
- 可见性
- 初始化
- ABI
- 恢复
- 权限
- 可观测性
这些都要由我们自己来承担。
flowchart TB
accTitle: 共享内存的本质是责任的转移
accDescr: 说明在减少复制与跨内核消息传递的同时,同步、可见性、初始化、ABI、恢复、权限、可观测性都要由应用侧承担,这就是责任的转移。
e1["减少复制与消息传递"] --> e2["作为代价要承担的东西"]
e2 --> e3["同步、可见性、初始化"]
e2 --> e4["ABI、恢复、权限"]
e2 --> e5["可观测性"]
图26:共享内存的本质与其说是“快”,不如说是把一致性的责任转移到我们这一侧。
所以,第一个共享内存模块,建议这样开始比较安全:
- SPSC ring buffer 或双缓冲
- 头部固定 header
- offset 引用
- 用别的通道来通知
- 具备 version / generation / heartbeat
- 具备异常路径测试
从这个形态开始,shared memory 会是相当顺手的工具。 反过来,如果一开始就把它当成“什么都能塞的高速公共内存”来对待,慢慢地就会从写应用程序变成搞考古。
10. 参考资料
- Windows:file mapping 与 named shared memory 的基础682
- Windows:namespace / security / synchronization1524512
- POSIX:
shm_open,shm_unlink,mmap, process-shared / robust synchronization322162013 - .NET:
MemoryMappedFile概述1
-
Microsoft Learn, “内存映射文件” / Microsoft Learn, “MemoryMappedFile 类” ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, “/volatile (volatile Keyword Interpretation)” / Microsoft Learn, “volatile (C++)” ↩ ↩2
-
Microsoft Learn, “Interlocked Variable Access” / Microsoft Learn, “MemoryBarrier function” ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, “Creating Named Shared Memory” / Microsoft Learn, “创建命名共享内存” ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, “Scope of Allocated Memory” ↩ ↩2
-
Microsoft Learn, “CreateFileMappingA function” ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10
-
man7.org, “POSIX Shared Memory” training slides ↩
-
Microsoft Learn, “WaitOnAddress function” ↩ ↩2
-
Microsoft Learn, “MapViewOfFileEx function” / Microsoft Learn, “MapViewOfFile function” ↩
-
Microsoft Learn, “Mutex Objects” ↩ ↩2 ↩3
-
man7.org, “pthread_mutex_lock(3p)” / man7.org, “pthread_mutexattr_setrobust(3)” ↩ ↩2 ↩3
-
man7.org, “pthread_mutex_consistent(3)” / man7.org, “pthread_mutex_consistent(3p)” ↩ ↩2
-
Microsoft Learn, “Kernel object namespaces” ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, “Using Mutex Objects” ↩
-
man7.org, “sem_init(3)” / man7.org, “sem_init(3p)” ↩ ↩2
-
Microsoft Learn, “Critical Section Objects” ↩
-
man7.org, “shm_unlink(3p)” ↩ ↩2
-
man7.org, “shm_open(3)” (shm_unlink semantics) ↩
-
Microsoft Learn, “文件映射的安全性和访问权限” / Microsoft Learn, “File Mapping Security and Access Rights” ↩ ↩2
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
参数为什么会坏掉 ── 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。
Time Travel Debugging ── 把长期运行中不复现的缺陷“录下来”再倒回去
一个月才出一次的缺陷,崩溃转储只拍得到结果。本文讲解如何用 WinDbg 的 Time Travel Debugging(TTD) 录制执行并倒回,涵盖 TTD.exe 的录制设计、环形缓冲区、TTD.Calls 查询,以及与转储的分工。
相关主题
与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。
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(互斥)是两个不同的问题。用 volatile bool 配合 busy loop 去监视的设计,会白白浪费 CPU,payload 与 ready flag 之间的顺序保证也会变得模糊,容易读到中间状态。另外 Windows 的 WaitOnAddress 是面向同一进程内线程的机制,不应该把它当作跨进程的等待机制来使用。通知应该交给 event、semaphore 这类可以等待的 primitive。
- 设计共享内存时,最先应该决定的是什么?
- 有 4 件事。第一是分离 control plane 与 data plane:控制(启动、停止、通知)走消息类通道,数据本体走共享内存;第二是收窄并发模型(最初选 SPSC 环形缓冲区或双缓冲区比较不容易出事);第三是确定所有者与生命周期,即谁创建、谁初始化、谁删除、参与者中途异常退出时由谁恢复;第四是 ABI 设计,包括布局与版本号。仅仅在头部放上 magic、version、size、state、generation、heartbeat 这些字段,排查问题的难易度就会有很大差别。