☰
Linux共享内存实战:从ftok到shmctl的进程间通信完整指南
2026/10/7 10:45:09 网站建设 项目流程

做 Linux 进程间通信,绕不开一位老朋友:共享内存。不管是写多进程服务,还是在嵌入式板子上做数据交换,ftok、shmget、shmat、shmdt、shmctl 这五个函数大概率都会碰面。我第一次接触它们的时候,被各种概念绕得头晕——key 和 shmid 到底什么区别,创建完为什么还要 attach,删个段为什么还删不干净。等到把不止一个线上故障排完,才慢慢把这条链路彻底打通。这篇文章不打算做 API 手册的复读机,我想按自己从零到一写出第一个共享内存程序的顺序,把共享内存这套机制、每个函数背后的意图、以及最容易被坑到的细节讲清楚。适合刚接触 IPC 的朋友,也适合用了很久但没系统梳理过共享内存的老手。


1. 为什么最终选了共享内存:对比管道和消息队列的真实感受

很多初学者在学习进程间通信时,第一个接触的往往是管道,然后是消息队列,最后才是共享内存。等真正做项目选型时,不少人直接选了管道或者 socket,因为资料多、例子多、心里踏实。但如果你遇到的是高频、大批量的数据交换,用过一次共享内存之后,大概率就回不去了。

1.1 拷贝路径决定了性能上限

管道的本质是在内核里维护一块缓冲区,写端调用 write 把数据从用户空间拷进内核,读端调用 read 再把数据从内核拷回用户空间。一趟数据要经过两次拷贝、两次系统调用,中间还有可能有调度、锁、等待。数据量小的时候感受不明显,一旦单次传输几百 KB 甚至几 MB,CPU 时间基本都耗在 memcpy 和上下文切换上了。

消息队列也有类似的问题,而且比管道还多了一层消息类型管理。好消息是它天然支持按类型读取,坏消息是每条消息都有大小上限,并且同样要经历用户态到内核态再到用户态的两次搬运。

共享内存换了一个思路:直接在物理内存中划出一块区域,让多个进程通过页表映射到各自地址空间。参与通信的进程读写这块区域,就像读写普通内存一样,不经过内核中转,也不需要专门的系统调用。数据传输的成本从"拷贝+系统调用"降到了"一次内存读写"。这几乎是 Linux 下最快的 IPC 方式。

举个例子。我之前维护过一个网关服务,上游模块每秒钟要往下游模块送几十张图片的缩略数据,用的自定义 socket 协议。线上负载一高,CPU 直接冲到 80%,火焰图里全是 copy 相关函数。后来把数据通道换成共享内存,只保留了 socket 做心跳和控制消息,CPU 使用率下降一半以上。项目排期很紧的时候,这种改动带来的回报是非常明显的。

1.2 什么场景适合共享内存,什么场景别硬上

适合共享内存的场景:

  • 两个或多个进程需要频繁读写同一份数据,比如共享配置、共享缓存、共享状态表;
  • 实时性要求高,不希望每次传输都穿一次内核;
  • 单机多进程架构,而且进程之间本来就部署在同一台机器上。

不适合共享内存的场景:

  • 需要跨网络通信,共享内存只能作用于同一台主机;
  • 需要可靠的流式传输,比如 TCP 那种按序到达、丢包重传,共享内存本身不提供这些保障;
  • 本来低频、小数据量交换,管道或消息队列反而更简单、更安全。

还有一个容易忽略的点:共享内存没有内置的同步机制。管道和消息队列是内核来协调读写,自带阻塞语义。共享内存就是把一块内存摆在那里,谁都能读,谁都能写,没有互斥锁、没有条件变量,全靠应用程序自己保证时序。想用共享内存还不想加锁,几乎一定会踩到数据竞争。


2. ftok 与 shmget:key 的生成玄学与共享内存段的创建

System V 共享内存的整个流程从 key 开始。这里的 key 是一个 key_t 类型的整数,可以理解成共享内存段在系统范围内的"名字"。两个进程只有拿到相同的 key,才能定位到同一个共享内存段。

2.1 ftok 的原理与常见踩坑

ftok 函数的作用是根据一个路径名和一个整数标识号,生成一个统一的 key。

#include <sys/ipc.h> key_t ftok(const char *pathname, int proj_id);

