在 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() 时,内核主要完成四件事:

  1. 在当前进程中选择一段空闲虚拟地址区域;
  2. 创建对应的 VMA(虚拟内存区域)结构体;
  3. 将该 VMA 与共享内存对象关联,设置读写与共享属性;
  4. 返回虚拟地址起始指针。

mmap() 不会立刻为整个区域分配并填充所有物理页。 绝大多数页面会在首次实际访问时,通过缺页异常处理建立页表映射。这意味着第一次访问会有额外延迟,同时 NUMA 节点上页面的实际归属,由触发缺页的 CPU 节点决定。

2.3 缺页异常:真正建立页表的时刻

假设进程 A 第一次向共享内存写入数据,实际会发生:

  1. CPU 访问虚拟地址,发现对应页表项不存在;
  2. 触发缺页异常(page fault),陷入内核;
  3. 内核确认地址属于合法 VMA;
  4. 找到或分配共享对象对应的物理页;
  5. 为 A 进程建立页表项;
  6. 返回用户态,重新执行写入指令。

当进程 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 设置大小:ftruncateSIGBUS

新创建的 POSIX 共享内存对象长度为 0,必须调用 ftruncate() 设置大小:

ftruncate(fd, sizeof(SharedBlock));

如果忘记设置大小就映射并访问,会在访问时收到 SIGBUS 信号。它与空指针的 SIGSEGV 不同:

  • SIGSEGV:虚拟地址不合法或权限错误
  • SIGBUS:地址在映射区内,但底层对象没有对应有效存储

4.4 建立映射:MAP_SHAREDMAP_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

  1. 删除全局名称,后续新的 shm_open 无法打开旧对象;
  2. 已经打开、已经映射的进程可以继续使用;
  3. 当最后一个引用和映射消失后,内核才回收内存。

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 = truedata 只写了一半。

必须使用明确的同步机制。

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::stringstd::vectorstd::mapstd::shared_ptr 等标准容器都不能直接放进共享内存交给另一进程使用。它们内部包含指向进程私有堆的指针、分配器状态、运行库内部对象,跨进程后全部失效。

可行方案:

  • 固定长度数组;
  • 偏移指针 + 自定义分配器;
  • Boost.Interprocess 等专门的共享内存容器;
  • FlatBuffers、Cap’n Proto 等基于偏移的序列化格式。

7.3 用固定宽度类型,拒绝 long/size_t

跨 32/64 位进程、不同编译选项下,longsize_tenum 的大小和对齐可能不同。推荐统一使用 uint32_tuint64_tint32_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 至少有连接断开的信号,而共享内存只会留下半截数据。

常见的防护手段:

  1. 先写备用槽,原子切换指针:发布前数据始终写在非活跃槽,写完校验后再原子更新活跃索引,旧槽位在发布前始终有效。
  2. 版本号 + 校验和:每个槽位附带 generation、size、checksum,消费者只接受校验通过、状态完整的数据。
  3. 状态机与所有权标记:每个槽位有明确的状态(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,会产生大量远端访问。可以通过 numactlset_mempolicy()mbind() 调整内存策略,将生产者和消费者绑定到同一 NUMA 节点,或对大缓冲池采用交错分配。

11.5 大页

普通 4KB 页面在大内存区域下会产生大量页表项与 TLB miss。使用 2MB 甚至 1GB 大页可以显著降低地址翻译开销。

Linux 支持透明大页(THP)与显式 HugeTLB 两种路径。tmpfs/shmem 可以使用透明大页,显式大页则需要预留与专门映射。

11.6 预缺页与 mlock

实时系统不希望运行中突发大量缺页。可以在初始化阶段主动触碰每一页,或使用 MAP_POPULATEmadvise(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 的默认容量可能很小,大数据场景必须显式调大。


十三、高频踩坑清单

  1. 错用 MAP_PRIVATE:写入对方看不到,写时复制各自私有副本。
  2. 忘记 ftruncate:对象长度为 0,访问触发 SIGBUS
  3. 共享结构里放指针:另一进程地址空间无效,必崩。
  4. volatile 做同步volatile 不提供原子性、互斥和内存序,完全不够。
  5. sleep 猜写入时机:负载一变就失效,不是同步协议。
  6. 死循环忙轮询:占满 CPU、加剧缓存竞争,正确做法是条件变量或 eventfd。
  7. 运行时 ftruncate 缩容:其他进程仍映射旧区域,访问后半段触发 SIGBUS
  8. munmapshm_unlink:对象遗留在系统中,占用内存。
  9. 持锁执行重计算:锁内做长耗时运算,让对方长时间阻塞。正确做法是锁内只交换所有权,锁外处理数据。

十四、调试与观测工具

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(),而是设计一套健壮的并发协议与故障恢复机制。理解内核底层行为,再结合业务场景选择合适的方案与同步模型,才能既发挥共享内存的性能优势,又保证系统长期稳定运行。

可以把共享内存拆成五层知识框架:

  1. 存储对象层:POSIX shm / System V shm / 文件 / memfd / dma-buf
  2. 虚拟内存层:mmap / VMA / 页表 / 缺页异常
  3. 硬件访问层:Cache / TLB / 缓存一致性 / NUMA
  4. 并发协议层:mutex / cond / semaphore / futex / atomic / eventfd
  5. 业务协议层:消息格式 / 环形队列 / 所有权 / 版本 / 崩溃恢复
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