用 System V 共享内存实现本机高速 IPC:从原理到可运行环形队列

在 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 的进程一定是生产者。常见做法是在共享内存头部放 magicversioninitialized 字段,并用文件锁或信号量保护初始化过程。

第二,阻塞通知。示例用 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 场景。但它只提供共享存储,不提供完整通信协议。一个可靠实现必须同时考虑初始化、同步、内存可见性、资源清理、版本兼容和异常恢复。

如果只是学习机制,可以从本文的环形队列开始;如果要用于生产,建议把共享内存视为一份严格的二进制接口契约,像设计网络协议一样设计字段、状态和升级路径。共享内存本身不复杂,复杂的是把它放进真实系统后仍然可维护、可排障、可恢复。

相关推荐
菜小麒4 小时前
面试问题-01
面试·职场和发展
朱容zr3331335 小时前
为什么推荐使用自增主键?使用UUID作为主键的优缺点是什么?
java·运维·数据库·后端·mysql·面试·性能优化
艾莉丝努力练剑5 小时前
【Linux:动静态库】Linux 动静态库与可执行文件
linux·运维·服务器·学习·面试·文件系统·动静态库
李剑一5 小时前
再见前端,你好AI
面试·求职
HeiSenBerg5 小时前
Android IPC 深度解析:Binder 机制与 AIDL 实战
面试
城管不管6 小时前
重生——第五次面试2026.8.1一面
java·数据库·后端·ai·面试·职场和发展·agent
代码代码快快显灵6 小时前
MYSQL—DAY1
面试·职场和发展
kyriewen20 小时前
面试官让我手写虚拟列表——AI生成的版本,快速滚动几下就白屏了
前端·javascript·面试
Revolution6121 小时前
React 组件重新渲染时,到底重新执行了什么
前端·react.js·面试