它的底层实现,以 glibc 为例,大致是把 pathname 对应文件的 inode 号低 16 位、proj_id 低 8 位、以及设备号某些位拼装成一个 32 位整数。这个过程对使用者来说是透明的,你只需要知道两条铁律:

第一,pathname 必须指向一个真实存在的文件,而且发起 ftok 调用的进程必须对该文件有访问权限(至少能够 stat)。如果路径不存在,ftok 返回 -1,errno 设置为 ENOENT。

第二,参与通信的所有进程必须使用相同的 pathname 和相同的 proj_id,否则生成的 key 不一致,谁也找不到谁。

这里有几个非常经典的坑。

第一个坑:用临时文件当 pathname。有人图省事,用 /tmp/a.txt 这种路径,又因为某种原因把文件删了重建。文件 inode 变了,ftok 生成的 key 也跟着变。老进程读旧 key,新进程算新 key,两边对不上,shmget 要么找不到段,要么傻乎乎地重新创建了一个。我建议选一个长期稳定存在的路径,比如项目自己的配置文件目录,并且程序启动前检查文件是否存在。

第二个坑:proj_id 的取值范围没那么宽。ftok 实际参与计算的通常只有低 8 位,也就是说 proj_id 传 0 到 255 就足够了,传一个更大的数虽然不报错,但低位相同的高位会被截断或者忽略。很多习惯好的开发者直接传一个字符,比如 'A',代码可读性也高。

第三个坑:ftok 存在小概率碰撞。inode 和 proj_id 的组合空间就那么大,极端情况下两个不同文件可能算出同一个 key。正常项目不必过度担心,但如果你在做调度系统或金融交易系统,最好在创建共享内存后对 shmid 做一次校验,或者直接改用 IPC_PRIVATE 加显式传递 shmid 的方式。

2.2 shmget 的参数组合与内核限制

key 算出来之后,下一步就是创建或者获取共享内存段。

#include <sys/ipc.h> #include <sys/shm.h> int shmget(key_t key, size_t size, int shmflg);

三个参数里,key 是共享内存段的名字,size 是段的最小字节数,shmflg 是权限和控制标志。

这里必须把 key 和 shmid 的关系说清楚,这是初学者最容易绕晕的点。key 是用户空间用来寻址的名字,shmid 是内核返回给调用者的整数标识符。后续所有操作——shmat、shmctl、shmdt——用的都是 shmid,而不是 key。类似 open 函数里,文件名是外部名称,fd 是内部句柄。

shmflg 里最有用的组合是 IPC_CREAT 和 IPC_EXCL:

  • 只传 IPC_CREAT:如果 key 对应的段不存在就创建,如果已经存在则直接打开并返回现有 shmid,不会报错;
  • IPC_CREAT | IPC_EXCL:如果 key 对应的段已经存在,调用失败并返回 EEXIST,类似 open 的 O_CREAT | O_EXCL;
  • 单单传权限位(比如 0666):只能打开已经存在的段,如果不存在会返回 ENOENT。

这种组合语义非常重要。创建端一般用 IPC_CREAT | 0666,读取端一般只传 0666。很多新手在读取端也加了 IPC_CREAT,结果段不存在时读取端悄悄地创建了一个新段,双方各玩各的,排查到天亮都找不到原因。

size 参数指定段的字节数。内核在实际分配物理页的时候会按页向上取整,在 Linux 上一个页通常是 4096 字节,所以你传 1 字节也可能占掉一页物理内存。但 shmid_ds 结构里记录的逻辑大小仍然是你传入的 size,ipcs 查看时也会显示这个逻辑大小。

内核还有几道硬限制,相关文件都在 /proc/sys/kernel/ 下:

cat /proc/sys/kernel/shmmax # 单个共享内存段的最大字节数 cat /proc/sys/kernel/shmmni # 系统中共享内存段的最大数量 cat /proc/sys/kernel/shmall # 系统中共享内存页的最大总数

如果 shmget 返回 ENOSPC,先别怀疑代码逻辑,用上面这几条命令看看自己是不是撞到配额上限了。我见过一个服务连续几天异常崩溃,留下几千个共享内存段,最终把 shmmni 打满,整个系统再也建不了新段,所有依赖共享内存的业务全部告警。


3. shmat 与 shmdt:把共享内存段映射进进程地址空间

我当年学到这里的时候产生过一个疑问:shmget 都已经"创建"共享内存了,为什么还要再调用一次 shmat?这感觉就像买了一套房子,钥匙都拿到手了,结果还要再办一道手续才能进门住。

