共享内存(Shared Memory)—— 两套接口,同一种 IPC
共享内存是最快的 IPC 方式:两个(或更多)进程把同一块物理内存映射到各自的地址空间,之后数据交换就只是普通的内存读写——没有系统调用、没有数据拷贝、没有内核中转。本篇对比 Linux 上的两套共享内存接口:**System V IPC(
shmget/shmat)**和 POSIX(shm_open/mmap),讲清它们分别怎么用、本质区别在哪、什么时候选哪套。
相关:mmap 讲了 mmap 的完整机制(四象限、缺页惰性映射)——POSIX 共享内存底层就是
shm_open拿到 fd +mmap(MAP_SHARED);memory-layout 讲了共享内存映射在地址空间的哪个区域;fork-and-threads 讲了 fork 继承共享映射的语义。
零、一句话抓住本质
两套接口的目标完全一样:让多个进程共享同一块物理内存。区别只在"怎么找到这块内存":
| 维度 | System V IPC | POSIX |
|---|---|---|
| 标识方式 | 整数 key_t(通过 ftok() 生成 / IPC_PRIVATE) | 字符串名字(如 "/my_shm") |
| 内核对象 | IPC 对象(shmid,一套独立命名空间) | 文件描述符(fd),挂在 /dev/shm 下 |
| 生命周期 | 显式 shmctl(IPC_RMID) 删除,否则永久存在直到重启 | 内核引用计数:所有进程 unlink + unmap 后自动清理 |
| 接口风格 | 专用函数:shmget/shmat/shmdt/shmctl | 文件接口 + mmap:shm_open/ftruncate/mmap/munmap/shm_unlink |
| 查看/调试 | ipcs -m / ipcrm -m | ls /dev/shm/ / rm /dev/shm/xxx |
| 权限模型 | shmget 的 mode 参数(类似文件权限) | 文件权限(shm_open 的 mode),就是普通文件权限模型 |
| 推荐度 | 老旧且繁琐,建议迁移 | 现代应用首选,接口干净,与 mmap 统一 |
一、System V IPC 共享内存
System V IPC 是 AT&T UNIX System V 引入的一套进程间通信机制,包括共享内存、信号量(semaphore)和消息队列(message queue)。共享内存是其中最直接的一个——就是把一段物理页映射到多个进程的地址空间。
1.1 核心 API
#include <sys/shm.h>
// ① 创建/获取共享内存段
int shmget(key_t key, size_t size, int shmflg);
// ② 映射到进程地址空间(类似 mmap 的 MAP_SHARED)
void *shmat(int shmid, const void *shmaddr, int shmflg);
// ③ 解除映射(不从内核删除对象)
int shmdt(const void *shmaddr);
// ④ 控制操作(IPC_RMID 删除、IPC_STAT 查看、IPC_SET 改权限等)
int shmctl(int shmid, int cmd, struct shmid_ds *buf);1.2 完整使用流程
#include <sys/shm.h>
#include <sys/ipc.h>
#include <stdio.h>
#include <string.h>
int main() {
key_t key = ftok("/tmp", 'A'); // 用文件+项目ID生成 key
if (key == -1) { perror("ftok"); return 1; }
// ① 创建共享内存段(4KB)
int shmid = shmget(key, 4096, IPC_CREAT | 0666);
if (shmid == -1) { perror("shmget"); return 1; }
// ② 映射到本进程地址空间
char *ptr = (char *)shmat(shmid, NULL, 0);
if (ptr == (void *)-1) { perror("shmat"); return 1; }
// ③ 读写——普通内存操作,无系统调用!
strcpy(ptr, "hello from System V shared memory!");
// ④ 解除映射
shmdt(ptr);
// ⑤ 删除共享内存对象(通常由最后一个进程做)
// shmctl(shmid, IPC_RMID, NULL);
return 0;
}1.3 System V 的关键痛点
key碰撞:ftok()用文件 inode + 项目 ID 生成 key,不同项目可能撞上同一个 key。- 清理噩梦:如果没人调用
shmctl(IPC_RMID),共享内存段永久存在(即使所有进程都退出了)。用ipcs -m能看到大量"已无人使用但未删除"的段。 - 大小不可变:
shmget创建时定死大小,之后不能ftruncate调整——只能删了重建。 - 独立的命名空间:
shmid是一套独立于文件系统的整数 ID,调试和管理都别扭。
用
ipcs -m和ipcrm -m <shmid>管理 System V 共享内存段。
二、POSIX 共享内存
POSIX 共享内存在设计上彻底换了一种思路:把共享内存对象当作文件来对待。在 /dev/shm 下有一个对应的文件节点,通过文件描述符来操作——这使它和 mmap 无缝对接。
2.1 核心 API
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
// ① 打开/创建共享内存对象(返回文件描述符)
int shm_open(const char *name, int oflag, mode_t mode);
// ② 调整大小(和普通文件一样用 ftruncate)
int ftruncate(int fd, off_t length);
// ③ 映射到地址空间(这就是 mmap 的 文件+MAP_SHARED 场景)
void *mmap(void *addr, size_t length, int prot, int flags,
int fd, off_t offset);
// ④ 解除映射
int munmap(void *addr, size_t length);
// ⑤ 删除共享内存对象名字(引用计数归零后才真正释放)
int shm_unlink(const char *name);
// ⑥ 关闭文件描述符(不影响已映射的内存)
int close(int fd);2.2 完整使用流程
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>
int main() {
const char *name = "/my_shared_memory";
// ① 创建共享内存对象(O_CREAT | O_RDWR,权限 0666)
int fd = shm_open(name, O_CREAT | O_RDWR, 0666);
if (fd == -1) { perror("shm_open"); return 1; }
// ② 设置大小(truncate 到 4KB)
if (ftruncate(fd, 4096) == -1) { perror("ftruncate"); return 1; }
// ③ 映射到本进程地址空间
char *ptr = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0);
if (ptr == MAP_FAILED) { perror("mmap"); return 1; }
// ④ fd 已不需要,可以关了(不影响已映射的内存)
close(fd);
// ⑤ 读写——普通内存操作
strcpy(ptr, "hello from POSIX shared memory!");
// ⑥ 解除映射
munmap(ptr, 4096);
// ⑦ 删除名字(最后一个引用者退出后内核自动清理)
shm_unlink(name);
return 0;
}2.3 POSIX 的优势
- 名字即文件路径:
shm_open("/my_shm", ...)创建一个文件节点,ls -la /dev/shm/直接能看到——调试特别直观。 - 引用计数自动清理:所有进程都
shm_unlink+munmap后,内核自动释放物理内存,不会像 System V 那样泄漏。 - 大小可变:关闭映射后重新
ftruncate+mmap就能调整大小。 - 与 mmap 统一:本质就是"文件 + MAP_SHARED"这一种场景,和 mmap 里讲的四象限完全一致——你学一套 mmap 就能同时理解文件映射和共享内存两种 IPC。
- fd 可以传给其他机制:比如放进
epoll(虽然共享内存本身不需要事件通知,但可以配合eventfd做信号)。
三、两套接口的本质对比
System V IPC POSIX
┌──────────┐ ┌──────────────┐
│ key_t │ (整数) │ "/name" │ (字符串)
│ shmid │ (独立ID空间) │ fd │ (文件描述符)
│ │ │ │
创建: shmget(key,sz,flg) shm_open()+ftruncate()
映射: shmat(shmid,...) mmap(MAP_SHARED, fd)
解除: shmdt(ptr) munmap(ptr)
删除: shmctl(shmid,IPC_RMID) shm_unlink("/name")
│ │ │ │
▼ ▼ ▼ ▼
┌────────────────────────────────────────────────────┐
│ 同一块物理内存(RAM) │
│ 被映射到不同进程的不同虚拟地址上 │
│ 进程A: ptr = 0x7f...000 进程B: ptr = 0x7f...800 │
│ └─ 都指向同一 PFN ─┘ │
└────────────────────────────────────────────────────┘核心理解:两套接口只是"找到同一块物理内存的方式"不同——System V 用整数 key(像"内存段的身份证号"),POSIX 用字符串名字(像"内存段的文件路径")。一旦映射进地址空间(shmat 或 mmap),后续的读写就是完全一样的普通内存访问。
四、与普通文件 mmap 的区别
很多人第一次看到 POSIX 共享内存 shm_open 时会困惑:它和直接 open 一个普通文件再 mmap(MAP_SHARED) 有什么区别?
| 维度 | shm_open + mmap | open 普通文件 + mmap |
|---|---|---|
| 后备存储 | 纯内存(tmpfs,挂载在 /dev/shm) | 磁盘文件(有 page cache 层) |
| 是否落盘 | 不落盘(重启丢失) | 脏页异步回写磁盘(持久化) |
| 性能 | 纯内存访问,无磁盘 IO | 首次读从磁盘加载,脏页被写回 |
| 物理页类型 | 匿名页(anonymous page) | 文件页(file-backed page) |
| Page Cache | 不经过 page cache | 经过 page cache |
| 用途 | 纯 IPC:进程间快速交换数据 | 持久化共享:数据库文件、配置文件 |
| 清理 | 重启/shm_unlink 后消失 | 文件本身在磁盘上持久存在 |
shm_open创建的共享内存对象本质是/dev/shm下tmpfs里的一个文件节点。tmpfs是内存文件系统——文件内容只存在于 RAM(必要时可 swap),断电就没了。但和普通文件 mmap 不同,共享内存页是匿名页,不会污染 page cache,回收路径更简单。
五、使用场景与选型
5.1 System V 遗留场景
旧代码(1990s-2000s):
- 大量遗留项目 / 教科书的 "共享内存 IPC" 示例
- Oracle 某些版本用 System V 共享内存做 SGA
- Nginx、Apache 早期某些模块用 System V 信号量 + 共享内存
→ 新项目不推荐;但需能读得懂旧代码。5.2 POSIX 推荐场景
需要多进程共享数据的所有场景:
- 进程间大块数据交换(零拷贝)
- 生产者-消费者:进程A写指针、进程B读,配合 eventfd/semaphore 通知
- 多进程缓存:多个 worker 共享同一份只读数据
- perf 采样环缓冲区(内核写,用户读——这不是普通文件的 shm_open,而是 perf_event_open→mmap,但同属 mmap 共享映射范畴,见 mmap.md §3.6)
→ 任何新项目需要共享内存,用 POSIX 这套。5.3 什么时候不用共享内存?
- **只需要"通知" **:用信号/
eventfd/管道。共享内存的优势在于"大块数据的零拷贝",只有几个字节的状态同步别为了用而用 - 需要跨网络:共享内存只能单机,跨机用 socket/消息队列
- 需要持久化:用普通文件 mmap(
open+mmap(MAP_SHARED)),落盘由内核保证
六、同步问题——共享内存天生不带锁
两套接口都不提供同步机制。共享内存本身就是一块 RAM——你的指针指向它之后,读写就是普通 mov 指令:
进程A: ptr[0] = 42; ← CPU 的一条 store 指令
进程B: int x = ptr[0]; ← CPU 的一条 load 指令
问题:A 写到一半、B 读到了"半个值"?→ 需要用锁/原子操作保护。三套配套同步方案(不是本篇重点,只点出方向):
| 方案 | 接口 | 特点 |
|---|---|---|
| POSIX 信号量 | sem_open/sem_wait/sem_post | 和 POSIX shm 风格一致,都基于名字 |
pthread_mutex + PTHREAD_PROCESS_SHARED | pthread_mutexattr_setpshared() | 放在共享内存里,所有进程都能锁 |
| C11 原子操作 | atomic_load/atomic_store(<stdatomic.h>) | 无锁场景,适合极简的生产者-消费者 |
如果只是单生产者+单消费者,甚至不需要锁——两个进程通过头尾指针(放到各自不重叠的区域)就能做环形缓冲区,用
__atomic_store/load保证内存序。
七、观测与调试
# === POSIX 共享内存 ===
# 看所有 POSIX 共享内存对象(本质是 /dev/shm 下的文件)
ls -la /dev/shm/
# -rw------- 1 user user 4096 Jul 14 10:00 my_shared_memory
# 看哪个进程打开了
lsof /dev/shm/my_shared_memory
# 强制删除(前提是没有进程还在用)
rm /dev/shm/my_shared_memory
# === System V IPC 共享内存 ===
# 查看所有 System V 共享内存段
ipcs -m
# key shmid owner perms bytes nattch status
# 0x00000000 65538 user 644 4096 0
# 删除某个段(没有人 attach 时才能删)
ipcrm -m 65538
# 清除所有无人使用的共享内存段(危险!先确认)
# ipcrm -a
# === 看进程的映射 ===
# 两种共享内存在 /proc/$PID/maps 里的体现:
cat /proc/$$/maps
# POSIX: 7f... rw-s ... /dev/shm/my_shared_memory (s=shared)
# SysV: 7f... rw-s ... [shmid=65538] (某些内核版本可能显示不同)八、一句话总结
共享内存是最快的 IPC——数据在物理 RAM 里、被多个进程的页表 PTE 指向同一个 PFN,读写就是普通
mov指令,没有内核中转。Linux 有两套接口:老旧的 System V IPC(shmget/shmat/shmctl,用整数 key,不手动删就永远泄漏)和现代的 POSIX(shm_open/mmap,用字符串名+fd,引用计数自动清理,本质就是把 mmap 的"文件+MAP_SHARED"场景背后的文件换成/dev/shm下的 tmpfs 节点)。新项目一律用 POSIX,但需要能读旧代码里的 System V 写法。两套接口都不带同步——它不是 bug,是让你按需选锁(POSIX semaphore / pthread mutex 共享 / 原子操作)。
九、深入阅读
- mmap —— mmap 完整机制:四象限、缺页惰性映射、MAP_SHARED 的页表级共享语义
- memory-layout —— 共享内存映射在进程地址空间的哪个位置
- fork-and-threads —— fork 对共享映射的继承语义
- tlb —— 多个进程的 PTE 指向同一 PFN 的 TLB 一致性(cross-CPU shootdown)
- cache-organization —— 两个 CPU 核心同时写同一块共享内存时的缓存一致性(MESI 协议)