3. POSIX共享内存:shm_open与mmap结合,创建与销毁共享内存段

共享内存,说白了就是让两个或多个进程直接读写同一块物理内存。没有拷贝,没有序列化,数据写进去就能读出来。我刚开始接触这个的时候,觉得它简直像魔法一样——两个完全独立的进程,怎么就能看到同一块数据呢?

嗯,其实原理没那么玄乎。操作系统提供了一套机制,让不同进程的虚拟地址映射到同一个物理页面。POSIX共享内存就是其中一种标准实现,核心就两个函数:shm_openmmap

3.1 核心概念:共享内存的“身份证”

POSIX共享内存用“名字”来标识一段共享内存段。这个名字以斜杠开头,比如 /my_shared_mem。你可以把它想象成共享内存的“身份证号”,进程之间通过这个名字来找到同一块内存。

整个流程分三步走:

  1. 创建/打开:用 shm_open 创建或打开一个共享内存对象
  2. 设置大小:用 ftruncate 设定共享内存的大小
  3. 映射:用 mmap 把共享内存映射到进程的地址空间

用完记得销毁,不然内存会泄漏。我在项目中就吃过这个亏,后面细说。

关键点shm_open 返回的是文件描述符,不是内存地址。真正的内存映射靠 mmap 完成。

3.2 创建共享内存段

先看创建端的代码。我习惯把创建逻辑封装成一个函数,这样调用方不用关心底层细节。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>

#define SHM_NAME "/my_shared_mem"
#define SHM_SIZE 4096

int create_shared_memory() {
    // 创建共享内存对象
    int shm_fd = shm_open(SHM_NAME, 
                          O_CREAT | O_RDWR, 
                          0666);
    if (shm_fd == -1) {
        perror("shm_open failed");
        return -1;
    }
    
    // 设置共享内存大小
    if (ftruncate(shm_fd, SHM_SIZE) == -1) {
        perror("ftruncate failed");
        close(shm_fd);
        shm_unlink(SHM_NAME);
        return -1;
    }
    
    // 映射到进程地址空间
    void *ptr = mmap(NULL, SHM_SIZE,
                     PROT_READ | PROT_WRITE,
                     MAP_SHARED,
                     shm_fd, 0);
    if (ptr == MAP_FAILED) {
        perror("mmap failed");
        close(shm_fd);
        shm_unlink(SHM_NAME);
        return -1;
    }
    
    // 文件描述符可以关闭了,映射还在
    close(shm_fd);
    
    printf("共享内存创建成功,地址:%p\n", ptr);
    return 0;
}

这里有几个细节要注意:

  • O_CREAT | O_RDWR:如果不存在就创建,以读写方式打开
  • 0666:权限位,和文件权限一样
  • MAP_SHARED:所有进程对内存的修改互相可见
  • ftruncate 必须在 mmap 之前调用

我的习惯:共享内存大小通常设成页面大小的整数倍。Linux 默认页面大小是 4096 字节,所以我经常用 4096、8192 这样的值。不是必须的,但能避免一些对齐问题。

3.3 连接已有的共享内存

另一个进程要访问同一块共享内存,不需要重新创建,直接打开就行。

int connect_shared_memory() {
    int shm_fd = shm_open(SHM_NAME, O_RDWR, 0666);
    if (shm_fd == -1) {
        perror("shm_open failed");
        return -1;
    }
    
    void *ptr = mmap(NULL, SHM_SIZE,
                     PROT_READ | PROT_WRITE,
                     MAP_SHARED,
                     shm_fd, 0);
    if (ptr == MAP_FAILED) {
        perror("mmap failed");
        close(shm_fd);
        return -1;
    }
    
    close(shm_fd);
    printf("连接到共享内存,地址:%p\n", ptr);
    return 0;
}

看到区别了吗?连接端没有 O_CREAT 标志,也没有 ftruncate 调用。因为共享内存已经存在了,大小也定好了。

3.4 销毁共享内存段

销毁分两步:先解除映射,再删除共享内存对象。

int destroy_shared_memory(void *ptr) {
    // 解除映射
    if (munmap(ptr, SHM_SIZE) == -1) {
        perror("munmap failed");
        return -1;
    }
    
    // 删除共享内存对象
    if (shm_unlink(SHM_NAME) == -1) {
        perror("shm_unlink failed");
        return -1;
    }
    
    printf("共享内存已销毁\n");
    return 0;
}

