共享内存与信号量

本页目录

共享内存让多个执行者访问同一份底层存储,减少大块数据经 IPC 缓冲往返的需求。但它不自动定义谁能写、何时可读、数据格式和崩溃后怎么恢复。很多共享内存协议真正困难的部分是同步与生命周期。

先选择共享对象的创建方式

方式如何让另一进程获得访问生命周期特点
MAP_SHARED 匿名映射在 fork 前创建并由子进程继承通常适合有共同祖先的进程
POSIX shm_open用约定名称打开对象名称与已打开引用、映射分开管理
普通文件 MAP_SHARED打开同一文件对象还要考虑文件大小与持久化语义
memfd_create继承 fd 或经 SCM_RIGHTS 传递不需公共路径名,可配置 seals
SysV shmget/shmat通过 IPC 标识符附加需要显式管理标记删除与附加关系

MAP_SHARED 的 mmap 失败返回 MAP_FAILED,不是 NULL。文件支持的映射通常需先把对象扩展到足够大小,越过实际文件范围访问可能产生 SIGBUS;对方并发截断也是需要处理的协议风险。

POSIX shm_open 的名称如 /myregion,不是要求调用者直接 open 某个 /dev/shm 路径。Linux 常在 tmpfs 实现相关对象,但接口语义不应绑定那个文件系统路径。O_CREAT|O_EXCL 可用于识别唯一创建者;初始化者与后续打开者要协调,不能双方都重复初始化同步对象。shm_open(3)

同一存储不等于相同虚拟地址

两个进程可能把同一对象映射到不同地址。共享数据结构中直接保存普通进程指针,通常不能由另一进程解引用;应使用相对对象起点的偏移、索引或其他协议句柄。

结构布局还涉及整数宽度、对齐、字节序、版本和原子操作支持。如果双方程序独立升级,不能只因为使用同一个头文件名称就认为二进制布局兼容。

MAP_SHARED 让写入作用于共享对象,不代替同步。缓存一致性也不等于语言层面的 happens-before。发布数据应使用正确的进程间锁、信号量或明确支持该用途的原子协议,不能用 volatile 加忙等凑成通用同步。

偏移也需要校验,不能只是把指针改成整数

设共享区协议允许访问的总长度为 total,记录给出 offset 和 length。检查应写成 offset ≤ total 且 length ≤ total - offset;在固定宽度语言里直接算 offset + length 再比较,可能先发生溢出。下面把所有字段限制为无符号 64 位,再验证范围。

U64_MAX = (1 << 64) - 1
def valid_span(total, offset, length):
    return (all(0 <= x <= U64_MAX for x in (total, offset, length))
            and offset <= total and length <= total - offset)
assert valid_span(4096, 128, 32)
assert valid_span(4096, 4096, 0)
assert not valid_span(4096, 4096, 1)
assert not valid_span(4096, U64_MAX - 3, 8)
assert not valid_span(4096, -1, 4)
base_a, base_b, offset = 0x10000000, 0x70000000, 128
assert base_a + offset != base_b + offset

offset=total、length=0 可以表示合法空区间,但不能据此解引用该位置。范围通过后还需验证布局版本、对齐与对象类型;如果读取头部期间另一个进程能改变长度或对象大小,校验结果也可能立即失效。校验和读取需要由协议保证同一版本,或通过不可变对象限制变化。

共享的是偏移,还是另一个进程的指针?

图中彩色区间只属于共享对象内部,两个进程的基址是各自的坐标原点。改变接收方基址不会改变对象内区间,却会改变接收方应使用的指针。offset=256、length=0 是边界处的空区间;不能把“范围检查通过”误读为这里有一个可访问字节。

正在呈现知识画面
共享的是偏移,还是另一个进程的指针?

同一对象偏移在两个进程中可以得到不同地址。必须先验证 offset≤total 且 length≤total−offset;空区间不允许解引用。

信号量:计数资源与发布事件

sem_wait 成功时取得一个计数令牌,计数为零则等待;sem_post 增加令牌并使等待者有机会继续。信号量没有普通互斥锁那样的固定所有者,生产者与消费者可以由不同线程或进程 post、wait。

sem_init(&sem, pshared, value) 中 pshared 非零表示跨进程共享,但 sem_t 必须已经位于各进程能访问的共享区域。它不会把栈上的 sem 变成共享内存,也不能在仍有使用者时重新初始化。sem_init(3)

共享映射提供数据位置,同步操作定义何时可以读取 生产者 写入 shared.value = 42 消费者 sem_wait 等待 ready sem_post:发布一个令牌 成功等待后读取 value sem_t 本身也位于共享映射;pshared=1 不会自动把普通栈变量搬到共享内存。

完整例子:父进程发布,子进程读取

保存为 shared_demo.c,以 cc -std=c11 -Wall -Wextra -pthread shared_demo.c -o shared_demo 编译。程序用子进程退出状态检查发布结果,不依赖 stdio 缓冲输出。