这个类比放在这里挺合适。shmget 只完成了"内核里分配和管理共享内存段"这一步,它返回的是一个内核对象的描述符 shmid。进程自己的地址空间和这个内核对象还没有建立映射关系,也就是说你还不能通过普通的指针去读写它。shmat 干的事情,就是"进门"——把指定 shmid 对应的共享内存段映射到调用进程的虚拟地址空间。

3.1 为什么创建之后还要再来一次 attach

共享内存是物理内存,但进程能访问的只能是自己的虚拟地址空间。内核需要为这块物理内存分配一段虚拟地址,并建立页表映射,这个映射过程就由 shmat 完成。

#include <sys/types.h> #include <sys/shm.h> void *shmat(int shmid, const void *shmaddr, int shmflg);

shmaddr 一般传 NULL,交给内核自己选择合适的虚拟地址。如果有特殊地址要求,比如某些嵌入式场景,可以传一个具体地址,但必须保证页对齐。shmflg 常见取值是 0(可读可写),或者 SHM_RDONLY(只读挂载)。还有一个 SHM_RND 标志,如果传了,内核会把传入的 shmaddr 向下取整到页边界。

shmat 成功时返回映射后的虚拟地址,失败时返回 (void *)-1。这个判断必须写,因为 -1 看起来像一个合法地址,直接拿来读写就是段错误。

这里有一个特别有意思的现象,也是共享内存非常重要的一个特性:同一个共享内存段,在每个进程里映射到的虚拟地址通常是不一样的。进程 A attach 到 0x7f1a00000000,进程 B attach 到 0x7f2b00000000,但它们指向的是同一块物理内存。这就意味着,在共享内存里存绝对指针是危险的,这一点后面写 demo 的时候会再展开。

3.2 读写共享内存的标准姿势

attach 完成后,shmat 返回的指针就是一个普通的内存地址,你可以直接 memcpy、strcpy、读写结构体。下面是一个最小写端程序:

// write.c #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/ipc.h> #include <sys/shm.h> #define SHM_SIZE 1024 int main(void) { key_t key = ftok("/tmp/shm_test", 'A'); if (key == -1) { perror("ftok"); exit(1); } int shmid = shmget(key, SHM_SIZE, IPC_CREAT | 0666); if (shmid == -1) { perror("shmget"); exit(1); } char *addr = shmat(shmid, NULL, 0); if (addr == (void *)-1) { perror("shmat"); exit(1); } memset(addr, 0, SHM_SIZE); strcpy(addr, "hello from writer"); printf("writer attach at %p, wrote: %s\n", addr, addr); /* 给读端留出时间 */ sleep(5); if (shmdt(addr) == -1) { perror("shmdt"); exit(1); } shmctl(shmid, IPC_RMID, NULL); return 0; }

对应的读端程序:

// read.c #include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/ipc.h> #include <sys/shm.h> #define SHM_SIZE 1024 int main(void) { key_t key = ftok("/tmp/shm_test", 'A'); if (key == -1) { perror("ftok"); exit(1); } int shmid = shmget(key, SHM_SIZE, 0666); if (shmid == -1) { perror("shmget"); exit(1); } char *addr = shmat(shmid, NULL, 0); if (addr == (void *)-1) { perror("shmat"); exit(1); } printf("reader attach at %p, got: %s\n", addr, addr); shmdt(addr); return 0; }

编译运行看一下效果:

gcc write.c -o write gcc read.c -o read ./write & ./read

输出大致是这样:

writer attach at 0x7f1a00001000, wrote: hello from writer reader attach at 0x7f2b00002000, got: hello from writer

两个进程 attach 的地址不一样,但读到的是同一份数据,这就是共享内存最直观的证明。

shmdt 负责把共享内存从进程地址空间分离。它只解除当前进程的映射关系,不会删除内核里的共享内存段,也不会影响其他进程的 attach。进程退出时,内核会自动清理该进程的所有映射,相当于隐式调用了 shmdt。但如果有多个进程同时 attach 同一个段,其中一个进程退出并不会把映射计数减为零,所以还是要靠使用者做好生命周期管理。


4. shmctl:删除一个共享内存段居然要等所有进程退出

shmctl 是 System V 共享内存的"控制台",大部分管理操作都靠它完成。

#include <sys/ipc.h> #include <sys/shm.h> int shmctl(int shmid, int cmd, struct shmid_ds *buf);

