共享内存的陷阱与实务最佳实践
· 小村 豪 · Shared Memory, IPC, Concurrency, C++, C#, Windows 开发
图像帧、检测结果、时间序列日志、盘口信息、巨大缓冲区。 想在同一台机器内以低延迟交换大数据时,共享内存相当有吸引力。
不过这里有点危险的是,共享内存常以「快速 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::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)的环形缓冲区,或者双缓冲区开始
总而言之,共享内存确实快,但用得粗糙就容易染上「感觉自己会自动同步」的错觉病。避开这一点,是最初的胜负关键。
2. 共享内存共享的是什么,不共享的是什么
共享内存粗略地说,就是把同一段物理页映射到多个进程的虚拟地址空间中的机制。
Windows 使用 file mapping object 和 view,POSIX 则是把 shared memory object 拿去 mmap。273
这里有两点很重要。
- 共享的是内容的字节序列,而不是虚拟地址本身
- coherent(一致)和「已同步」是两件不同的事
Windows 的文档中也提到,从同一个 file mapping object 创建出来的 view,在同一时间点上是 coherent 的。 但这并不意味着读者随时都能读到一致且已更新完成的记录。8
例如,
- writer 打算先写
length - 接着写
payload - 最后写
ready flag
按这个顺序写入,但如果 reader 端没有任何同步就去读,就可能看到新的 length 搭配旧的 payload这种组合。
共享内存并不会自动帮你修正这一点。
也就是说,共享内存共享的是字节。 不共享的是含义、顺序、完成通知、恢复方针。 这些都需要由我们自己来设计。
3. 共享内存适合的场景 / 不适合的场景
| 场景 | 是否适合 | 理由 |
|---|---|---|
| 在同一台机器内传递大型帧或缓冲区 | 适合 | 容易减少复制次数 |
| 高频率的传感器数值、图像、音频、盘口信息等 | 适合 | 容易做到低延迟、高吞吐 |
| 只交换很小的命令或响应 | 不太适合 | 为控制所付出的同步成本相对较重 |
| 与其他机器交换数据 | 不适合 | 共享内存基本上以同一主机为前提 |
| 不同语言、不同版本长期共存 | 较难 | 需要设计 ABI 和版本管理 |
| 同时还需要持久化 | 视目的而定 | file-backed mapping 是有力选项,但持久化与 IPC 的职责容易混在一起 |
在实务中,「控制走消息类通道,数据本体走共享内存」这种分离方式相当有效。 例如,
- UI 进程通知 worker 进程「使用下一帧」用 event / pipe / socket
- 实际的帧数据本体则放在共享内存中
这样的结构。 这种做法相当省心。
4. 最先应该决定的 4 件事
设计共享内存时,最先应该决定的是以下 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,是相当勇猛的选择。通常之后都会冒出内存顺序的妖怪。
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 并不是能拯救共享内存设计的魔法。
至少 atomicity(原子性) 和 mutual exclusion(互斥) 是两个不同的问题。45
例如放一个 volatile bool ready; 然后用 busy loop 去监视的设计,会出现:
- 白白浪费 CPU
- payload 与 ready 之间的顺序保证变得模糊
- 不具备可移植性(portable)
- 容易读到中间状态
基本上没什么好处。
而且 Windows 的 WaitOnAddress 是面向同一进程内线程(thread)的机制。
不应该把它当作跨进程(cross-process)的等待机制来使用,这样更安全。10
5.3 让人读到中间状态
共享内存出事时,外观往往相当「普通」。
- 只有头部是新的
- 只有 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 换算成自己的地址。
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 对象
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。
仅仅遵守这个做法,整个世界就会安静很多。
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
- 损坏时的全量重新初始化流程
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 上比较常见的数值」,而不是绝对的法则。
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 特色的坑。
在 POSIX 一侧,如果轻视 shm_open 的 mode 或 umask,也会出现权限设得过于宽松,或者反而打不开的情况。3
shared memory 并不是「只要是内存就安全」。 从拥有读权限的 process 来看,内容是相当直白可见的。 如果要存放机密信息,就需要和普通内存一样,在 paging / swap / dump / 权限的语境下加以考虑。
5.10 随意地改变大小与升级
「事后想稍微扩大一下」共享内存,其实是相当危险的需求。
- Windows 的 mapping object 在创建时就已经确定了大小8
- POSIX 上如果不考虑
ftruncate与mmap之间的一致性,也会导致与参与者一侧的 map 长度不匹配316
在实务中,同一世代(generation)内保持大小不变会更安全。 如果确实需要扩展,按以下步骤进行:
- 创建新 version / name / generation 的 segment
- 切换参与者
- 关闭旧的 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 一个跨网络的共享文件,是不是也能实现跨机器的「类共享内存」呢?
这里是很危险的想法。
Windows 的 CreateFileMapping 文档中也提到,对 remote file(远程文件)不保证 coherence(一致性)。
如果两台机器都以 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 在头部放置固定的 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(元数据)。
6.3 采用 offset 引用
引用不用 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
如果一个设计无法用文字说清楚「从哪一刻开始可以读」,那就很危险。
比如双缓冲的话,可以这样定义:
- 写入非公开一侧的缓冲区
- 确定校验和或长度
- 带 release 语义切换 active buffer index(当前有效缓冲区编号)
- reader 带 acquire 语义读取 active index
- 读取完成后确认 index 是否发生变化
像这样,把「公开的仪式」明确定义下来。
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 而言,比起正常路径,「怎么把它弄坏」的测试价值更大。
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
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
- 恢复
- 权限
- 可观测性
这些都要由我们自己来承担。
所以,第一个共享内存模块,建议这样开始比较安全:
- 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
-
Microsoft Learn, “/volatile (volatile Keyword Interpretation)” / Microsoft Learn, “volatile (C++)” ↩ ↩2
-
Microsoft Learn, “Interlocked Variable Access” / Microsoft Learn, “MemoryBarrier function” ↩ ↩2 ↩3 ↩4
-
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
-
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
-
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 应用安全处理子进程的清单 - Job Object、退出传播、标准输入输出、watchdog 最佳实践
在 Windows 应用中安全处理子进程,比起选择启动 API,更重要的是设计进程树的所有权和结束流程。本文整理 Job Object、退出传播、标准输入输出与 watchdog。
防止 Windows 应用程序重复启动 ── 命名 Mutex 与重复执行时的窗口前置
整理业务型 Windows 应用程序的常见需求——「不让同一个应用程序启动两次」——如何用命名 Mutex 来实现。涵盖 Global\ 与 Local\ 命名空间差异在 RDP 环境中的陷阱、拥有线程限制与 AbandonedMutexException、在 SetFor...
委托开发 Windows 应用程序前该梳理的事项
在委托外包开发 Windows 应用程序之前,梳理现有软件改造、设备联动、COM/ActiveX、发布与更新、维护等需要注意的要点。
Windows Forms、WPF、WinUI 该怎么选 - 新建项目、存量资产、发布、UI 表现力判断表
从存量资产的规模、界面是表单为主还是需要表现力、现代 Windows UI 是否是产品刚需、发布与运维怎么落地这四个角度,整理 WinForms、WPF、WinUI 该怎么选的判断表,并提醒只想用 Windows App SDK 不必全面迁移到 WinUI。
如何公平比较 C#/C++/Java/Go 的运行速度
整理如何公平比较 C#、C++、Java、Go 的运行速度,涵盖测量设计、warm-up、环境固定、统计数据的解读方式,以及具体的基准测试项目。
常见问题
汇总了咨询这一主题时常见的问题。
- 写入共享内存的值,其他进程能立刻正确读到吗?
- 「看得到」和「能安全读到」是两个不同的问题。共享内存只是让多个进程看到同一段字节序列的机制,并不是同步本身。即使 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 这些字段,事故排查的难易度就会有很大差别。
作者简介
本文作者的个人简介页面。
Go Komura
小村软件有限公司 代表
以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。