接手一个老项目,代码里一堆 shmget、shmat,注释还写着"System V 共享内存"。第一次接触这套接口的人多半会愣一下:这跟直接用指针访问内存有什么区别?多进程之间为什么非要绕这么一层?说实话,我早年也在这上面栽过跟头,把共享内存当成普通堆内存用,结果进程一多就出现各种匪夷所思的段错误和数据错乱。
System V 共享内存是 UNIX/Linux 上最经典的进程间通信(IPC)机制之一,核心思路很简单:让多个进程通过页表映射,共享同一块物理内存。相比管道、消息队列、Socket 那套"数据从用户态拷到内核态、再从内核态拷到另一个用户态"的路径,共享内存是真正意义上的零拷贝,数据写进去,另一个进程立刻就能看见。这篇内容主要面向 C/C++ 服务端开发、Linux 后端运维,以及正在补系统编程短板的同学,我会从接口原理讲到可运行代码,再讲到排错和选型,争取把这条路走通一遍。
1. 为什么进程间通信的数据通路里,System V 共享内存最快
1.1 从"拷贝"到"共享":IPC 效率差异的本质
先理清楚一个基础问题:进程间通信为什么慢?拿管道举例,A 进程要发一段数据给 B 进程,数据并不是直接飞过去的。A 先调用 write 把用户空间的数据拷贝到内核空间的管道缓冲区,B 再调用 read 把内核缓冲区的数据拷贝到自己的用户空间。两次拷贝,加两次上下文切换,数据量小的时候无所谓,一旦单次消息体到几十 KB、上百 KB,这套路径的耗时就会非常难看。
消息队列的原理类似,数据也是先进入内核维护的队列结构,接收方再从内核取走。每一跳都逃不掉"用户态→内核态→用户态"的搬运过程。共享内存的思路完全不同:它不再搬运数据,而是由内核创建一块物理内存,然后把这块物理内存分别映射到多个进程的虚拟地址空间。A 进程往自己的映射地址里写入数据,本质上就是在往这块物理内存写,B 进程读取自己的映射地址时,读到的就是同一块物理内存里的内容。整个过程没有任何内核态的中间拷贝。
我用一个生活化的类比说明:管道通信相当于你寄快递,必须先送到小区驿站,再让收件人去驿站取;共享内存相当于你和邻居直接凿墙开了个共用柜子,你把东西放进去,邻居转身就拿走了。省掉驿站这一步,就是共享内存快的根本原因。
1.2 数据量越大,差距越明显
共享内存的速度优势不是线性的,而是会随着数据量增大逐渐拉开。单次收发几个字节的控制消息,管道和共享内存的差异很难体感出来,毕竟拷贝几个字节的开销微乎其微。但如果你在做共享缓存、实时行情分发、图像帧传输、大数据量日志聚合这类场景,一条消息动辄几 MB,用管道走一次就是几 MB 的用户态内核态来回拷贝,而共享内存只是几次指针操作的时间量级。
这里有一个容易误判的点:共享内存省掉了拷贝,并不代表它完全没有开销。shmat 映射和 shmdt 解映射本身涉及页表操作,频繁地映射解映射同样有成本。所以工程上普遍的做法是长连接式使用——进程启动时 shmat 一次,进程退出前 shmdt 一次,中间的所有数据交换都在已映射的地址上进行。
1.3 共享内存的代价:没有人替你同步
共享内存的高效是有代价的,最大的代价就是它只管共享,不管同步。
管道和消息队列天然自带同步:A 写入的数据如果没有被读走,B 的 read 就会阻塞等待;消息队列则会严格按照消息边界存放和读取。但共享内存不一样,A 进程写入数据的同一时刻,B 进程可能正在读,如果 A 还没写完,B 已经把中间状态读走了,就会出现数据错乱。更典型的是多进程同时写同一个共享结构体,没有锁的保护,轻则数据覆盖,重则指针指向非法地址直接让程序崩溃。
所以在设计共享内存方案时,必须额外叠加一套同步机制。最传统的是配合 System V 信号量一起使用,也可以自己封装原子操作、自旋锁、互斥锁,甚至用文件锁做跨进程同步。我这里先用一句话总结:共享内存解决的是"数据怎么到达",同步机制解决的是"数据何时到达、谁先谁后",两者缺一不可。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 熟悉又陌生的四个系统调用:shmget、shmat、shmdt、shmctl
System V 共享内存的接口非常集中,核心就是四个函数:shmget 建段、shmat 挂载、shmdt 卸载、shmctl 控制。很多教程把步骤列得非常简单,但实际使用中,几乎每个函数都有几个值得深挖的细节。
2.1 shmget:创建或获取共享内存段
shmget 的原型是:
c复制#include <sys/ipc.h>
#include <sys/shm.h>
int shmget(key_t key, size_t size, int shmflg);
第一个参数 key 是共享内存段的外部名字,整个系统所有进程都靠这个 key 来找到同一个段。第二个参数 size 指定段的字节数。第三个参数 shmflg 是权限标志和创建控制标志的组合。
创建模式通常写作:
c复制int shmid = shmget(key, 4096, IPC_CREAT | 0666);
这里 IPC_CREAT 表示如果不存在就创建,存在就直接返回已有段的标识符,具体是新建还是复用,需要用 IPC_EXCL 来区分。IPC_CREAT | IPC_EXCL 组合起来类似 open 的 O_CREAT | O_EXCL,如果段已经存在,调用会返回 EEXIST 错误。这个组合在防止多进程重复初始化时非常有用,但要注意一个经典坑:如果所有创建者都用 IPC_CREAT | IPC_EXCL,那么第一个进程创建成功后,后续进程必须改用不带 IPC_EXCL 的 shmget 去获取已有段。
size 参数在创建时决定物理内存大小,内核会按页向上取整。也就是说你传 1 字节,内核实际分配的是 4096 字节(常见页大小)。获取已有段时,size 必须小于或等于段的实际大小,否则返回 EINVAL。
2.2 shmat:把共享内存挂到进程地址空间
shmget 返回的是 shmid,这只是个整数标识符,进程不能直接访问它。要访问共享内存,必须调用 shmat 把段挂载到进程的虚拟地址空间里:
c复制void *shmat(int shmid, const void *shmaddr, int shmflg);
第二个参数 shmaddr 指定挂载地址,绝大多数情况下传 NULL,让内核自动选择合适的地址。这么做的好处是避免了地址冲突,也是长期实践中最稳妥的写法。第三个参数 shmflg 常用 0(读写挂载)或 SHM_RDONLY(只读挂载)。
函数返回的是进程内的一个普通指针,之后你可以像操作普通内存一样,把它转换成结构体指针、数组指针、缓冲区指针来使用。需要注意,挂载之后这片内存不归 malloc 管,进程退出时也不会自动释放共享内存段本身,只解除映射。
如果一个进程多次对同一个段调用 shmat,内核会记录多次挂载,shmdt 时也需要对应调用同样次数才能完全解映射。这一点在循环逻辑里很容易忽略,一旦挂载次数和解映射次数不对等,就会出现地址空间泄漏。
2.3 shmdt:解除挂载
shmdt 的原型是:
c复制int shmdt(const void *shmaddr);
参数是 shmat 返回的地址。这个函数只做一件事:把共享内存段从当前进程的地址空间里摘掉。它不会删除共享内存段,也不会影响其他进程的挂载。
进程正常退出或者崩溃退出时,内核会自动清理该进程所有的挂载关系,所以严格来说 shmdt 在最简单的 Demo 里可以不调用。但在长期运行的服务里,如果一段逻辑需要临时挂载共享内存、用完后马上处理其他事务,不调用 shmdt 会导致进程映射的虚拟内存区域不断增加,虽然没有 malloc,但地址空间碎片化的问题依然会出现。
2.4 shmctl:查询、设置、删除
shmctl 是控制接口,常用的操作有三种:
c复制int shmctl(int shmid, int cmd, struct shmid_ds *buf);
IPC_STAT:获取段的状态信息,存在struct shmid_ds结构中。IPC_SET:修改段的权限位、属主等信息。IPC_RMID:标记删除共享内存段。
IPC_RMID 的行为是最容易误解的。它并不是立即把物理内存销毁,而是把段标记为"待删除"。此时新进程不能再通过 shmat 挂载这个段,但如果某些进程已经挂载了,它们依然可以正常读写,直到所有进程都 shmdt 之后,物理内存才会被真正回收。这个机制在程序异常崩溃时尤其关键:进程没了,段已经标记删除,但因为没有进程再挂载,内存会自动释放;如果还有进程挂着,段会继续存活到最后一个进程解除挂载。
3. 手写一个生产者-消费者:把原理落成能跑的代码
讲完接口,直接上代码。下面这个例子用 System V 共享内存加上一对 System V 信号量,实现一个单格缓冲区的生产者-消费者模型。生产者在共享内存里写一个结构体,消费者读取并打印,整个过程不经过内核拷贝。
3.1 场景设计
共享内存里放一个结构体:
c复制// shm_common.h
#ifndef SHM_COMMON_H
#define SHM_COMMON_H
#define SHM_KEY 0x1234
#define SEM_KEY 0x5678
typedef struct {
int seq;
char payload[64];
} shared_item;
#endif
同步设计上,我用了两个 System V 信号量:一个表示"缓冲区空位"(初始为 1),一个表示"缓冲区有数据"(初始为 0)。生产者先 P(空位),写入数据,然后 V(有数据);消费者先 P(有数据),读取数据,然后 V(空位)。这样就能保证生产者不会覆盖未消费的数据,消费者也不会读到未写入完整的旧数据。
3.2 完整代码
生产者 producer.c:
c复制#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/sem.h>
#include <unistd.h>
#include "shm_common.h"
union semun {
int val;
struct semid_ds *buf;
unsigned short *array;
};
static int sem_p(int semid) {
struct sembuf op = {0, -1, 0};
return semop(semid, &op, 1);
}
static int sem_v(int semid) {
struct sembuf op = {0, 1, 0};
return semop(semid, &op, 1);
}
int main(void) {
int shmid = shmget(SHM_KEY, sizeof(shared_item), IPC_CREAT | IPC_EXCL | 0666);
if (shmid < 0) {
perror("shmget");
exit(1);
}
int semid = semget(SEM_KEY, 2, IPC_CREAT | IPC_EXCL | 0666);
if (semid < 0) {
perror("semget");
exit(1);
}
union semun su;
su.val = 1;
semctl(semid, 0, SETVAL, su); // 空位信号量,初始1
su.val = 0;
semctl(semid, 1, SETVAL, su); // 数据信号量,初始0
shared_item *item = (shared_item *)shmat(shmid, NULL, 0);
if (item == (void *)-1) {
perror("shmat");
exit(1);
}
for (int i = 1; i <= 10; i++) {
sem_p(semid); // P(空位)
item->seq = i;
snprintf(item->payload, sizeof(item->payload), "hello-%d", i);
printf("[producer] write seq=%d payload=%s\n", item->seq, item->payload);
sem_v(semid + 1); // V(有数据)
sleep(1);
}
shmdt(item);
shmctl(shmid, IPC_RMID, NULL);
semctl(semid, 0, IPC_RMID, NULL);
return 0;
}
消费者 consumer.c:
c复制#include <stdio.h>
#include <stdlib.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/sem.h>
#include <unistd.h>
#include "shm_common.h"
union semun {
int val;
struct semid_ds *buf;
unsigned short *array;
};
static int sem_p(int semid) {
struct sembuf op = {0, -1, 0};
return semop(semid, &op, 1);
}
static int sem_v(int semid) {
struct sembuf op = {0, 1, 0};
return semop(semid, &op, 1);
}
int main(void) {
int shmid = shmget(SHM_KEY, sizeof(shared_item), 0666);
if (shmid < 0) {
perror("shmget");
exit(1);
}
int semid = semget(SEM_KEY, 2, 0666);
if (semid < 0) {
perror("semget");
exit(1);
}
shared_item *item = (shared_item *)shmat(shmid, NULL, 0);
if (item == (void *)-1) {
perror("shmat");
exit(1);
}
for (int i = 1; i <= 10; i++) {
sem_p(semid + 1); // P(有数据)
printf("[consumer] read seq=%d payload=%s\n", item->seq, item->payload);
sem_v(semid); // V(空位)
}
shmdt(item);
return 0;
}
编译和运行:
bash复制gcc -o producer producer.c
gcc -o consumer consumer.c
./producer &
./consumer &
运行输出示例:
code复制[producer] write seq=1 payload=hello-1
[consumer] read seq=1 payload=hello-1
[producer] write seq=2 payload=hello-2
[consumer] read seq=2 payload=hello-2
...
3.3 代码里的几处关键细节
这个示例虽然简短,但有几个细节经常在真实项目里被忽略。
第一个细节,信号量操作的单位是"信号量集合",不是单个信号量。semget(SEM_KEY, 2, ...) 创建的是包含两个信号量的集合,semop 的 struct sembuf 里 sem_num 指定是集合中的哪个信号量。我在代码里是通过 semid 和 semid + 1 分别操作两个信号量的,这个技巧依赖内核把相邻创建的两个信号量放在同一个集合里,所以这里的 semid + 1 能成立。更严谨的写法是用 semctl 的 GETVAL 或者直接建立两个独立的信号量集合。如果不想在这个细节上花时间,建议创建两个单一信号量集合,代码会稍微长一点,但逻辑更清晰。
第二个细节,生产者在结束时调用了 shmctl(shmid, IPC_RMID, NULL) 和 semctl(semid, 0, IPC_RMID, NULL),这是为了让系统资源及时回收。如果生产者异常退出,这两个资源会变成残留,需要手动用 ipcrm 清理。这个问题我后面专门讲。
第三个细节,shmat 的返回值判断。手册上明确写着失败返回 (void *)-1,不是 NULL。很多初学者只判断是否等于 NULL,结果共享内存挂载失败后程序继续往下走,马上就在解引用时崩溃。这是一个非常隐蔽的坑,尤其在嵌入式交叉编译环境里,NULL 和 (void *)-1 的地址值在部分平台上看起来差不多,更容易误判。
4. 从"能跑"到"管得好":ipcs、ipcrm 与内核参数的那些事
很多教程写完代码就到头了,但实际生产环境里,代码能跑只是第一步。系统里的共享内存段是全局资源,内核不会因为进程退出就立刻回收所有东西,运维排查时最常用的工具就是 ipcs 和 ipcrm。
4.1 用 ipcs 查看系统里所有共享内存段
执行 ipcs -m,输出类似这样:
code复制------ Shared Memory Segments --------
key shmid owner perms bytes nattch status
0x00001234 98304 user 666 4096 2
关键字段的含义:
key:创建时用的外部键值。shmid:段标识符,删除和查询时要用。perms:权限位,等价于文件权限。bytes:段大小。nattch:当前挂载到该段的进程数。status:如果显示dest,说明该段已经被IPC_RMID标记删除,但仍有进程在挂载使用。
nattch 这个字段在排查内存是否泄漏时特别有用。如果一个共享内存段的 nattch 长期不为 0,但看进程列表又找不到对应的进程,那大概率是有进程处于僵尸状态或者内核没有及时回收挂载关系。虽然正常情况下进程退出会自动清理挂载,但在某些特殊驱动或异常阻塞场景下,也可能出现清理不及时的情况。
ipcs -u 可以查看共享内存的整体限制和当前用量:
code复制------ Shared Memory Status --------
segments allocated 3
pages allocated 1024
pages resident 512
pages swapped 0
Swap performance: 0 attempts 0 successes
4.2 ipcrm 清理残留
程序崩溃或临时调试时产生残留共享内存段,最直接的清理方式是用 ipcrm:
bash复制ipcrm -m shmid # 按 shmid 删除
ipcrm -M key # 按 key 删除
注意 ipcrm -m 删除的是标记,如果 nattch 不为 0,段不会真正释放,新进程也不能挂载。这时要么等待挂载进程退出,要么确认那些进程确实不会再使用后,用 kill 结束它们,再重新清理。
我在维护一个常驻服务时遇到过一种情况:每次发布新版本,旧进程带着旧的共享内存段还没完全退出,新进程用同样的 key 尝试 shmget,发现段存在就直接挂载了。结果新旧进程操作的是同一块数据,字段定义却已经变了,整个运行逻辑全部错乱。后来我们在发布脚本里强制加了一步:先 ipcrm 旧的共享内存段,再启动新进程,这个问题才根除。
4.3 内核参数与容量规划
Linux 内核通过几个参数控制 System V 共享内存的使用上限:
| 参数 | 默认值(常见 64 位系统) | 含义 |
|---|---|---|
kernel.shmmax |
18446744073709551615 | 单个共享内存段的最大字节数 |
kernel.shmall |
18446744073709551615 | 系统内共享内存总页数上限 |
kernel.shmmni |
4096 | 系统内共享内存段总数上限 |
查看和修改:
bash复制cat /proc/sys/kernel/shmmax
sysctl -w kernel.shmmax=1073741824
如果要持久化,写入 /etc/sysctl.conf:
code复制kernel.shmmax = 1073741824
kernel.shmall = 262144
shmmax 这个参数在数据库类应用(比如老版本 PostgreSQL 的共享内存配置)里非常关键。如果你计划创建一个超过 shmmax 的共享内存段,shmget 会返回 EINVAL。在容器环境里尤其要注意,部分容器默认的 shmmax 很小,直接使用 Docker 默认配置跑大型共享内存程序,经常莫名其妙报错,查半天才发现是容器内核参数限制。
4.4 系统重启后的共享内存
System V 共享内存是内核资源,不写入磁盘,系统重启后全部清空。这意味着你不能依赖共享内存做持久化存储,只能用它做进程运行期间的临时数据交换。如果业务上有"重启后数据不丢"的需求,那得另配数据库或文件存储,把共享内存里的数据定期落盘。
同时,重启后如果某些程序启动时用 IPC_CREAT | IPC_EXCL 创建段,第一次启动会成功,第二次启动会因为段已存在而失败。这是正常的,服务编排脚本里应该处理"段已存在"的分支,而不是简单地把错误抛出。
5. 实战中绕不开的典型坑:从段错误到挂死
共享内存的代码逻辑本身不复杂,但它和普通内存使用方式差异明显,导致一连串典型问题。这一节我把这几年调试中碰到过的高频问题整理出来,按排查路径来讲。
5.1 ftok 的路径和 proj_id 不要换来换去
虽然上面的示例直接用了整数 key,但很多项目用 ftok 从文件路径生成 key:
c复制key_t key = ftok("/tmp/shm.flag", 'a');
ftok 的原理是取指定文件的 inode 号和 proj_id 组合生成 key。这带来一个隐患:如果 /tmp/shm.flag 这个文件被删除后重新创建,inode 变了,ftok 生成的 key 也就变了,所有用旧 key 的客户端找不到原来的共享内存段,服务端创建新段,两边就"失联"了。
排查手段:先 ls -i 查看文件 inode,再在两边的进程里分别打印 ftok 的结果,通常能立刻发现问题。这个坑在文件被临时目录清理或者部署脚本误删除时特别常见。
5.2 shmat 返回的指针,越界操作就是段错误
shmat 返回的地址不像 malloc 返回的堆地址,它位于映射区域,旁边可能没有可访问的相邻内存。一旦你写入的偏移超过共享内存段实际大小,大概率会直接触发段错误,连一点缓冲余地都不给你。
举一个真实案例:我们曾有一个共享内存段,创建时大小写成了 sizeof(Header) + MAX_ITEMS * sizeof(Item),后来结构体里加了一个字段,sizeof(Item) 变大,但创建段的地方忘了同步修改。程序一运行,写第 N 个 Item 时直接越界,shmat 映射之外的地址不可写,进程立刻段错误。排查过程非常痛苦,因为崩溃点在写入数据处,但根因在创建段时的大小计算,两个地方隔了好几层函数调用。
建议:把所有共享内存段的大小定义收敛到一个公共头文件的宏或者常量里,禁止在业务代码里散落硬编码。分配前打印一下 sizeof 结果和 shmget 实际分配的大小(通过 IPC_STAT 查看),能在早期发现大多数问题。
5.3 IPC_RMID 之后程序还能不能继续用共享内存
前面简单提过,这里再展开一下。shmctl(shmid, IPC_RMID, NULL) 标记删除之后:
- 已经
shmat成功的进程,仍然可以继续读写这块内存。 - 新的
shmat调用会失败,返回EINVAL。 - 只有当所有已挂载进程都
shmdt后,物理内存才真正释放。
这个特性带来的实际问题是:你说"删除了共享内存",但它可能并没有立刻消失。在发布流程里,如果旧进程还占着段,新进程又启动不起来,就会形成"旧进程删不掉、新进程起不来"的僵局。这时要走完整排查链:先 ipcs -m 看看段是否处于 dest 状态,再用 lsof 或者 ls -l /proc/[pid]/maps | grep shmid 找出哪个进程还挂着,确认安全后结束旧进程。
5.4 反复 attach 不 detach 的映射区堆积
一个进程可以多次 shmat 同一个共享内存段,每成功一次,进程地址空间里就多一个映射区域。如果你在循环里反复做 attach/detach,但某个分支忘了 detach,映射区域就会越积越多,最终 shmat 返回 ENOMEM。
这种问题不像段错误那么明显,进程不崩,但系统内存一直在涨。排查思路是观察进程的映射数量:
bash复制cat /proc/<pid>/maps | grep -c "/dev/shm\|shmid"
正常进程的共享内存映射数量应该是稳定的小数值。如果持续增加,基本可以确定是 attach 和 detach 不成对。
5.5 共享内存与 fork 的关系
fork() 之后,子进程会继承父进程的共享内存挂载关系。也就是说,子进程可以直接访问父进程 shmat 出来的内存,不需要重新调用 shmat。这个特性有时候是便利,有时候是灾难。
我遇到过的问题形态是:主进程 shmat 后 fork 出多个 worker,每个 worker 都认为自己可以写共享内存,结果多个 worker 同时更新同一个计数器,竞态严重。后来统一改为"只有主进程负责写共享内存,worker 通过队列传递数据",问题才解决。
如果子进程只需要短暂访问共享内存,用完最好主动 shmdt,避免父子进程叠加挂载,导致 nattch 迟迟降不下来。
5.6 同步问题才是最大的坑
代码逻辑如果少了同步,表现出来就是"数据偶尔对、偶尔错",这类问题比段错误更难查。因为段错误有明确的崩溃点,数据错乱则可能在生产环境运行几小时才出现一次,而且样本完全随机。
我自己的排查套路是:
- 先看共享内存结构体里有没有自带的序列号或校验字段,用它判断数据是不是写半截。
- 加日志确认两个进程的写入和读取时序,看看有没有交叠窗口。
- 再排查同步原语本身是不是真的生效了。常见错误包括:信号量初始值不对、
semop操作了错误的sem_num、忘记在 fork 前初始化信号量。
这里我强烈建议,共享内存结构体里一定要保留一个 magic 字段和 seq 字段。magic 用于校验内存是否被意外破坏,seq 用于判断数据是否更新。这两个字段在调试同步问题时能大幅缩短定位时间。
6. 选型对比:System V 共享内存、POSIX 共享内存、消息队列,到底用哪个
很多人在系统设计阶段就会纠结:都是进程间通信,到底选哪种?我根据自己的实际项目经验,把几个常用方案的差异梳理一下。
6.1 三种机制能力对比表
| 维度 | System V 共享内存 | POSIX 共享内存(shm_open) | 消息队列 |
|---|---|---|---|
| 数据拷贝次数 | 0 次(映射后) | 0 次(映射后) | 2 次(写+读) |
| 接口复杂度 | 中,四件套 + 信号量 | 相对简单,类似文件操作 | 低,封装程度高 |
| 是否需要额外同步 | 是 | 是 | 否,自带边界 |
| 最大消息限制 | 受 shmmax 控制 |
受 /dev/shm 挂载大小限制 |
受消息队列上限限制 |
| 跨平台性 | UNIX 绝大多数支持 | Linux/macOS 支持,Windows 需第三方库 | UNIX 常见,但参数差异大 |
| 调试工具 | ipcs / ipcrm |
ls /dev/shm,可直接当文件查看 |
ipcs -q |
| 生命周期 | 随内核,可用 ipcrm 删 |
可持久化为文件,系统重启后 /dev/shm 内容丢失 |
随内核,可用 ipcrm 删 |
6.2 我的选型建议
如果项目是纯 Linux 环境,而且进程之间需要共享一个较大的数据结构(比如共享缓存、统计表、状态快照),首选 POSIX 共享内存。原因很简单:它的接口更像文件操作,shm_open 之后直接 mmap,语义直观,而且 ls /dev/shm 能看到具体文件,调试时比 ipcs 更友好。
如果项目需要兼容老系统、遗留代码本身就是 System V 接口,或者跑在一些精简的嵌入式 Linux 上,那就老老实实用 System V。虽然接口老,但胜在稳定,内核实现非常成熟,几乎不存在平台不支持的问题。实际上很多数据库和消息中间件的共享内存实现都是 System V 风格,跟着老代码走不会错。
如果只是进程间传小消息,消息量不大但要求可靠、有边界,消息队列是最省心的。它不需要额外做同步,系统自带消息边界,取消息的时候还能按类型过滤,代码量最少。消息队列的缺点是性能上限低,单条消息通常有大小限制(常见 8KB),数据量一大就不合适。
至于需要跨主机通信的场景,共享内存和消息队列都不行,直接上 Socket 或者共享文件加网络文件系统。这不是同一维度的问题,别混在一起选。
我自己的经验法则是:超过 64KB 的共享数据结构,优先共享内存;小于 8KB 的短消息,消息队列;需要从多个进程高频读写的热数据,共享内存 + 自旋锁;想快速上线不改代码结构,先拿消息队列顶住,后续再优化。
最后再分享一个我在实际调试中的体会:不管选哪种机制,一定要把"查询当前状态"的能力内建到程序里。我习惯在每个使用共享内存的服务里留一个隐藏命令行参数,比如 --shm-status,启动后打印所有共享内存段的 key、shmid、nattch、权限位,然后退出。配合 ipcs -m 一起看,绝大多数共享内存问题都能在十分钟内定位到根因。这个习惯帮我省了无数次在线上环境抓耳挠腮的时间。
