项目标题里那几个 API 名一放出来,基本就能猜到是刚入坑 Linux 进程间通信,或者正在准备面试。我当年第一次看到 ftok、shmget 这一串函数时,第一反应是“这名字怎么这么怪”,第二反应是在网上翻了半天教程,发现大家都在贴同一个示例代码,但没人讲清楚为什么 kv 进程之间的共享内存在 shmctl 之后还要 shmat,也没人提 ipcs 那条命令看着乱七八糟的输出到底在说什么。这篇就当是我把踩过的坑、看过的文档和实际项目里用共享内存的经验一次性倒出来,从原理、API 每一个参数、完整可跑的 demo 到运维排查,争取让你看完能直接上手,也能应付面试里“你给我讲讲共享内存”这种问题。
1. 选对 IPC 方式,比闷头写代码更重要
1.1 为什么共享内存被叫做“最快的 IPC”
Linux 下进程间通信的方式很多:管道、消息队列、信号量、socket、共享内存。如果你去查资料,几乎每一篇都会告诉你“共享内存速度最快”。这不只是口号,而是实打实的机制差异决定的。
管道和消息队列本质上是靠内核做中转。A 进程把数据 write 进内核缓冲区,B 进程再从内核缓冲区 read 出来,数据从用户态拷贝到内核态,然后再拷贝回用户态。整个过程至少两次数据拷贝,如果数据量大,系统调用本身的切换开销也非常可观。而共享内存的思路完全不同:它把同一块物理内存映射到多个进程的虚拟地址空间里,A 进程往这块内存里写数据,B 进程直接读同一块物理页,全程不需要内核参与数据搬移。
我见过一个量化交易相关的嵌入式项目,两个模块之间要高频传递行情快照,每秒钟几千次,每次快照几十 KB。最初用消息队列,压测时 CPU 占用和延迟都很不理想,后来改成共享内存,延迟直接降了一个数量级。说白了,共享内存之所以快,就是因为它避免了“用户态-内核态-用户态”的拷贝链路,数据的物理地址只有一份,谁都能访问,访问的时候跟访问自己的堆内存一样。
不过,这个“快”是有代价的:内核不再帮你同步,多个进程同时读写同一块内存时,数据一致性问题就要自己解决。所以,共享内存通常不是单独出现的,它要搭配锁、信号量或原子操作一起用。这也是面试题里最喜欢挖坑的地方。
1.2 共享内存的使用场景和选型边界
不是所有场景都适合上共享内存。我给这类选择列的判断依据很简单:
- 数据量大,比如几十 KB 甚至几 MB 级别的消息,走管道或消息队列拷贝成本太高。
- 频率高,比如每秒成百上千次交互,系统调用开销占比变得明显。
- 进程间关系紧密,比如同一台机器上由同一组程序协作,而不是跨网络的分布式通信。
- 数据本身不需要跨机器传输。共享内存只在单机内有效,跨机器还是要走 socket 或消息队列。
反过来,如果你的数据量很小,频率也不高,用共享内存就属于杀鸡用牛刀。维护同步的复杂度可能会超过你省下的拷贝开销,不划算。还有一点要注意:写共享内存的程序崩溃了,内存里可能残留半截数据,下个进程读到的可能是脏数据。针对这个问题,实际工程里往往会加“头结构 + 校验和”的方式,写端先填头信息,再填数据,最后填校验;读端校验通过才认为数据有效。这是我强烈建议你在设计阶段就纳入的考虑点,别等出了问题再补。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心 API 拆解:从 ftok 到 shmctl 的每一环
2.1 ftok:一个路径名如何变成唯一 key
很多人写共享内存 demo 时,会直接硬编码一个 key,比如 key = 9527。这个用法能跑通,但风险在于不同程序之间很难约定到底谁是 9527,如果撞了号,要么创建失败,要么误连到别人的内存上。ftok 的作用,就是根据一个真实存在的文件路径和一个项目 ID,生成一个相对唯一的 key。
c复制key_t ftok(const char *pathname, int proj_id);
这里有两个重点。第一个,pathname 必须指向一个真实存在且调用进程有权限访问的文件。ftok 内部是拿该文件的 inode 编号和 proj_id 做一些位运算组合出 key,如果文件不存在,调用直接返回 -1,errno 设为 ENOENT。第二个,proj_id 是一个 8 位的整数(实际取低 8 位),取值一般从 1 开始往上写,如果你传给它的值超过 255,内核会截断,这一点很多人容易忽略。
实际项目中,我习惯用程序自己的配置文件路径来生成 key,比如 /etc/myapp/config.ini。这样同一台机器上不同应用天然分开,不容易撞 key。但你一定要记住一个隐藏坑:如果你先创建了文件,调 ftok 拿到 key,然后删掉文件又重新创建一个同名文件,inode 很可能变了,再调 ftok 拿到的 key 就和之前不一样了,之前创建的共享内存就找不到了。这种情况在开发调试时特别容易遇到,找不到共享内存时别先怀疑 shmget,先回头看一眼 ftok 的路径文件还在不在、有没有被动过。
2.2 shmget:申请一片共享内存,参数细节决定成败
shmget 负责创建或者获取一块共享内存。
c复制int shmget(key_t key, size_t size, int shmflg);
第一个参数就是你从 ftok 拿到的 key,也可以是 IPC_PRIVATE(此时等价于 key 为 0,创建一个只有当前进程有权限用的共享内存,适合父子进程通过 fork 继承 fd 的场景)。第二个参数是共享内存的大小,单位是字节。注意内核是按页(通常是 4KB)分配物理内存的,你申请 1 字节,实际占用也是 4KB,ipcs -m 里看到的也是按页对齐后的大小。我遇到过有人申请 size = 1,然后往里面写几百字节的字符串,结果数据越界覆盖了其他进程的数据,总线错误或段错误都来了。
第三个参数 shmflg 是最容易出问题的位置。它由权限位和附加标志位组成:
IPC_CREAT:如果 key 对应的共享内存不存在,则创建;如果已存在,直接返回已有对象的 id。IPC_EXCL:必须搭配IPC_CREAT使用。如果 key 已经存在,调用失败,返回 -1。这个参数在需要保证“只创建一次”的场景下非常有用,能避免多个进程同时创建带来的竞态。- 权限位:例如
0644,表示属主可读写,其他人只读,与文件权限的语义一致。
还有一个比较容易忽略的问题:shmget 返回的是共享内存的标识符 shmid,这类似于文件描述符,但它并不自动附着到进程地址空间。初次接触的人很容易在这里卡住:创建成功了,然后呢?然后需要 shmat 把这块内存映射到自己的地址空间,才能像操作 C 语言数组一样读写它。
这里分享一个我在工程里常用的判断逻辑:两个毫无血缘关系的进程(不是父子进程)之间要共享内存,必须用同一个 key 去 shmget;如果是父子进程,可以用 IPC_PRIVATE 创建后子进程直接继承 shmid,简单省事,根本不用 ftok。面试里如果问你“IPC_PRIVATE 创建的共享内存怎么让另一个进程拿到”,答“只能通过继承或进程间传递 shmid”即可得分,这正好体现了 fork 之后文件描述符和 IPC 标识符都会被子进程继承的机制。
2.3 shmat 与 shmdt:挂载与拆离的正确姿势
创建或获取到 shmid 之后,下一步就是挂载。
c复制void *shmat(int shmid, const void *shmaddr, int shmflg);
- 第二个参数
shmaddr一般传NULL,让内核帮你选择一个合适的地址。如果你自己指定一个地址,还要加SHM_RND标志让地址对齐到页边界,否则结果是未定义的。我基本从不手动指定地址,没这个必要。 - 第三个参数
shmflg常用两个值:0 表示以可读可写方式挂载;SHM_RDONLY表示只读挂载。注意只读挂载只对当前进程有效,原本权限就是可写的共享内存,另一个进程依然可以写。所以别以为加了个SHM_RDONLY就等于安全保护,真正保护数据靠的是权限位和同步机制。
shmat 成功以后返回值是映射的起始地址,这就是你在 C 代码里直接操作的指针。收到数据后,如果进程不再使用该共享内存,必须调用 shmdt 拆离:
c复制int shmdt(const void *shmaddr);
这个步骤经常被初学者漏掉,因为漏掉的短期后果不明显。但进程退出时内核会自动把挂载解除,并不是说非调不可。可如果你在一个长驻服务里,反复 shmat 不 shmdt,每次挂载都会占用一块虚拟地址空间,并导致内核里的挂载计数一直增长。时间长了,你会在 /proc/<pid>/maps 里看到一串又一串来自同一共享内存的映射段,内存碎片和地址空间浪费都是问题。规范的做法是:挂载之后用成对出现的 shmdt 来拆离,让每段代码都保持入口和出口的清晰对应。
拆离之后,共享内存本身并不会消失,它只是从当前进程的地址空间里解除了映射。真正决定它是否“还存在”的,是内核里的引用计数和是否为被标记删除的状态。这也正是 shmctl 存在的意义。
2.4 shmctl:查询状态、修改属性与销毁
共享内存与普通的堆内存一个很大的不同是:它是有生命周期管理器的,这个管理器就是 shmctl。
c复制int shmctl(int shmid, int cmd, struct shmid_ds *buf);
我平常用得最多的是两个命令:
IPC_STAT:获取这块共享内存的状态,把信息填充到shmid_ds结构体里。结构体里的shm_segsz是大小,shm_nattch是当前已挂载的进程数,shm_perm.mode是权限,shm_lpid、shm_cpid分别是最近操作和创建进程的 PID。这些字段在排查“谁还在用这块内存”时非常好使。IPC_RMID:标记这块共享内存为删除状态。注意这里说的是“标记删除”,不是立即释放。内核会等所有挂载它的进程都shmdt之后才真正回收物理页面。所以你会发现一个有趣的现象:执行完IPC_RMID后,ipcs -m里可能看不到这块内存了(因为被标记删除后不再列入常规列表),但已挂载的进程仍然能够正常读写它,直到最后一个进程拆离才会真正释放。
这个特性在实际工程里非常重要。比如某个守护进程正在频繁读写共享内存,你想回收内存,如果直接把整个进程 kill 掉再删内存,逻辑上没问题;但如果你想优雅地热更新数据,就可以先调用 IPC_RMID 标记删除,让老进程继续读到退出前,再启动新进程创建同名共享内存。老进程一拆离,旧的物理页自动释放,新进程用新的物理页,无缝切换。
另外 IPC_SET 可以用来修改共享内存的属主、权限等,但我很少在实际项目里用它,知道有这个能力即可,业务上改权限往往意味着设计上就要重新审视。
3. 实操:搭一套可用的共享内存通信 demo
3.1 写端程序:创建、写入、通知
下面这套 demo 我实际跑过很多次,两端均为纯 C 实现,逻辑非常简单:写端创建共享内存,往里面放一个两段式结构(头信息 + 数据区),然后读端把数据解析出来。为了突出共享内存本身的用法,我用简单的轮询代替信号量,实际项目里请换成信号量或条件变量,后面我会强调。
先定义一个简单的结构:
c复制#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <unistd.h>
#define SHM_PATH "/tmp/shm_demo"
#define PROJ_ID 1
#define SHM_SIZE 4096
typedef struct {
int seq;
int len;
char data[128];
} msg_t;
写端逻辑:
c复制int main() {
key_t key = ftok(SHM_PATH, PROJ_ID);
if (key == -1) {
perror("ftok");
exit(EXIT_FAILURE);
}
int shmid = shmget(key, SHM_SIZE, IPC_CREAT | IPC_EXCL | 0666);
if (shmid == -1) {
perror("shmget");
exit(EXIT_FAILURE);
}
msg_t *msg = (msg_t *)shmat(shmid, NULL, 0);
if (msg == (void *)-1) {
perror("shmat");
exit(EXIT_FAILURE);
}
msg->seq = 1;
strcpy(msg->data, "hello shared memory");
msg->len = strlen(msg->data);
printf("[writer] written seq=%d data=%s\n", msg->seq, msg->data);
getchar(); // 等待手动输入,方便观察读端
shmdt(msg);
shmctl(shmid, IPC_RMID, NULL);
return 0;
}
这里有几个细节值得解释。第一,shmget 用了 IPC_CREAT | IPC_EXCL | 0666,意味着如果 /tmp/shm_demo 对应的 key 已经有共享内存存在,shmget 会返回 -1 并置 errno 为 EEXIST。第一次编译运行没问题,但如果上一次程序异常退出,共享内存没有被 IPC_RMID 清理,下一次运行时就会创建失败。想偷懒省事可以把 IPC_EXCL 去掉,但这样如果旧内存里还有上一轮的脏数据,你可能会读到旧残留。所以我推荐的模式是:开发阶段保留 IPC_EXCL,一旦报 EEXIST,就手动用 ipcrm -m shmid 清掉;生产代码则应该由专门的初始化模块负责创建和清理,或者用 IPC_STAT 先检查状态再决定是否 IPC_RMID。
第二,shmat 返回值判断要和 (void *)-1 比较,不要写成 msg == NULL。因为共享内存挂载地址理论上可能从 NULL 附近的保留地址开始,虽然概率极低,但作为防御性编程,必须用 (void *)-1 判断失败。这个点面试时偶尔会被问到,答对了很加分。
3.2 读端程序:附着、读取、释放
读端相对简单:
c复制int main() {
key_t key = ftok(SHM_PATH, PROJ_ID);
if (key == -1) {
perror("ftok");
exit(EXIT_FAILURE);
}
int shmid = shmget(key, SHM_SIZE, 0666);
if (shmid == -1) {
perror("shmget");
exit(EXIT_FAILURE);
}
msg_t *msg = (msg_t *)shmat(shmid, NULL, 0);
if (msg == (void *)-1) {
perror("shmat");
exit(EXIT_FAILURE);
}
while (1) {
if (msg->len > 0) {
printf("[reader] got seq=%d data=%s\n", msg->seq, msg->data);
break;
}
usleep(1000);
}
shmdt(msg);
return 0;
}
读端用 shmget 时不加 IPC_CREAT,只有 0666 权限位,意思是“必须已经存在才能获取,否则返回 -1”。这样可以避免读端意外创建一块空白共享内存,导致写端写入失败或读到错误对象。这个使用姿势我希望你养成习惯:谁负责创建,谁负责销毁;业务方只负责获取和读写。
循环里的 usleep(1000) 是一个简单的轮询等待,现实中不建议这么干。最实用的改进是用信号量 semop 来做双端同步:写端写完把 sem_post 信号量加一,读端 sem_wait 阻塞等到信号量为正才读。共享内存只负责数据快速落地,同步秩序交给信号量,这个组合才是完整解法。如果不愿意引入 System V 信号量,也可以用 C11 原子操作做一个自旋锁放在共享内存头部,配合 __atomic_compare_exchange 系列指令实现无内核参与的数据保护,在高频场景下性能也过得去。
3.3 编译运行与关键参数验证
编译非常简单:
bash复制gcc writer.c -o writer
gcc reader.c -o reader
./writer
# 另开一个终端
./reader
建议顺序是先启动写端,再启动读端。写端创建完共享内存后 getchar() 等待,此时你可以开另一个终端执行 ipcs -m 查看:
text复制------ Shared Memory Segments --------
key shmid owner perms bytes nattch status
0x0101b6d7 0 root 666 4096 1
字段含义:key 十六进制显示,shmid 是共享内存 id,nattch 是当前附着进程数,也就是调用 shmat 成功但还没 shmdt 的进程数。此时读端还没启动,nattch 应该是 1(写端自己)。等读端跑起来后,再查一次,nattch 会变成 2。读端循环一次读完后退出并调用 shmdt,nattch 又会降回 1。最终写端手动回车后,会先 shmdt 再 shmctl(IPC_RMID),此时 ipcs -m 里这条记录彻底消失。
这整个流程跑下来,其实你就已经把共享内存的生命周期摸了一遍:创建(shmget)→ 挂载(shmat)→ 读写 → 拆离(shmdt)→ 销毁(shmctl IPC_RMID)。后面遇到更复杂的问题,无非是在这个骨架上加同步、加权限、加错误处理。
4. IPC 管理命令:ipcs / ipcrm 运维实录
4.1 ipcs 输出怎么读
如果你负责的机器跑着多个业务进程,共享内存的条数可能很快变得多起来。最常用的排查命令是:
bash复制ipcs -m
它会列出所有共享内存段。加上 -u 可以看使用概况:
bash复制ipcs -mu
输出示例:
text复制------ Shared Memory Limits --------
max number of segments = 4096
max seg size = 18014398509481984
max total shared memory = 18014398509481984
min seg size = 1
这里三个 max 值很多是和内核参数 kernel.shmmax、kernel.shmall、kernel.shmmni 相关的。我遇到过一台多实例服务的机器上启动新实例时报 shmget: No space left on device,吓一跳以为磁盘满了,查命令才发现是共享内存的数量超过了 kernel.shmmni 的限制。批量清理时用 ipcrm -m 一条条太慢,可以先用 ipcs -m | awk 'NR>3{print $2}' 过滤出所有 shmid,再循环删除。但删除前务必确认没有重要业务还在使用,不然等于拔了正在通信的两根网线。
ipcs -m -p 可以看到创建者和最近操作者的 PID,ipcs -m -t 能看到各个时间戳,这些都能帮你在多进程环境里定位是谁还在占用。排查“共享内存删不掉”时,我经常靠 nattch 和 PID 信息来反查哪个进程没有正常拆离。
4.2 ipcrm 删除与清理的时机
ipcrm 的用法:
bash复制ipcrm -m shmid
或者用 key 删除:
bash复制ipcrm -M 0x0101b6d7
但需要注意:用 key 删除时,-M 后面跟的是十六进制 key 值,别漏了 0x 前缀,否则命令会把字符串按数值解析,结果很可能完全对不上。
关于清理时机,我想强调一个常见误区:很多人以为调用 IPC_RMID 之后,再 shmat 应该会失败,但实际不一定。内核对于已经被标记删除的共享内存,如果另一个进程拿着旧的 shmid 再次 shmat,在某些内核配置下是可能成功的,因为物理内存还没释放,这就容易造成“明明删了,还能读”的错觉。因此,如果你的业务允许多进程先后挂载同一块内存,清理逻辑最好由一个专门的“管理员进程”执行,避免两个进程互相拆台。生产环境里,我发现最稳妥的处理方式是:先让所有业务进程都主动拆离并退出,再执行 ipcrm -m,避免在进程还挂载着时就标记删除。如果实在无法优雅退出,再考虑标记删除并等待最后的 shmdt 自动回收,但此时新接入的进程要能识别对象已被废弃,不能继续拿旧 key 干活。
5. 实战故障排查汇总与高频避坑经验
5.1 常见问题速查表
结合我自己的使用经历和团队踩过的坑,整理了一张速查表,方便你遇到问题直接对号入座。
| 现象 | 可能原因 | 检查与处理思路 |
|---|---|---|
ftok 返回 -1 |
路径文件不存在或无权限 | 确认文件存在且可访问,检查 errno |
shmget 返回 -1 且 errno=EEXIST |
同名 key 共享内存已存在,且用了 IPC_EXCL | 用 ipcs -m 查看,ipcrm -m 清理 |
shmget 返回 -1 且 errno=ENOMEM |
超出 kernel.shmmax 或内存不足 |
ipcs -lu 看限制,调整内核参数或减少申请 |
shmat 返回 (void *)-1 |
权限不足,或 shmid 已被标记删除但你没权限操作 | 用 ipcs -m 查看权限位,检查进程属主 |
| 读写数据出现脏值、旧值 | 没有做同步,或上一个程序崩溃留下脏数据 | 加入信号量或原子操作,设计校验和 |
ipcs -m 里 nattch 一直不降 |
有进程挂载后未 shmdt |
用 ipcs -m -p 找到 PID,查 /proc/<pid>/maps |
IPC_RMID 后 ipcs -m 看不到但进程仍能读 |
内核延迟物理释放 | 等待所有进程 shmdt 后释放,勿强行干预 |
这张表里我特别想强调第三行:共享内存大小并不是想申请多大就多大,内核默认参数 kernel.shmmax 在很多发行版上设置得很大,但有些云主机或容器镜像会把这个值调得很小,导致大块内存申请失败。排查时先 cat /proc/sys/kernel/shmmax 看一眼,别上来就怀疑代码。
5.2 我频繁踩坑后总结的六条经验
第一,共享内存用 System V 系列 API 时,key 是全局资源,竞争很激烈。 多个模块只要 key 冲突,轻则创建失败,重则 A 模块读到了 B 模块的数据。设计阶段尽量使用配置文件路径作为 ftok 来源,并且每个应用固定一个 proj_id。
第二,写共享内存前一定要考虑“断电/崩溃”恢复。 共享内存不像文件,没有事务日志。进程崩溃在写入中间位置时,你读到的就是半成品。我习惯在每个消息头里放一个自增序号、一个长度字段、一个 CRC 校验,读端在确认校验通过前不更新业务状态。
第三,不要把共享内存当作消息队列来用。 共享内存天然适合“共享数据区”模型,不太适合“消息队列”模型。如果你需要的是一发一收、异步解耦、带优先级、按序消费的消息通信,Linux 的 POSIX 消息队列或 socketpair 可能更合适,硬用共享内存只会让同步逻辑越写越复杂。
第四,coredump 文件里看共享内存映射是排查脏数据的捷径。 只要进程崩溃前共享内存还在映射状态,用 gdb core 后你可以直接查看对应的地址空间内容,和从 ipcs -m 拿到的 shmid 对照,很快能定位是哪个字段被写坏了。这个技巧知道的人不多,但排查效率真的很高。
第五,共享内存的权限位和文件权限语义相同,但实际效果更隐蔽。 我遇到过两个进程,一个 root 起的,一个普通用户起的,前者创建的共享内存权限位写成了 0600,后者 shmget 成功后 shmat 直接 EACCES。普通用户进程读不了 root 创建的内存,这在容器环境和微服务多租户场景里特别容易触发。解决办法是让 IPC 对象的属主和访问者落在同一个用户组下,或者统一通过一个中间管理进程处理权限。
第六,别忽略 32 位与 64 位差异。 如果你的进程是 32 位的,地址空间只有 4GB,shmat 时选择映射地址更要谨慎,挂载多块大共享内存很容易出现地址空间不足。编译时尽量统一为 64 位,确实要混跑的,建议把 shmaddr 交给内核自动选择,不要手动指定固定地址。
另外再说一个我实际项目里用的一个小技巧:可以用 shmctl(shmid, IPC_STAT, &buf) 里的 shm_perm.__key(某些系统上是 shm_perm.key)来反查共享内存对应的 key,配合 ipcs -m -i shmid 能直接在命令行看到单独某段的完整信息。排查问题时,这个命令比 ipcs -m 全量输出更聚焦,省得在一堆记录里翻找。
共享内存这东西,单独看 API 好像就是五六次函数调用,但真正把它放进生产环境,你会发现它牵扯内核资源管理、并发同步、故障恢复、权限模型一大堆内容。跑通一个 demo 只需要半小时,把边界情况和异常处理想周全则需要大量实践。希望这篇实战笔记能帮你少走一些我走过的弯路,尤其是 IPC_RMID 和 shmdt 的关系、nattch 排查技巧、以及 key 冲突的预防,这三处是新手最容易摔跟头的地方。