cmd 常用三个值:

  • IPC_STAT:获取共享内存段的状态信息,写入 buf;
  • IPC_SET:修改共享内存段的权限、属主等信息;
  • IPC_RMID:向内核请求删除指定的共享内存段。

4.1 shmid_ds 里藏着什么信息

IPC_STAT 返回的 struct shmid_ds 结构,长这样(不同内核版本字段略有差异,但核心部分一致):

struct shmid_ds { struct ipc_perm shm_perm; /* 权限、属主信息 */ size_t shm_segsz; /* 段大小,字节 */ time_t shm_atime; /* 最后 attach 时间 */ time_t shm_dtime; /* 最后 detach 时间 */ time_t shm_ctime; /* 最后修改时间 */ pid_t shm_cpid; /* 创建者 pid */ pid_t shm_lpid; /* 最后操作者的 pid */ shmatt_t shm_nattch; /* 当前 attach 的进程数量 */ };

运维排查时最关注两个字段:shm_segsz 告诉你段多大,shm_nattch 告诉你现在还有几个进程挂在上面。nattch 为 0 的段,通常意味着已经没有进程在使用,是清理的首选对象。

4.2 IPC_RMID 的延迟删除语义与线上堆积案例

IPC_RMID 的行为和大多数人直觉不同:它不是立即释放共享内存,而是给共享内存段打一个"删除标记"。

具体来说,段被标记删除后:

  • 已经被 attach 的进程仍然可以通过原有映射继续读写,段里的实际数据不会消失;
  • 新的进程无法再通过 shmat 挂载这个段,shmat 会失败;
  • 当所有已 attach 的进程都执行 shmdt 或者退出后,内核才真正释放这块物理内存。

所以你可能遇到一种现象:执行了 shmctl(shmid, IPC_RMID, NULL),马上用 ipcs -m 查看,段居然还在,nattch 显示大于 0。这不是删除失败,是内核在等最后一个进程离开。

反过来还有一种更常见的线上事故:某个进程创建了共享内存段,异常退出前没有调用 IPC_RMID,也没有进程去清理它。这个段就变成孤儿段,一直占着物理内存和内核段表项。一次两次无所谓,积少成多,系统里几百上千个 nattch 为 0 的孤儿段,最终 shmget 返回 ENOSPC,新的业务全部创建失败。

我处理过一起真实的线上故障:某个支付通道模块每个小时都会 shmget 创建一段共享内存存放临时统计结果,正常退出时会清理,但有一次线上出了 bug,进程在 attach 之后直接 abort,释放流程没走到。两个小时后,同一个机器上其他模块也陆续开始报共享内存创建失败。一看 /proc/sys/kernel/shmmni,默认 32000 的段数量配额被打满,ipcs -m 列出来一片一片的 1024 字节小段,全是同一个 owner。

排查链路很简单:先看错误码,再确认 /proc/sys/kernel 三个配额文件,然后 ipcs -m 按 shmid 从旧到新筛选。确认没有业务进程正在使用后,批量 ipcrm 重置现场。

那次之后,我在自己负责的组件里加了一个启动检查逻辑:根据约定的 key 前缀扫一遍 ipcs,如果发现 nattch == 0 且 shm_nattch 为 0 的残留段,直接删除。简单粗暴但不失为一种自我修复手段。


5. ipcs 与 ipcrm:排查共享内存问题的两条命根命令

学共享内存如果只懂 API 不懂命令行,就像开车只会踩油门不会看仪表盘。ipcs 和 ipcrm 是 Linux 下查看和清理 IPC 资源的两把利器。

5.1 逐列看懂 ipcs -m 的输出

直接执行:

ipcs -m

输出类似这样:

------ Shared Memory Segments -------- key shmid owner perms bytes nattch status 0x6101690b 32768 root 666 1024 0 0x610238a4 32769 app 666 4096 2

每一列的含义:

  • key:创建时用的 key,也就是 ftok 算出来的那个值;
  • shmid:内核返回的共享内存标识符,ipcrm 清理时要用它;
  • owner:段的所有者;
  • perms:权限位,666 就是普通读写权限;
  • bytes:段的逻辑大小,shmget 传入的 size;
  • nattch:当前挂载该段的进程数量;
  • status:有时候会显示 dest 标记,意思是这个段已经被 IPC_RMID 请求删除,但还有进程占着没释放。

