在 Linux 系统里,进程隔离是默认安全边界:每个进程拥有独立虚拟地址空间,一个进程不能直接读写另一个进程的普通内存。这个设计让程序更安全,但也带来了进程间通信的问题。常见 IPC 方式包括管道、Unix Domain Socket、TCP Socket、消息队列、mmap 共享映射以及 System V 共享内存。
如果两个本机进程需要频繁交换大量数据,例如采集进程把日志片段交给分析进程、媒体进程把帧元数据交给编码进程、交易网关把行情快照传给策略进程,Socket 和管道都能工作,但数据通常要经历内核缓冲区拷贝和系统调用调度。共享内存的核心优势是:多个进程把同一段物理内存映射到自己的虚拟地址空间后,可以像访问普通数组一样读写同一块数据区。
共享内存并不自动解决所有问题。它只解决"数据放在哪里",不解决"谁先写、谁后读、写到一半能不能被读、进程退出后资源如何清理"等问题。工程上真正容易出错的地方,往往不是 shmget 或 shmat 的调用,而是生命周期、同步协议和异常恢复。
本文以 System V 共享内存为主线,写一个最小但可执行的单生产者单消费者环形队列,帮助你理解它适合什么场景、如何落地、以及哪些坑不能忽略。
原理解释
System V 共享内存的使用流程可以分为四步。
第一步,通过 ftok 生成 key,或直接约定一个 key_t。key 是创建和查找共享内存段的标识,不是内存地址。
第二步,通过 shmget 创建或获取共享内存段。它返回一个 shmid,后续进程可以用这个 ID 绑定共享内存。
第三步,通过 shmat 把共享内存段挂载到当前进程地址空间。调用成功后会得到一个指针,进程可以通过这个指针读写共享区域。
第四步,通过 shmdt 分离共享内存,通过 shmctl(..., IPC_RMID, ...) 标记删除共享内存段。注意,IPC_RMID 并不等于立刻强制解除所有已挂载进程的映射;它表示当最后一个进程分离后,内核可以回收该段资源。
共享内存的问题在于多个进程可以同时读写同一段内存。如果没有同步,读进程可能读到半条消息,写进程可能覆盖未消费数据。同步可以使用 System V 信号量、POSIX 信号量、pthread 进程共享锁、eventfd、futex 等方式。为了让示例聚焦,本文用 C11 原子变量实现一个单生产者单消费者模型:只有一个 writer 和一个 reader,因此可以用 head/tail 下标配合 acquire/release 内存序完成基本同步。
这个限制很重要:下面的队列不是多生产者多消费者队列。如果你有多个写进程或多个读进程,需要引入互斥锁、信号量或更复杂的无锁算法。
可执行步骤
1. 准备目录
bash
mkdir -p sysv-shm-ring
cd sysv-shm-ring
2. 编写公共头文件
创建 ring.h:
c
#ifndef RING_H
#define RING_H
#include <stdatomic.h>
#include <stddef.h>
#include <stdint.h>
#define RING_CAPACITY 64
#define MESSAGE_SIZE 256
struct ring_slot {
char data[MESSAGE_SIZE];
};
struct shm_ring {
atomic_uint head;
atomic_uint tail;
struct ring_slot slots[RING_CAPACITY];
};
#define SHM_PROJ_ID 65
#define SHM_SIZE sizeof(struct shm_ring)
#endif
这里的 head 表示下一个要写入的位置,tail 表示下一个要读取的位置。为了区分"空"和"满",环形队列会浪费一个槽位:当 (head + 1) % RING_CAPACITY == tail 时认为队列已满。
3. 编写生产者
创建 producer.c:
c
#include "ring.h"
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>
static key_t build_key(void) {
key_t key = ftok(".", SHM_PROJ_ID);
if (key == -1) {
perror("ftok");
exit(1);
}
return key;
}
int main(void) {
int shmid = shmget(build_key(), SHM_SIZE, IPC_CREAT | 0600);
if (shmid == -1) {
perror("shmget");
return 1;
}
struct shm_ring *ring = shmat(shmid, NULL, 0);
if (ring == (void *)-1) {
perror("shmat");
return 1;
}
atomic_store_explicit(&ring->head, 0, memory_order_release);
atomic_store_explicit(&ring->tail, 0, memory_order_release);
for (unsigned i = 0; i < 20; i++) {
while (1) {
unsigned head = atomic_load_explicit(&ring->head, memory_order_relaxed);
unsigned tail = atomic_load_explicit(&ring->tail, memory_order_acquire);
unsigned next = (head + 1) % RING_CAPACITY;
if (next == tail) {
usleep(1000);
continue;
}
snprintf(ring->slots[head].data, MESSAGE_SIZE, "message-%u", i);
atomic_store_explicit(&ring->head, next, memory_order_release);
printf("produced: message-%u\n", i);
break;
}
}
shmdt(ring);
return 0;
}
生产者先创建共享内存,再初始化队列下标,然后循环写入消息。这里用 memory_order_release 发布 head,保证消费者看到新的 head 时,也能看到对应槽位里的消息内容。
4. 编写消费者
创建 consumer.c:
c
#include "ring.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>
static key_t build_key(void) {
key_t key = ftok(".", SHM_PROJ_ID);
if (key == -1) {
perror("ftok");
exit(1);
}
return key;
}
int main(void) {
int shmid = shmget(build_key(), SHM_SIZE, 0600);
if (shmid == -1) {
perror("shmget");
return 1;
}
struct shm_ring *ring = shmat(shmid, NULL, 0);
if (ring == (void *)-1) {
perror("shmat");
return 1;
}
for (unsigned count = 0; count < 20;) {
unsigned tail = atomic_load_explicit(&ring->tail, memory_order_relaxed);
unsigned head = atomic_load_explicit(&ring->head, memory_order_acquire);
if (tail == head) {
usleep(1000);
continue;
}
printf("consumed: %s\n", ring->slots[tail].data);
unsigned next = (tail + 1) % RING_CAPACITY;
atomic_store_explicit(&ring->tail, next, memory_order_release);
count++;
}
shmdt(ring);
return 0;
}
消费者通过同一个 key 找到共享内存段。读取 head 使用 acquire 语义,保证读取到生产者发布的 head 后,可以安全读取该 head 之前写入的消息。
5. 编写清理工具
共享内存是内核对象,进程退出后不一定自动删除。创建 cleanup.c:
c
#include "ring.h"
#include <stdio.h>
#include <stdlib.h>
#include <sys/ipc.h>
#include <sys/shm.h>
int main(void) {
key_t key = ftok(".", SHM_PROJ_ID);
if (key == -1) {
perror("ftok");
return 1;
}
int shmid = shmget(key, SHM_SIZE, 0600);
if (shmid == -1) {
perror("shmget");
return 1;
}
if (shmctl(shmid, IPC_RMID, NULL) == -1) {
perror("shmctl");
return 1;
}
puts("shared memory marked for removal");
return 0;
}
6. 编写 Makefile
makefile
CC ?= gcc
CFLAGS := -std=c11 -O2 -Wall -Wextra -pedantic
all: producer consumer cleanup
producer: producer.c ring.h
$(CC) $(CFLAGS) -o producer producer.c
consumer: consumer.c ring.h
$(CC) $(CFLAGS) -o consumer consumer.c
cleanup: cleanup.c ring.h
$(CC) $(CFLAGS) -o cleanup cleanup.c
clean:
rm -f producer consumer cleanup
7. 编译和运行
bash
make
./producer
./consumer
./cleanup
更接近真实通信的运行方式是打开两个终端。先启动生产者或消费者都可以,但如果先启动消费者,共享内存必须已经存在。因此可以先运行一次生产者,或把消费者里的 shmget 改成带 IPC_CREAT 的创建逻辑,并增加初始化状态字段,避免消费者读到未初始化队列。
也可以用系统命令观察共享内存段:
bash
ipcs -m
如果调试期间进程异常退出,清理工具无法定位资源,可以用 ipcrm 按 shmid 删除。但在线上环境不要随手删除不认识的共享内存段,先确认 owner、权限和业务归属。
工程化改进
上面的示例刻意保持简单,适合理解机制,但生产环境至少要补上四类能力。
第一,初始化协议。共享内存段可能由任意进程先创建,因此不能假设第一个 attach 的进程一定是生产者。常见做法是在共享内存头部放 magic、version、initialized 字段,并用文件锁或信号量保护初始化过程。
第二,阻塞通知。示例用 usleep 轮询,简单但不优雅。低延迟场景可能接受短暂自旋,普通服务更适合用 semaphore、eventfd 或条件变量减少空转。
第三,数据格式版本。共享内存里的结构体是二进制契约,字段顺序、对齐方式、大小端、编译器 ABI 都会影响兼容性。跨版本升级时,不要直接改结构体含义;应保留版本号、长度字段和向后兼容策略。
第四,异常恢复。生产者写入一半崩溃时,消费者如何判断槽位是否有效?一种方式是给每个槽位增加 sequence 或 committed 标记,写入完成后再发布状态;读取端只消费完整提交的数据。
常见问题
共享内存是不是一定比 Socket 快?
不一定。共享内存减少了数据拷贝,但同步、缓存一致性、错误恢复和协议复杂度都要自己承担。小消息、低频调用、跨机器通信或安全隔离要求高的场景,Socket 往往更简单、更稳妥。共享内存更适合本机进程之间传递高频、大块、结构稳定的数据。
为什么示例不用字符串拼接协议?
共享内存不是字节流,而是一块双方约定布局的内存。使用固定大小槽位可以让示例更清晰,避免长度字段、内存碎片和变长消息回收问题。真实系统如果要支持变长消息,可以采用"共享内存存数据 + 队列传 offset/length"的设计。
shmctl(..., IPC_RMID, ...) 会不会影响正在使用的进程?
已经 attach 的进程通常仍可继续访问,直到它们 detach 或退出;但新的 attach 行为会受影响。实际策略要结合系统行为和业务生命周期设计,不要把 IPC_RMID 当成普通内存 free 来理解。
可以把指针放进共享内存吗?
不要直接放普通进程内指针。不同进程 attach 到的虚拟地址可能不同,某个进程里的地址在另一个进程中没有意义。共享内存里应存 offset、索引或固定布局数据。如果确实要表达引用关系,用相对偏移更可靠。
多个生产者能复用这个代码吗?
不能直接复用。这个实现只针对单生产者单消费者。多个生产者同时写 head 会产生竞争,必须引入锁、CAS 循环、槽位序列号或其他并发控制机制。不要只把 atomic 加上就认为多进程并发已经安全。
总结
System V 共享内存的价值在于让本机多个进程直接访问同一段内存,适合高频、大块、低拷贝的 IPC 场景。但它只提供共享存储,不提供完整通信协议。一个可靠实现必须同时考虑初始化、同步、内存可见性、资源清理、版本兼容和异常恢复。
如果只是学习机制,可以从本文的环形队列开始;如果要用于生产,建议把共享内存视为一份严格的二进制接口契约,像设计网络协议一样设计字段、状态和升级路径。共享内存本身不复杂,复杂的是把它放进真实系统后仍然可维护、可排障、可恢复。