#define _GNU_SOURCE
#include <errno.h>
#include <semaphore.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <unistd.h>

struct Shared {
    sem_t ready;
    int value;
};

int main(void) {
    struct Shared *s = mmap(NULL, sizeof *s, PROT_READ | PROT_WRITE,
                           MAP_SHARED | MAP_ANONYMOUS, -1, 0);
    if (s == MAP_FAILED) return 1;
    if (sem_init(&s->ready, 1, 0) == -1) {
        munmap(s, sizeof *s);
        return 1;
    }
    pid_t child = fork();
    if (child == -1) {
        sem_destroy(&s->ready);
        munmap(s, sizeof *s);
        return 1;
    }
    if (child == 0) {
        while (sem_wait(&s->ready) == -1) {
            if (errno != EINTR) _exit(2);
        }
        int result = s->value == 42 ? 0 : 3;
        munmap(s, sizeof *s);
        _exit(result);
    }
    s->value = 42;
    if (sem_post(&s->ready) == -1) _exit(4);
    int status;
    while (waitpid(child, &status, 0) == -1) {
        if (errno != EINTR) _exit(5);
    }
    int failed = !WIFEXITED(status) || WEXITSTATUS(status) != 0;
    if (sem_destroy(&s->ready) == -1) failed = 1;
    if (munmap(s, sizeof *s) == -1) failed = 1;
    return failed;
}

初始化在 fork 前完成,初始计数为零;父进程先写数据再 post,子进程成功 wait 后读取。父进程等待子进程结束再销毁信号量,避免仍有使用者。sem_wait 的 EINTR 路径重试,其他错误不能当成已经取得令牌。sem_wait(3)

这是一次性交接示例,不是完整崩溃恢复协议。若生产者在 post 前死亡,消费者仍可能永久等待;循环缓冲区还需空槽、可读槽、序号和关闭状态。生产系统应定义超时、对端存活检测与废弃共享区的策略。

重复使用一个共享槽位,需要生产与消费两个方向的交接 生产者 wait(empty) 取得槽位写入权 写 payload + length 写完后 post(full) 消费者 wait(full) 取得读取这一版数据的权利 读完后 post(empty),才允许生产者覆盖下一版 初始化:empty=1,full=0;信号量都在共享区 这里只表达单生产者/单消费者正常路径;关闭、超时和对端死亡仍需额外协议。

一次性 ready 不能直接变成循环发布协议

若父进程连续写 value=1、post,再写 value=2、post,而子进程尚未来得及读,两个令牌并不保存两个历史值。子进程可能两次都读到 2。信号量计数记住的是可取得多少次令牌,不是复制 payload。

单槽位重复交接可以使用 empty 与 full 两个信号量,分别表示可写槽和可读槽。生产者 wait(empty) 后写数据、post(full);消费者 wait(full) 后读数据、post(empty)。读者若把共享区指针交给另一个异步任务,在任务读完前不能归还 empty,否则下一次覆盖就与旧任务读取竞争。

协议状态谁能访问 payload可以释放或覆盖吗
empty 令牌尚未取得没有已发布内容供消费生产者先取得令牌
生产者持有槽位生产者写消费者不能提前读
full 已发布消费者取得令牌后读生产者不能提前覆盖
消费者完成并归还 empty下一轮生产者可以开始新一轮

N 个槽位的队列还需要正确的索引管理。多个生产者同时拿到两个 empty 令牌,并不能自动分配到不同槽位;索引推进仍须互斥或原子协议。异常退出也可能丢失令牌,需要与后面的恢复策略一起设计。

两个令牌,会保存两个历史值吗?

错误方案中连续发布 v1、v2,再消费两次,会读到两次 v2:令牌数没有丢,历史 payload 却被覆盖了。正确方案在 full 尚未消费时禁用再次发布,消费完成归还 empty 后才允许覆盖。真实多线程或多进程实现还必须用同步操作建立这些访问的顺序。

正在呈现知识画面
两个令牌,会保存两个历史值吗?

信号量令牌不会复制 payload。单槽位循环交接需要可写权和可读权,防止生产者覆盖尚未消费的数据。

命名信号量与删除

sem_open 创建或打开命名信号量;sem_close 释放本进程引用,sem_unlink 删除名字。unlink 不应理解为立即销毁仍被其他进程打开的对象,与共享内存名称管理类似。

名称删除和数据可访问性是不同层次。安全清理常由创建者负责,但必须明确后来者如何发现对象、什么时候不再允许附加,以及旧版本对象是否仍有使用者。把资源删除责任写进协议比“程序退出时清理一下”可靠。

memfd 与 sealing:限制对象能发生的变化

memfd_create 返回匿名内存文件 fd,可 ftruncate、mmap,并通过 Unix socket 传给另一进程。MFD_CLOEXEC 控制 exec 继承;如需后续加 seals,创建时通常还应设置 MFD_ALLOW_SEALING。

