Linux --- 共享内存
在 Linux 进程间通信(IPC)的所有机制中,共享内存是理论带宽最高、延迟最低的方案,却也是最容易因为同步设计、生命周期管理、崩溃一致性考虑不周而埋下隐患的机制。很多开发者对共享内存的理解停留在“mmap 一下就能用”的表层,实际工程中却频繁遇到数据竞争、进程死锁、异常退出后资源泄漏、性能不达预期等问题。
一、本质:共享内存共享的是同一组物理内存页
共享内存是把同一个内存对象映射到 A、B 两个进程各自的虚拟地址空间。
两个进程看到的虚拟地址通常不同,但最终指向同一组物理页:
进程 A 虚拟地址空间 进程 B 虚拟地址空间
0x7f10_0000 0x7f80_0000
│ │
│ A 的页表 │ B 的页表
└──────────────┐ ┌────────────────┘
▼ ▼
同一物理页
进程天然拥有相互隔离的虚拟地址空间。普通情况下,即使两个进程的虚拟地址数值完全相同,也对应不同的页表、不同的物理内存,一方无法直接访问另一方的指针。
传统管道、Socket 的数据路径至少包含两次拷贝:用户态 A → 内核 IPC 缓冲区 → 用户态 B。而共享内存跳过了内核中转,建立映射后,业务数据直接在同一块物理内存上读写,这是其性能优势的根本来源。
但必须明确:共享内存只提供共同可访问的数据区域,不自动提供消息边界、写入完成通知、读写互斥、生产者消费者速度协调、进程崩溃恢复、数据版本兼容、访问权限校验等能力。这些恰恰是工程落地中最容易出问题的部分。
二、内核视角:共享内存工作原理
2.1 虚拟地址、页表与物理页
进程访问共享内存时,使用的仍然是普通虚拟地址。CPU 执行读写操作的完整链路为:虚拟地址 → TLB 查询 → 页表查询 → 物理页地址 → CPU Cache / 主存。
A 和 B 两个进程的页表可以分别建立不同虚拟页到同一物理页的映射:
- A 页表项:虚拟页
VA_A→ 物理页 P - B 页表项:虚拟页
VA_B→ 物理页 P
两个进程的虚拟地址可以不同,页表项分属不同进程,但最终映射目标完全一致。这也是共享内存结构中不能保存普通指针的根本原因——指针是虚拟地址,在另一个进程的地址空间中没有意义。
2.2 mmap() 只建立映射关系,不分配物理页
调用 mmap() 时,内核主要完成四件事:
- 在当前进程中选择一段空闲虚拟地址区域;
- 创建对应的 VMA(虚拟内存区域)结构体;
- 将该 VMA 与共享内存对象关联,设置读写与共享属性;
- 返回虚拟地址起始指针。
mmap() 不会立刻为整个区域分配并填充所有物理页。 绝大多数页面会在首次实际访问时,通过缺页异常处理建立页表映射。这意味着第一次访问会有额外延迟,同时 NUMA 节点上页面的实际归属,由触发缺页的 CPU 节点决定。
2.3 缺页异常:真正建立页表的时刻
假设进程 A 第一次向共享内存写入数据,实际会发生:
- CPU 访问虚拟地址,发现对应页表项不存在;
- 触发缺页异常(page fault),陷入内核;
- 内核确认地址属于合法 VMA;
- 找到或分配共享对象对应的物理页;
- 为 A 进程建立页表项;
- 返回用户态,重新执行写入指令。
当进程 B 首次访问同一位置时,也会触发一次缺页:内核不会重新分配物理页,而是直接让 B 的页表项指向已存在的物理页。这是建立映射,不是把 A 的页面复制给 B。
2.4 缓存一致性 ≠ 程序同步正确
A 和 B 运行在不同 CPU 核心时,数据会经过 CPU Cache。现代 CPU 的缓存一致性协议(如 MESI)会保证同一缓存行在多个核心之间的最终一致性,但这完全不等于程序层面的同步正确。
缓存一致性不能自动保证:
- B 一定在 A 写完之后才读取;
- 结构体多个字段被 B 看成一个完整事务;
- 编译器不重排普通变量的访问顺序;
- CPU 不重排内存操作指令;
- B 不会读到一半新数据、一半旧数据。
共享内存的并发访问,仍然必须使用锁、原子操作与正确的内存序来保证正确性。
三、Linux 上的 5 种共享内存方案
3.1 POSIX 共享内存(shm_open)
这是新项目中最常用、接口相对清晰的命名共享内存机制,核心接口为:
shm_open → ftruncate → mmap → munmap → shm_unlink
POSIX 共享内存对象在 Linux 上由挂载在 /dev/shm 的 tmpfs 支持,对象有全局名称,无关进程可以通过名称打开。新建对象初始长度为 0,必须通过 ftruncate() 设置大小后才能正常访问。
3.2 System V 共享内存(shmget)
属于经典的 System V IPC 体系,核心接口为:
shmget → shmat → shmdt → shmctl
它使用整数 key 和内核返回的 shmid 标识共享段,常见于数据库、中间件、遗留工业软件与历史项目。
一个容易误解的点:shmget(IPC_PRIVATE, ...) 中的 IPC_PRIVATE 不代表“只有当前进程能访问”,而是要求创建一个全新的、唯一的共享段;其他进程拿到 shmid 后仍然可以附加到该段上。
3.3 普通文件 + mmap(MAP_SHARED)
也可以直接映射磁盘上的普通文件:
int fd = open("/data/shared.bin", O_RDWR | O_CREAT, 0600);
ftruncate(fd, size);
void* p = mmap(nullptr, size, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
这种方案适合数据需要落盘、重启后需要恢复、多进程共同修改磁盘文件的场景,例如数据库索引、持久化状态机。
MAP_SHARED 的修改对所有映射同一区域的进程可见;对于普通文件,修改最终也会回写到磁盘。msync() 用于控制修改何时同步到底层文件,不能替代进程间的同步原语。
3.4 匿名共享映射
具有父子关系的进程可以使用匿名共享映射,无需名称和文件:
void* p = mmap(nullptr, size, PROT_READ|PROT_WRITE,
MAP_SHARED | MAP_ANONYMOUS, -1, 0);
pid_t pid = fork();
fork() 后父子进程继承该映射,共同访问同一块内存。它适合父进程先分配、再派生子进程的场景,无关进程难以发现和打开该区域。
注意必须使用 MAP_SHARED | MAP_ANONYMOUS;如果写成 MAP_PRIVATE | MAP_ANONYMOUS,写入时会触发写时复制,父子进程各自看到私有副本,失去共享效果。
3.5 memfd_create:更安全的匿名内存文件
memfd_create() 创建一个以内存为 backing 的匿名文件对象,没有全局路径名,通常通过 Unix 域 Socket 将文件描述符传递给其他进程,对方再执行 mmap()。
int fd = memfd_create("frame_pool", MFD_CLOEXEC | MFD_ALLOW_SEALING);
ftruncate(fd, size);
void* p = mmap(nullptr, size, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
它尤其适合不希望使用全局名称、需要精确控制访问权限的场景。其 sealing 机制可以限制后续写入、扩容、缩容等操作,减少不可信对端造成 TOCTOU 或 SIGBUS 的风险,广泛用于多媒体、浏览器、Wayland 等缓冲区共享场景。
方案对比总览
| 方案 | 命名方式 | 无关进程访问难度 | 持久化能力 | 典型适用场景 |
|---|---|---|---|---|
POSIX shm_open |
全局名称 | 容易 | 内核持久,直到显式删除 | 通用本机 IPC |
System V shmget |
key/shmid | 容易 | 内核持久,需显式删除 | 遗留系统、数据库中间件 |
普通文件 mmap |
文件路径 | 容易 | 磁盘持久化 | 数据库索引、状态持久化 |
匿名 MAP_SHARED |
无 | 仅靠 fork 继承 | 无 | 父子进程间共享 |
memfd_create |
无全局路径 | 通过 FD 授权 | 无 | 安全共享、FD 传递场景 |
dma-buf |
FD | 通过 FD 授权 | 无 | CPU/GPU/相机设备间缓冲共享 |
四、POSIX 共享内存完整生命周期
4.1 创建与打开:原子判定创建者
int fd = shm_open("/robot_state_v1", O_CREAT | O_EXCL | O_RDWR, 0600);
POSIX 共享内存名称的可移植格式为 /名称,名称中不要再包含额外的 /。
O_CREAT | O_EXCL 可以原子地完成“对象不存在则创建,存在则失败”的逻辑,非常适合判定谁是初始化者:创建成功的一方负责初始化互斥锁、条件变量与数据头部,打开失败(errno == EEXIST)的一方直接使用已有对象。
4.2 权限控制:别再默认 0666
shm_open 的 mode 参数会受进程 umask 影响,最终权限为 请求权限 & ~umask。生产环境不建议使用 0666,更合理的选择是:
0600:仅当前用户可读写0660:当前用户和指定组可读写
还可以通过 fchmod()、fchown() 精细管理权限与所有者。
4.3 设置大小:ftruncate 与 SIGBUS
新创建的 POSIX 共享内存对象长度为 0,必须调用 ftruncate() 设置大小:
ftruncate(fd, sizeof(SharedBlock));
如果忘记设置大小就映射并访问,会在访问时收到 SIGBUS 信号。它与空指针的 SIGSEGV 不同:
SIGSEGV:虚拟地址不合法或权限错误SIGBUS:地址在映射区内,但底层对象没有对应有效存储
4.4 建立映射:MAP_SHARED 与 MAP_PRIVATE 的天壤之别
void* raw = mmap(
nullptr,
sizeof(SharedBlock),
PROT_READ | PROT_WRITE,
MAP_SHARED,
fd,
0
);
两个关键 flag 必须区分清楚:
MAP_SHARED:修改对所有映射同一区域的进程可见,是共享 IPC 的必选项MAP_PRIVATE:写入时触发私有写时复制,修改不会发布给其他进程
如果错用了 MAP_PRIVATE,会出现“我明明写了,对方看不到”的经典问题。
4.5 映射后可以关闭文件描述符
成功 mmap() 之后,调用 close(fd) 不会让映射失效。文件描述符和内存映射是内核中两层独立的引用关系,映射建立后即可关闭 FD,避免文件描述符泄漏。
4.6 解除映射与删除名称
munmap() 只移除当前进程中的映射,不会删除对象名称,也不影响其他进程的已有映射。
shm_unlink() 语义类似文件系统的 unlink:
- 删除全局名称,后续新的
shm_open无法打开旧对象; - 已经打开、已经映射的进程可以继续使用;
- 当最后一个引用和映射消失后,内核才回收内存。
POSIX 共享内存具有内核持久性:如果不调用 shm_unlink(),对象会一直存在到系统关机。进程异常退出后经常会在 /dev/shm 下看到遗留对象,就是这个原因。
五、共享内存到底是不是“零拷贝”?
“零拷贝”必须分层讨论,不能一概而论。
5.1 理想情况:零次 IPC 拷贝
如果生产者直接在共享内存上生成数据,消费者直接在共享内存上处理,那么进程间的有效载荷拷贝次数为 0。共享内存消除了传统 IPC 中“用户态 A → 内核缓冲区 → 用户态 B”的两次拷贝。
5.2 实际工程:业务侧的 memcpy 无法避免
如果程序本身执行了:
std::memcpy(shared->data, local_buffer, size);
那么仍然存在一次显式拷贝。如果消费者再复制到自己的私有缓冲区,就是两次。
共享内存消除的是 IPC 路径上的内核中转拷贝,无法消除程序主动执行的业务层拷贝。能不能做到真正零拷贝,取决于数据生产者能否直接输出到共享内存。
5.3 硬件层面的缓存行迁移不是软件拷贝
不同核心之间通过缓存一致性协议迁移缓存行,会消耗带宽和延迟,但这不属于 IPC 语境下的缓冲区拷贝。不过在高频访问场景下,缓存行乒乓反而可能成为共享内存的主要性能瓶颈。
5.4 msync 不是“让对方看到数据”的开关
对于 MAP_SHARED 映射,一方的修改对另一方天然可见,不需要调用 msync()。
msync() 的真正作用是控制文件-backed 映射何时把修改同步到底层存储,它不负责加锁、发布完成标志、建立消息边界,也不能替代内存屏障与同步原语。
六、同步机制:共享内存最核心的部分
没有同步的共享内存,就是数据竞争的温床。
6.1 为什么裸变量绝对不行
很多初学者会写出这样的代码:
struct Shared {
bool ready;
char data[4096];
};
// A: 先写数据,再置 ready
// B: 等 ready 为 true 再读数据
这是典型的错误设计:存在并发数据竞争,编译器和 CPU 都可能重排访问顺序,B 完全可能看到 ready = true 但 data 只写了一半。
必须使用明确的同步机制。
6.2 进程共享互斥锁
互斥锁本身必须放在共享内存中,并设置进程共享属性:
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_setpshared(&attr, PTHREAD_PROCESS_SHARED);
pthread_mutex_init(&shared->mutex, &attr);
PTHREAD_PROCESS_SHARED 表示任何能访问该内存的进程都可以操作这把锁。互斥锁同时解决了互斥、临界区完整性、内存可见性与顺序三个问题。
6.3 条件变量:等待状态变化
互斥锁负责保护状态,条件变量负责等待状态变化,二者搭配是最经典的生产者消费者模型:
pthread_condattr_t attr;
pthread_condattr_init(&attr);
pthread_condattr_setpshared(&attr, PTHREAD_PROCESS_SHARED);
pthread_cond_init(&shared->cond, &attr);
使用时必须遵守 while (!condition) 范式,因为条件变量存在虚假唤醒,多消费者也会竞争同一状态。
6.4 POSIX 无名信号量
信号量可以直接放入共享内存,适合计数型资源:
sem_init(&shared->items, 1, 0); // pshared=1 表示跨进程共享
sem_init(&shared->spaces, 1, 1);
它非常适合表示队列中可读元素数、空闲槽位数、可用缓冲区数等计数场景。
6.5 futex:高性能同步的底层基石
futex 是 Linux 上互斥锁、信号量等同步原语的底层实现,核心思想是:
- 无竞争时:用户态原子操作完成加解锁,不进入内核
- 有竞争时:
futex(FUTEX_WAIT)进入内核休眠,futex(FUTEX_WAKE)唤醒等待者
futex word 必须位于共享内存中,内核保证“比较并休眠”的原子性,避免丢失唤醒。
普通业务不建议直接手写 futex 锁,需要处理的边界条件极多(原子指令、内存序、超时、信号中断、进程死亡、优先级继承、ABA 问题等),优先使用成熟的 pthread 原语。
6.6 robust mutex:处理持锁进程死亡
普通互斥锁有一个致命问题:如果持锁进程在临界区内崩溃,锁会永远处于锁定状态,其他进程永久阻塞。
解决方案是设置健壮互斥锁:
pthread_mutexattr_setrobust(&attr, PTHREAD_MUTEX_ROBUST);
此时另一方加锁会得到 EOWNERDEAD 返回值,表示:
- 本方已经获得锁;
- 上一个所有者异常死亡;
- 共享数据可能不一致,需要修复;
- 修复完成后调用
pthread_mutex_consistent()。
注意:robust mutex 只负责发现“锁持有者死了”,不会自动修复业务数据。数据一致性仍然需要应用层定义回滚与校验策略。
6.7 eventfd:事件通知的最佳搭档
共享内存存数据,eventfd 传通知,是工业界非常常见的组合:
- 生产者写完数据后,向 eventfd 写入一个计数表示有新数据;
- 消费者把 eventfd 加入
poll/epoll事件循环,等待通知。
这种模式避免了忙轮询,又能接入统一事件循环,非常适合大帧数据、低频率通知的场景。
6.8 原子变量与内存序
无锁设计常使用 std::atomic 配合 release/acquire 内存序:
- 生产者
release存储发布标志,保证之前的数据写入都已完成; - 消费者
acquire加载标志,保证之后读取的数据都是最新的。
但需要注意工程边界:ISO C++ 标准对跨进程共享原子的可移植语义定义不如 POSIX 进程共享原语明确。Linux 主流平台上,真正 lock-free 的整数原子可用于共享映射,但需要确认 is_always_lock_free、对齐、ABI 一致性。高可移植性要求下,仍然优先使用 POSIX 同步原语。
七、共享内存数据结构设计:
7.1 绝对不能存普通指针
A 进程里的指针值,放到 B 进程的地址空间中毫无意义,轻则读到垃圾,重则直接崩溃。
正确做法是保存相对于共享内存基址的偏移量:
struct Shared {
uint64_t data_offset;
};
// 使用时:base + offset
这样不要求两个进程的映射基地址相同。
7.2 不能直接放 STL 容器
std::string、std::vector、std::map、std::shared_ptr 等标准容器都不能直接放进共享内存交给另一进程使用。它们内部包含指向进程私有堆的指针、分配器状态、运行库内部对象,跨进程后全部失效。
可行方案:
- 固定长度数组;
- 偏移指针 + 自定义分配器;
- Boost.Interprocess 等专门的共享内存容器;
- FlatBuffers、Cap’n Proto 等基于偏移的序列化格式。
7.3 用固定宽度类型,拒绝 long/size_t
跨 32/64 位进程、不同编译选项下,long、size_t、enum 的大小和对齐可能不同。推荐统一使用 uint32_t、uint64_t、int32_t 等固定宽度类型。
7.4 魔数 + 版本号:防御旧对象与不兼容
共享头部必须包含魔数和版本号:
constexpr uint32_t kMagic = 0x53484D31; // "SHM1"
struct SharedHeader {
uint32_t magic;
uint16_t major_version;
uint16_t minor_version;
uint32_t total_size;
};
消费者映射后首先校验魔数和版本,避免:
- 旧进程误读新布局;
- 上一次崩溃留下的旧对象;
- 映射错了共享对象;
- 对象大小不匹配。
7.5 显式记录总大小与校验
头部中保存映射总大小,并与 fstat() 返回的实际大小做对比。不要只相信对象名称正确。
7.6 对齐与缓存行隔离
高频读写的原子变量、生产者/消费者索引应当按缓存行对齐并隔离,避免伪共享。x86 平台通常以 64 字节为缓存行基准。
八、工程中常用的 4 种通信模型
8.1 单缓冲
最简单的模型:一块共享区加一把锁,生产者写、消费者读,互斥访问。适合低频命令、状态数据,优点是简单易验证,缺点是读写相互阻塞,吞吐量有限。
8.2 双缓冲
两块缓冲区交替使用:一块消费者读取,一块生产者写入,完成后原子交换索引。适合图像、点云、状态快照等“最新值优先”的实时数据,读写可以在不同缓冲区上并行。
8.3 环形缓冲区
固定数量槽位组成环形,生产者不断写入、消费者依次读取,是高频流式数据的首选模型,例如相机帧、雷达点云、关节状态、音视频、高频日志。
工业级环形队列通常每个槽位带 sequence 号,通过原子发布协议实现无锁并发。
8.4 描述符 + 数据池
对于大图像、大帧数据,通常将控制区与数据区分开:
- 控制区:小,频繁同步,存放槽位索引、时间戳、大小、状态;
- 数据区:大,减少元数据修改,存放实际帧数据。
这种模式减少了锁持有大数据区的时间,也更容易实现缓冲池所有权转移。
九、启动初始化:90% 的竞态都发生在这里
9.1 谁来初始化
互斥锁、条件变量、信号量、数据头部都只能初始化一次,不能让两个进程同时执行初始化。
标准做法是用 O_CREAT | O_EXCL 原子判定创建者:创建成功的一方负责完整初始化,另一方直接打开使用。
9.2 创建成功 ≠ 初始化完成
可能出现 A 创建成功但还没初始化完 mutex,B 已经打开并开始加锁的情况。因此必须设计初始化状态机(Empty → Initializing → Ready),或者由 supervisor 严格控制启动顺序,初始化完成后再通知消费者接入。
9.3 遗留旧对象:崩溃重启的隐形陷阱
程序异常退出会留下共享内存对象。新启动时如果直接 O_CREAT 打开,可能拿到包含旧数据、损坏锁、错误版本的旧对象。
启动时必须校验魔数、版本、大小、初始化状态,必要时由管理进程删除并重建。
十、崩溃一致性:写到一半进程死了的处理方式
共享内存比 Socket 更容易受到“写到一半进程死亡”的影响,因为 Socket 至少有连接断开的信号,而共享内存只会留下半截数据。
常见的防护手段:
- 先写备用槽,原子切换指针:发布前数据始终写在非活跃槽,写完校验后再原子更新活跃索引,旧槽位在发布前始终有效。
- 版本号 + 校验和:每个槽位附带 generation、size、checksum,消费者只接受校验通过、状态完整的数据。
- 状态机与所有权标记:每个槽位有明确的状态(FREE / WRITING / READY / READING),异常恢复时可以检测到死在中间状态的槽位并回收。
注意只记录所有者 PID 并不足够可靠,因为 PID 会复用。严格方案需要结合进程启动时间、pidfd、心跳、supervisor 记录共同判定。
十一、性能优化
11.1 减少不必要的私有缓冲区
尽量让生产者直接在共享内存上生成数据,避免“SDK 缓冲区 → 私有工作缓冲区 → 共享内存”的多次拷贝。能原地处理就原地处理,能零拷贝就零拷贝。
11.2 消灭伪共享(False Sharing)
如果写索引和读索引在同一个缓存行,即使读写的是不同变量,也会导致缓存行在核心之间来回乒乓,严重影响性能。
解决方法是将高频写入的变量按缓存行对齐并填充隔离,确保不同核心的热点变量不在同一缓存行。
11.3 避免全局原子的缓存行乒乓
真共享的热点原子变量同样会造成缓存行迁移。优化方向:
- 遵循单写者原则;
- 每个生产者维护独立计数器,定期合并;
- 减少全局原子变量数量;
- 读多写少数据与写热点分开存放。
11.4 NUMA 亲和性
多路服务器中,访问本地 NUMA 节点比远端节点延迟更低、带宽更高。共享内存页面通常在首次缺页时分配在当前 CPU 所在节点。
如果生产者在 Node 0 触页、消费者长期跑在 Node 1,会产生大量远端访问。可以通过 numactl、set_mempolicy()、mbind() 调整内存策略,将生产者和消费者绑定到同一 NUMA 节点,或对大缓冲池采用交错分配。
11.5 大页
普通 4KB 页面在大内存区域下会产生大量页表项与 TLB miss。使用 2MB 甚至 1GB 大页可以显著降低地址翻译开销。
Linux 支持透明大页(THP)与显式 HugeTLB 两种路径。tmpfs/shmem 可以使用透明大页,显式大页则需要预留与专门映射。
11.6 预缺页与 mlock
实时系统不希望运行中突发大量缺页。可以在初始化阶段主动触碰每一页,或使用 MAP_POPULATE、madvise(MADV_WILLNEED) 预分配。
对延迟要求极高的场景,可以使用 mlock() 将共享区域锁定在物理内存,防止被回收或换出。需要注意 RLIMIT_MEMLOCK 限制与 OOM 风险。
11.7 澄清:tmpfs 不等于永不换出
/dev/shm 基于 tmpfs,但 tmpfs 属于虚拟内存体系,在内存压力下仍然可以被换出到 swap。它不是永久锁定在物理 RAM 中。要求低延迟和可预测性时,必须结合 mlock、swap 配置、cgroup 限制综合设计。
十二、安全性
12.1 权限收紧:拒绝 0666
共享内存使用文件式权限模型,敏感数据绝不能设置全局可读写。应遵循最小权限原则,仅授权给必要的用户和组。
12.2 名称抢占攻击
攻击者可以提前创建同名共享内存对象,服务启动时如果只用 O_CREAT 而不用 O_EXCL,就可能打开攻击者控制的对象,造成数据篡改与安全风险。
必须使用 O_CREAT | O_EXCL,并校验所有者、权限、魔数、版本。更高安全要求的场景推荐使用 memfd_create + FD 传递,彻底避免全局名称暴露。
12.3 不可信对端:永远校验所有字段
对端可能在你校验后修改数据、篡改长度、截短底层对象、构造越界索引。即使使用了 sealing,也必须校验:
- 长度不超过容量;
- 偏移 + 大小不溢出;
- 槽位索引不越界;
- 版本与魔数合法;
- 校验和匹配。
12.4 容器与命名空间的隔离边界
System V IPC 受 IPC namespace 隔离;POSIX 共享内存通过 /dev/shm 实现,可见性由 mount namespace 决定。容器部署时要注意 /dev/shm 的默认容量可能很小,大数据场景必须显式调大。
十三、高频踩坑清单
- 错用
MAP_PRIVATE:写入对方看不到,写时复制各自私有副本。 - 忘记
ftruncate:对象长度为 0,访问触发SIGBUS。 - 共享结构里放指针:另一进程地址空间无效,必崩。
- 靠
volatile做同步:volatile不提供原子性、互斥和内存序,完全不够。 - 用
sleep猜写入时机:负载一变就失效,不是同步协议。 - 死循环忙轮询:占满 CPU、加剧缓存竞争,正确做法是条件变量或 eventfd。
- 运行时
ftruncate缩容:其他进程仍映射旧区域,访问后半段触发SIGBUS。 - 只
munmap不shm_unlink:对象遗留在系统中,占用内存。 - 持锁执行重计算:锁内做长耗时运算,让对方长时间阻塞。正确做法是锁内只交换所有权,锁外处理数据。
十四、调试与观测工具
14.1 对象与进程映射观测
# 查看 POSIX 共享内存对象
ls -lah /dev/shm
df -h /dev/shm
# 查看 System V 共享内存
ipcs -m
# 查看进程映射详情
cat /proc/<pid>/maps
cat /proc/<pid>/smaps
14.2 系统调用跟踪
strace -f -e trace=openat,mmap,munmap,ftruncate,futex,unlink ./program
14.3 性能分析
perf stat / perf record:分析缺页、缓存未命中、上下文切换perf c2c:定位伪共享与缓存行竞争numastat:观察 NUMA 远端访问情况- Intel VTune:深度伪共享与内存带宽分析
十五、工程选型速查表
| 场景 | 推荐方案 |
|---|---|
| 两个无关进程共享少量状态 | POSIX shm + process-shared mutex |
| 图像帧最新值快照 | 双缓冲 + 原子索引 + eventfd |
| 连续高频传感器帧 | SPSC 环形缓冲区 |
| 多消费者图像处理 | 共享缓冲池 + 描述符 + 引用状态 |
| 父子进程间共享 | `MAP_SHARED |
| 不希望使用全局名称 | memfd_create + Unix Socket 传 FD |
| 需要落盘与重启恢复 | 普通文件 mmap(MAP_SHARED) |
| GPU/相机/显示设备共享 | dma-buf 或设备 SDK 缓冲区机制 |
| 遗留数据库/中间件兼容 | System V shm |
| 强实时低抖动场景 | 预分配 + 预缺页 + mlock + 固定容量 |
总结
共享内存的本质,是让多个进程的虚拟地址映射到同一块物理内存。它只解决了“数据不需要在进程间拷贝”这一个问题,而同步、通知、生命周期、崩溃恢复、版本兼容、性能优化、安全防护这些真正决定系统可靠性与上限的部分,都需要开发者自己设计与实现。
写好一套可靠的共享内存系统,难点从来不是调用 mmap(),而是设计一套健壮的并发协议与故障恢复机制。理解内核底层行为,再结合业务场景选择合适的方案与同步模型,才能既发挥共享内存的性能优势,又保证系统长期稳定运行。
可以把共享内存拆成五层知识框架:
- 存储对象层:POSIX shm / System V shm / 文件 / memfd / dma-buf
- 虚拟内存层:mmap / VMA / 页表 / 缺页异常
- 硬件访问层:Cache / TLB / 缓存一致性 / NUMA
- 并发协议层:mutex / cond / semaphore / futex / atomic / eventfd
- 业务协议层:消息格式 / 环形队列 / 所有权 / 版本 / 崩溃恢复
更多推荐

所有评论(0)