注意shm_unlink 只是标记删除,真正的内存释放要等到所有进程都 munmap 之后。我曾经在调试时先调了 shm_unlink,结果另一个进程还在用,直接崩溃了。嗯,血的教训。

3.5 完整示例:生产者-消费者

来个实际点的例子。生产者写数据,消费者读数据。

// producer.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>

typedef struct {
    int ready;
    char data[256];
} shared_data;

int main() {
    int shm_fd = shm_open("/shm_example", 
                          O_CREAT | O_RDWR, 0666);
    ftruncate(shm_fd, sizeof(shared_data));
    
    shared_data *shm = mmap(NULL, sizeof(shared_data),
                            PROT_READ | PROT_WRITE,
                            MAP_SHARED, shm_fd, 0);
    close(shm_fd);
    
    // 写入数据
    shm->ready = 0;
    strcpy(shm->data, "Hello from producer!");
    shm->ready = 1;
    
    printf("生产者:数据已写入\n");
    sleep(5);  // 等消费者读完
    
    munmap(shm, sizeof(shared_data));
    shm_unlink("/shm_example");
    return 0;
}
// consumer.c
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>

typedef struct {
    int ready;
    char data[256];
} shared_data;

int main() {
    int shm_fd = shm_open("/shm_example", O_RDWR, 0666);
    if (shm_fd == -1) {
        perror("共享内存不存在");
        return 1;
    }
    
    shared_data *shm = mmap(NULL, sizeof(shared_data),
                            PROT_READ | PROT_WRITE,
                            MAP_SHARED, shm_fd, 0);
    close(shm_fd);
    
    // 等待数据就绪
    while (!shm->ready) {
        usleep(100);
    }
    
    printf("消费者:收到数据 - %s\n", shm->data);
    
    munmap(shm, sizeof(shared_data));
    return 0;
}

3.6 核心流程图

下面这张图展示了整个生命周期,从创建到销毁的完整流程。

POSIX共享内存生命周期 进程A(创建端) 1. shm_open(O_CREAT) 2. ftruncate(设置大小) 3. mmap(MAP_SHARED) 4. 读写共享数据 5. munmap(解除映射) 6. shm_unlink(删除对象) 共享内存对象 /shm_example 物理内存页面 进程B(连接端) 1. shm_open(无O_CREAT) 2. mmap(MAP_SHARED) 3. 读写共享数据 4. munmap(解除映射) 创建/映射 打开/映射 shm_unlink 标记删除 注意:shm_unlink 只是标记删除,所有进程 munmap 后物理内存才真正释放

3.7 常见问题与避坑指南

问题 原因 解决方法
shm_open 返回 -1 名字格式不对,或权限不足 确保名字以斜杠开头,检查权限位
mmap 返回 MAP_FAILED 文件描述符无效,或大小设置不对 先调 ftruncate,再调 mmap
共享内存内容不对 忘记用 MAP_SHARED 标志 检查 mmap 的 flags 参数
程序退出后共享内存还在 忘记调 shm_unlink 在合适时机调用 shm_unlink

我曾经踩过的坑:有一次在生产环境上,共享内存段没销毁,导致系统 /dev/shm 目录下积累了上百个废弃的共享内存对象。排查了半天才发现是异常退出时没调 shm_unlink。后来我养成了一个习惯:在程序启动时先调 shm_unlink 清理旧的共享内存,再创建新的。这样即使上次异常退出,也不会留下垃圾。

3.8 性能考量

共享内存为什么快?说白了就是省掉了数据拷贝。传统 IPC 方式(管道、消息队列)需要在内核空间和用户空间之间来回拷贝数据,而共享内存直接读写物理内存,零拷贝。

但要注意:

  • 创建开销shm_open + mmap 第一次调用时开销较大,因为要分配物理页面
  • 映射开销mmap 涉及页表修改,但只做一次
  • 读写性能:一旦映射完成,读写就是普通内存操作,纳秒级延迟

我个人建议:如果进程间需要频繁交换大量数据(比如每秒几千次、每次几 KB 以上),共享内存是首选。如果只是偶尔发个消息,用管道或消息队列更省事。

小技巧:可以用 ls -l /dev/shm 查看系统上所有的 POSIX 共享内存对象。调试时特别有用,能确认共享内存有没有被正确清理。

嗯,关于 POSIX 共享内存的创建与销毁,核心内容就这些了。记住三个关键函数:shm_open 创建/打开、ftruncate 设大小、mmap 做映射。销毁时 munmapshm_unlink 配合使用。代码写多了你会发现,这套模式其实很固定,就像搭积木一样。

专注资料整理