F_SEAL_SHRINK 防缩小,F_SEAL_GROW 防扩大,它们不禁止改写现有字节⁠。F_SEAL_WRITE 与可写共享映射存在约束;F_SEAL_FUTURE_WRITE 的语义又不同,已有可写映射需要特别考虑。必须根据需要保证的属性选 seal,不能把所有 seal 都叫“只读”。memfd_create(2)

例如消费者要信任对象大小不在检查后变化,可要求相应长度 seal;要信任内容不可变,还需检查写入限制。memfd 是一种构件,不意味着 systemd 或其他程序的所有 IPC 都基于它。

用同一个 memfd 分别检查大小与内容限制

保存为 seal-demo.c。创建后写入 abc,先禁止缩小和扩大,此时改写已有第一个字节仍成功;随后增加 F_SEAL_WRITE,再次改写被拒绝。本例没有建立可写共享 mmap,所以不会触发“仍有可写映射导致加写 seal 失败”的条件。

#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <sys/mman.h>
#include <unistd.h>
int main(void) {
    int fd = memfd_create("seal-demo", MFD_CLOEXEC | MFD_ALLOW_SEALING);
    if (fd == -1) return 1;
    int failed = 1;
    if (write(fd, "abc", 3) != 3) goto done;
    int size_seals = F_SEAL_SHRINK | F_SEAL_GROW;
    if (fcntl(fd, F_ADD_SEALS, size_seals) == -1) goto done;
    if (pwrite(fd, "X", 1, 0) != 1) goto done;
    errno = 0;
    if (ftruncate(fd, 2) != -1 || errno != EPERM) goto done;
    if (fcntl(fd, F_ADD_SEALS, F_SEAL_WRITE) == -1) goto done;
    errno = 0;
    if (pwrite(fd, "Y", 1, 0) != -1 || errno != EPERM) goto done;
    char data[3];
    if (pread(fd, data, sizeof data, 0) != sizeof data) goto done;
    if (data[0] != 'X' || data[1] != 'b' || data[2] != 'c') goto done;
    int seals = fcntl(fd, F_GET_SEALS);
    if (seals == -1 || (seals & (size_seals | F_SEAL_WRITE)) !=
                      (size_seals | F_SEAL_WRITE)) goto done;
    puts("size fixed; overwrite first allowed, then denied; data=Xbc");
    failed = 0;
done:
    if (close(fd) == -1) failed = 1;
    return failed;
}

使用 cc -Wall -Wextra seal-demo.c -o seal-demo 编译运行。读取端拿到 fd 后,可以 F_GET_SEALS 检查对象当前具备哪些限制;这些限制作用于底层对象,不只是发送方的某个 fd 数字。文件 sealing

F_SEAL_SEAL 控制之后是否还能增加 seal,并不替代 F_SEAL_WRITE。F_SEAL_FUTURE_WRITE 允许已有可写共享映射继续修改,因此不能用它单独证明内容已经不可变。需要发布只读快照时,应完成写入、处理已有映射,再建立并检查所要求的内容与长度限制。

互斥与崩溃恢复不能只靠信号量计数

进程拿走信号量令牌后死亡,不会由 POSIX 信号量自动归还。若关键数据结构要求识别持锁者死亡,可考虑位于共享区、同时设置 PTHREAD_PROCESS_SHARED 与 robust 属性的互斥锁,并设计 EOWNERDEAD 数据修复路径。机制见 futex。

修复可能需要操作日志、版本状态或丢弃整个共享区。锁重新可获取只是恢复第一步,不意味着中途更新的长度、索引与数据已经一致。

SysV 共享内存仍有明确用途,不能仅因接口较早就一律排除。IPC_PRIVATE 表示创建新对象,不是基于名字的访问保护;权限、附加和删除行为应分别处理。

文件锁:先分清所有权模型

传统 fcntl record lock 可对文件字节范围加协作式锁。F_SETLK 不等待,F_SETLKW 可等待;返回错误需检查 errno,不能把所有失败都当成“另一个进程持有锁”。

传统记录锁与进程关联,关闭本进程对同一文件的其他 fd 也可能释放该进程在文件上的锁;OFD locks 则与打开文件描述关联,dup 和 fork 后的引用会影响最终释放。两者不应统称“close 当前 fd 就释放”的同一种机制。fcntl 锁

文件锁通常是 advisory,参与者要共同遵守。删除锁文件后重建可能得到新 inode,使新旧进程锁住不同对象;守护进程单实例设计需保证大家使用同一稳定对象。

性能与持久性

共享内存减少传输复制,但缓存行争用、伪共享、NUMA 和通知仍有成本,不保证对小消息一定比 pipe 快。适合先衡量数据规模、批处理、读写比例和背压需求。

映射写入何时被其他进程正确读取,与何时持久化到介质不同。文件支持的映射还要考虑 msync、文件系统和故障模型;信号量 post 不会自动使数据掉电可恢复。

关键词:shared memory, MAP_SHARED, shm_open, semaphore, sem_init, memfd, sealing, process shared, robust mutex, file locking, lifecycle