ipcs -l 可以查看系统上限:

ipcs -l

可以快速核对消息队列、信号量、共享内存的当前使用配额。

5.2 一起真实的 ENOSPC 事故还原

继续讲刚才那个事故。我当时的排查命令和思路是这样的:

第一步,确认当前系统里有多少段:

ipcs -m | wc -l

第二步,排除掉运行中的正常段,找出 nattch 为 0 的残留对象:

ipcs -m | awk '$6 == 0 && $2 != "shmid" {print $2}'

第三步,确认这些 shmid 对应的进程确实不在了,然后逐个清理:

ipcrm -m 32768

也可以批量代劳,但生产环境我不建议直接用 xargs 批量删,因为没法二次确认。我更倾向于先把列表打到屏幕上,人工扫一眼 owner、时间、nattch 列,再决定删哪些。

这里有一个关键经验:删除之前一定确认 nattch 确实为 0。如果误删了一个正在被多个进程使用的共享内存段,虽然老进程还能继续读写,但新进程再也无法 attach,而且一旦老进程退出,整个段就会被释放,业务瞬间崩掉。我见过有人手滑把生产上的核心配置段删掉,panic 声响了一整层楼。

顺手再记一条:apache、nginx 这种大型服务也会使用共享内存或类似机制,清理 ipc 资源前最好先和业务侧确认。


6. 双进程读写 Demo 与长期开发中的 5 个避坑清单

把前几章的 API 串起来,就是一个完整可运行的共享内存通信 demo。上面已经贴过 write.c 和 read.c 的核心代码,下面聊聊除了 API 调用之外,真正在项目里长期维护共享内存代码时最值得记住的几条经验。

6.1 共享内存里的指针问题

第一条就是前面提到的绝对指针问题。两个进程 shmat 得到的虚拟地址往往不同,所以你不能在共享内存里存一个指针,让另一个进程直接解引用。比如你在共享内存结构体里写了char *next = addr + 8,读端进程拿到这个 next 指针沿着地址去访问,大概率读到的是自己进程地址空间里一个毫无意义的位置。

正确的做法是存偏移量,而不是绝对地址。假设共享内存开头是一个结构体,结构体里char *data改成uint64_t data_offset,读端拿自己的 attach 基地址加上 offset 再取数据,这样才安全。

6.2 并发写必须配合同步机制

共享内存不提供任何读写锁,多个进程同时写同一块区域,数据内容会出现交叉和覆盖。简单的字符串 strcpy 都会踩到,更不用说结构体了。

实际项目里最简单的做法是用 System V 信号量做互斥,也可以把共享内存和文件锁配合使用。锁的粒度要尽量小,临界区代码越短越好,否则多进程争锁导致的性能回退会抵消共享内存本身的效率优势。

6.3 attach 之后要记得校验返回值

shmat 返回 (void *)-1 表示失败。失败原因常见的有三种:shmid 不存在、权限不足、共享内存被标记删除。写代码时这三行判断不能省,而且要把 errno 打出来,方便线上排查。

6.4 生命周期管理要成体系

创建端负责销毁,这是最朴素的约定。谁创建、谁清理,写清楚注释。如果跨模块调用,最好封装独立的共用内存管理组件,不要在业务代码里散落一堆 shmget 和 shmctl。

另外强烈建议在程序启动时检查自己的 IPC 资源残留,比如使用固定的 key,启动后先 ipc 查询一下,如果存在 nattch == 0 的旧段就删掉重建。这个习惯能救你很多次。

6.5 用 strace 抓 IPC 调用链

如果代码里 IPC 调用很多,又不好定位问题,直接上 strace 是最快的:

strace -e trace=shmget,shmat,shmdt,shmctl ./write

能看到每一步的参数和返回值,对排查"为什么拿不到同一个段"这类问题非常有效。


我自己的体会是,共享内存这套东西,API 本身并不复杂,真正的复杂度全在"生命周期"和"数据一致性"两层。生命周期管理靠纪律,数据一致性靠锁和设计。把这两个问题想清楚,共享内存就是一把锋利的刀,否则它随时可能反过来伤到自己的项目。最后再分享一个小技巧:调试共享内存 demo 时,可以在 writer 里把 attach 地址打出来,在 reader 里也打出来,两边吐出来的地址一对比,你对"同一块物理内存映射到不同虚拟地址"这个概念的理解会比读十篇文章都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询