Linux 共享内存与信号量全解析:原理、实践与避坑指南

在 Linux 进程间通信(IPC)机制中,共享内存与信号量是一对 "黄金搭档"------ 共享内存提供高效数据传输通道,信号量保障共享资源安全访问。本文从底层原理、核心用法、实战示例到常见问题,凝练解析二者核心知识点,助力快速吃透并灵活运用。

一、共享内存:最快的 IPC 通信方式

1.1 核心定义与设计初衷

共享内存是 Linux 最高效的 IPC 机制,核心是内核开辟一块物理内存让多个进程通过内存映射将其挂载到自身虚拟地址空间,直接读写物理内存,规避用户态与内核态数据拷贝开销,适用于大数据量、高频次交互场景(如实时视频处理、高并发日志采集)。


1.2 底层实现原理

共享内存主要分为 System V 和 POSIX 两种,均基于内核 tmpfs 临时文件系统(仅存于内存,可交换,不占磁盘空间),核心区别在于管理方式:

  • System V 共享内存:由内核 shm_mnt 管理,可通过 /proc/sysvipc/shm 查看,依赖 shmid_ds 结构体记录属性,采用引用计数管理进程关联,计数为 0 时内核释放内存。
  • POSIX 共享内存:挂载于 /dev/shm,按文件操作管理,接口简洁、兼容性好,底层与 System V 共享 shmem 层逻辑。

1.3 核心 API 详解(System V 与 POSIX)

共享内存的使用流程统一为:创建/获取 → 映射到进程地址空间 → 读写操作 → 解除映射 → 销毁,以下是两种实现方式的核心 API 及用法。

1.3.1 System V 共享内存 API

依赖头文件

cpp 复制代码
#include <sys/shm.h>

ftok 生成 IPC 键值

cpp 复制代码
key_t ftok(const char *pathname, int proj_id);

//计算规则
key_t =  文件inode号(低16位) +  文件设备号(低8位) +  proj_id(低8位)

// 推荐写法:用字符(最直观、不会出错)
key_t key1 = ftok("/tmp/my_ipc_file", 'A'); 
key_t key2 = ftok("/tmp/my_ipc_file", 'B'); 
  • 功能:生成唯一的 IPC 键值(key),用于唯一标识共享内存段
  • 参数说明:
    • pathname:必须是系统中已存在的文件路径
    • proj_id:自定义项目 ID,取值范围 0~255
  • 特性:相同的 pathname 和 proj_id 一定会生成相同的 key,从而访问同一块共享内存,不同的进程通过同一个key来进行通信
  • 返回值:成功返回非负 key 值,失败返回 -1

shmget 创建/获取共享内存

cpp 复制代码
int shmget(key_t key, size_t size, int shmflg);
  • 功能:创建新的共享内存段,或获取已存在的共享内存段
  • 参数说明:
    • key:由 ftok 生成的 IPC 键值
    • size:共享内存大小,必须是内存页大小(PAGE_SIZE,通常 4096 字节)的整数倍
      • **创建新内存时:**必须 >0,系统会按内存页大小(4KB)对齐;
      • **获取已存在共享内存时:**填 0 即可。
    • shmflg:权限与创建标志,常用组合:
      • IPC_CREAT:不存在则创建,存在则直接获取
      • IPC_CREAT | IPC_EXCL:不存在则创建,已存在则报错
      • 后拼接权限位 0666,表示所有用户可读写
  • 返回值:成功返回共享内存标识符 shmid,失败返回 -1

shmat 映射共享内存到进程空间

cpp 复制代码
void *shmat(int shmid, const void *shmaddr, int shmflg);
  • 功能:将指定共享内存段,映射到当前进程的虚拟地址空间
  • 参数说明:
    • shmid:shmget 返回的共享内存标识符
    • shmaddr:指定映射起始地址,**填 NULL即可,**让内核在【当前进程的虚拟地址空间】里,自动找一块空闲的虚拟地址,把共享内存映射过去(推荐)
    • shmflg:操作标志
      • 0: 表示可读可写
      • SHM_RDONLY:只读挂载
  • 返回值:成功返回映射后的虚拟地址指针,失败返回 (void *)-1

shmdt 解除共享内存映射

cpp 复制代码
int shmdt(const void *shmaddr);
  • 功能:解除当前进程与共享内存的映射关联,进程无法再访问该共享内存
  • 注意:仅断开连接,不会销毁共享内存
  • 参数:shmat 返回的映射地址指针
  • 返回值:成功返回 0,失败返回 -1

shmctl 共享内存控制(销毁/获取属性)

cpp 复制代码
int shmctl(int shmid, int cmd, struct shm_ds *buf);
  • 功能:对共享内存进行控制操作(销毁、查询、修改属性)
  • 参数说明:
    • shmid:共享内存标识符
    • cmd:操作指令,常用:
      • IPC_RMID:立即标记销毁共享内存(所有进程解除映射后释放)(🔥 最常用)
      • IPC_STAT:获取共享内存属性信息
      • IPC_SET:修改共享内存属性
    • buf:用于存储或设置共享内存属性
      • IPC_RMID 时:直接填 NULL
      • IPC_STAT/IPC_SET:传 struct shmid_ds 结构体地址
  • 返回值:成功返回 0,失败返回 -1

shmctl (shmid, IPC_RMID, NULL) ------ 只能调用一次

  • 作用:删除内核中的共享内存段
  • 共享内存在内核中只有一份
  • 删一次就没了
  • 所有进程 shmdt 后,内核真正释放

1.3.2 POSIX 共享内存 API

依赖头文件

cpp 复制代码
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>

shm_open 创建/打开 POSIX 共享内存

cpp 复制代码
int shm_open(const char *name, int oflag, mode_t mode);
  • 功能:创建或打开一个 POSIX 共享内存对象
  • 参数说明:
    • name:共享内存唯一名称,必须以 / 开头(如 /my_shm)
    • oflag:打开标志,常用:
      • O_CREAT:不存在则创建
      • O_RDWR:以读写方式打开
    • mode:权限位,创建时必须指定,如 0666
  • 返回值:成功返回共享内存文件描述符 fd,失败返回 -1

shm_open 的第一个参数 不是普通文件路径(绝对路径) ,而是 共享内存对象的名称

规则:必须以 / 开头,且后面不能再包含 /

  • 你写 /my_shm
  • 系统实际创建:/dev/shm/my_shm
  • 这个 /dev/shm 是内存文件系统(tmpfs),不是普通磁盘目录

shm_open函数

  • 路径必须以 / 开头,不代表磁盘路径;
  • 内核自动映射到: /dev/shm (tmpfs 内存文件系统)
  • 文件全程只在内存,不落地磁盘。


open函数(不推荐用open函数替代shm_open函数)

  • 打开的是磁盘上真实文件、设备文件、管道文件等;
  • 数据默认落磁盘,占用硬盘空间。

ftruncate 设置共享内存大小

cpp 复制代码
int ftruncate(int fd, off_t length);
  • 功能:必须调用,为新创建的共享内存设置大小
  • 参数说明:
    • fd:shm_open 返回的文件描述符
    • length:共享内存目标大小
  • 返回值:成功返回 0,失败返回 -1

mmap 映射共享内存到进程空间

cpp 复制代码
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
  • 功能:将 POSIX 共享内存映射到当前进程虚拟地址空间
  • 参数说明:
    • addr:填 NULL,由内核自动分配地址
    • length:共享内存大小,与 ftruncate 一致
    • prot:内存权限,PROT_READ | PROT_WRITE 表示可读可写
    • flags:必须填 MAP_SHARED,修改对所有进程可见
    • fd:共享内存文件描述符
    • offset:偏移量,填 0
  • 返回值:成功返回映射地址指针,失败返回 (void *)-1

munmap 解除映射

cpp 复制代码
int munmap(void *addr, size_t length);
  • 功能:解除进程与 POSIX 共享内存的映射
  • 参数:
    • addr:mmap 返回的映射地址
    • length:共享内存大小
  • 返回值:成功返回 0,失败返回 -1

shm_unlink 删除 POSIX 共享内存

cpp 复制代码
int shm_unlink(const char *name);
  • 功能:删除 POSIX 共享内存对象
  • 特性:删除后,已映射的进程仍可正常使用,直到所有进程解除映射,内核才会真正释放内存
  • 参数:与 shm_open 中的 name 保持一致
  • 返回值:成功返回 0,失败返回 -1

执行 shm_unlink 之后:

  1. 新进程不能再通过 shm_open 打开这个共享内存了(名字没了)

  1. 已经提前 mmap 映射成功的进程:
  • 虚拟地址还绑着物理内存页
  • 照常读、照常写,完全不受影响

  1. 内核什么时候真正释放物理内存?

所有映射过该共享内存的进程,全部调用 munmap 解除映射之后,内核才会真正回收内存。

行为和 Linux 文件硬链接删除机制一模一样:Linux 删除文件,只是删掉目录项,只要还有进程打开/映射着,物理数据绝不立刻回收


1.4 共享内存的优势与局限性

优势

  • 效率极高:无需数据拷贝,进程直接操作物理内存,是所有 IPC 机制中速度最快的。
  • 灵活性强:可共享任意类型的数据(字符、结构体、数组等),无固定格式限制。
  • 容量大:共享内存的大小仅受限于物理内存和系统内核参数(如 shmmax 限制最大共享内存大小)。

局限性

  • 无同步机制:多个进程同时读写共享内存时,会出现数据竞争(如写覆盖、读脏数据),需配合信号量等同步机制使用。
  • 无数据边界:进程需自行约定数据格式和读写规则,否则会出现数据错乱。
  • 资源泄漏风险:若进程异常终止且未解除映射、未销毁共享内存,会导致内存资源泄漏,需手动清理(如 ipcrm 命令)。

二、信号量:共享资源的"同步锁"

2.1 核心定义与设计初衷

信号量是原子计数器,用于控制多进程 / 线程对共享资源的访问顺序,实现同步与互斥,保证临界区同一时刻仅允许指定数量进程访问。核心原子操作:

  • P 操作(wait):信号量减 1,小于 0 则进程阻塞;大于等于 0 则继续执行。
  • V 操作(post):信号量加 1,大于 0 则唤醒等待进程;小于等于 0 仅更新计数器。

原子操作:要么完整做完,要么完全不做,中途绝不被打断、不会被拆分的操作。

为什么信号量 P/V 必须是原子操作?

信号量如果不是原子操作:多个进程同时减信号量计数,中途被打断,就会计数错乱、该阻塞不阻塞、出现数据竞争。

Linux 信号量、互斥锁,内核底层全部依赖硬件原子指令实现。


2.2 信号量的分类

Linux 信号量分为三类,核心区别在于适用范围:

  • 内核信号量:用于内核态(如驱动),睡眠锁,适合长时持有,不可用于中断上下文,基于等待队列实现。
  • POSIX 信号量:面向用户态,分有名(文件系统实现,用于进程间)和无名(内存实现,用于线程间),接口简洁,是现代开发首选。
  • System V 信号量:面向用户态进程,以信号量集形式存在,接口复杂,兼容性好,适用于传统系统。

补充:

  • 最常用的信号量是"二值信号量"(初始值为 1),仅允许一个进程访问临界区,本质上就是互斥锁;
  • "计数信号量"(初始值为 N)允许 N 个进程同时访问临界区,适用于资源池场景(如 N 个文件描述符共享),同一时刻最多允许允许N个进程同时访问临界资源。

2.3 核心 API 详解(常用场景)

实际开发中,POSIX 信号量(进程间同步)和 System V 信号量(传统场景)使用较多,以下重点讲解这两类的核心 API。

2.3.1 POSIX 信号量 API(进程间同步)

依赖头文件

cpp 复制代码
#include <semaphore.h>

sem_open 创建/打开有名信号量

cpp 复制代码
sem_t *sem_open(const char *name, int oflag, mode_t mode, unsigned int value);
  • 功能:创建或打开有名信号量
  • 参数说明:
    • name:信号量路径,必须以 / 开头(如 /my_sem)
    • oflag:打开标志,O_CREAT 表示不存在则创建
    • mode:权限位,常用 0666
    • value:信号量初始值,二值信号量设为 1,计数信号量设为 N
  • 返回值:成功返回信号量指针 sem_t *,失败返回 SEM_FAILED

sem_wait 执行 P 操作

cpp 复制代码
int sem_wait(sem_t *sem);
  • 功能:执行 P 操作,信号量值减 1
  • 特性:若结果小于 0,进程阻塞等待
  • 返回值:成功返回 0,失败返回 -1

sem_post 执行 V 操作

cpp 复制代码
int sem_post(sem_t *sem);
  • 功能:执行 V 操作,信号量值加 1
  • 特性:若有等待进程,唤醒队列中的一个
  • 返回值:成功返回 0,失败返回 -1

sem_close 关闭信号量

cpp 复制代码
int sem_close(sem_t *sem);
  • 功能:关闭信号量,解除当前进程与信号量的关联
  • 注意:不删除信号量对象
  • 返回值:成功返回 0,失败返回 -1

sem_unlink 删除信号量

cpp 复制代码
int sem_unlink(const char *name);
  • 功能:删除信号量对象
  • 特性:所有进程关闭信号量后,内核释放资源
  • 返回值:成功返回 0,失败返回 -1

2.3.2 System V 信号量 API

依赖头文件

cpp 复制代码
#include <sys/sem.h>

semget 创建/获取信号量集

cpp 复制代码
int semget(key_t key, int nsems, int semflg);
  • 功能:创建或获取信号量集
  • 参数说明:
    • key:唯一标识,由 ftok 生成
    • nsems:信号量集中包含的信号量数量,如果是创建信号量集,需要大于0;若信号量已存在,则为0
    • semflg:权限标志,常用 IPC_CREAT、IPC_CREAT | IPC_EXCL + 权限位
  • 返回值:成功返回信号量集标识符 semid,失败返回 -1

semop 执行 P/V 操作

cpp 复制代码
int semop(int semid, struct sembuf *sops, size_t nsops);
  • 功能:对信号量集执行 P/V 操作
  • 结构体定义:你要告诉 semop:操作第几个信号量、加还是减、是否阻塞
cpp 复制代码
struct sembuf { 
    short sem_num; // 信号量索引,从 0 开始 
    short sem_op; // -1 = P操作,+1 = V操作 
    short sem_flg; // 0 阻塞 
};
  • 参数说明:
    • sops:操作结构体数组
    • nsops:要执行的操作个数(一次性要执行多少个 sembuf 操作)
  • 返回值:成功返回 0,失败返回 -1

semctl 信号量集控制

cpp 复制代码
int semctl(int semid, int semnum, int cmd, ...);
  • 功能:信号量集控制(初始化、取值、销毁)
  • 参数说明:
    • semid:信号量集标识符

    • semnum:信号量索引(从 0 开始),比如信号量集里有 2 个信号量,索引就是0和1

      • ⚠️ 注意:当 cmd=IPC_RMID 时,这个参数会被忽略,直接填0即可
    • cmd:操作指令

      • SETVAL:设置信号量初始值

        • 给 semnum 指定的信号量设置初始值,必须配合第四个参数,传递你要设置的值
      • GETVAL:获取信号量当前值

        • 获取 semnum 指定的信号量的当前值。直接用返回值接收结果即可,不需要第四个参数
      • IPC_RMID:销毁信号量集

  • 返回值:成功返回 0 或对应数值(如 GETVAL),失败返回 -1

semctl (semid, 0, IPC_RMID) ------ 只能调用一次

  • 第二个参数填 0 即可(习惯写法)
  • 整个信号量集立刻被标记删除
  • 内核直接把它从系统 IPC 表里移除
  • semid 瞬间失效
  • 第二次再调用 semctl (semid, ...) → 直接报错:无效的 ID

2.4 信号量的优势与局限性

优势

  • 同步机制完善:支持互斥(二值信号量)和同步(计数信号量),可灵活控制共享资源的访问。
  • 原子性保证:P/V 操作是原子操作,避免了并发场景下的操作错乱。
  • 适用范围广:可用于进程间、线程间同步,也可用于内核态与用户态的同步(内核信号量)。

局限性

  • 仅负责同步:信号量本身不传递数据,需与共享内存、管道等配合使用,才能实现完整的 IPC 通信。
  • 死锁风险:若进程执行 P 操作后未执行 V 操作(如异常终止),会导致信号量永久为 0,其他进程无法获取资源,引发死锁。
  • 资源泄漏:信号量未正确删除时,会占用系统资源,需手动清理。

三、共享内存 + 信号量:实战结合示例

如前文所述,共享内存无同步机制,信号量无数据传输能力,二者结合是 Linux 多进程通信的经典方案。以下通过一个简单的实战示例,实现"进程 A 写入数据到共享内存,进程 B 读取数据",用 POSIX 信号量保证读写同步(避免读脏数据)。

3.1 实战需求

  • 创建 POSIX 共享内存和 POSIX 二值信号量。
  • 进程 A(写进程):获取信号量 → 写入数据到共享内存 → 释放信号量。
  • 进程 B(读进程):获取信号量 → 从共享内存读取数据 → 释放信号量。
  • 程序结束后,清理共享内存和信号量资源。

3.2 代码实现

3.2.1 写进程(write_shm.c)

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <semaphore.h>
#include <unistd.h>

#define SHM_NAME "/my_shm"   // 共享内存路径
#define SEM_NAME "/my_sem"   // 信号量路径
#define SHM_SIZE 1024        // 共享内存大小

int main() {
    int shm_fd;
    char *shm_ptr;
    sem_t *sem;

    // 1. 创建并打开信号量(初始值 1,二值信号量)
    sem = sem_open(SEM_NAME, O_CREAT | O_RDWR, 0666, 1);
    if (sem == SEM_FAILED) {
        perror("sem_open failed");
        exit(EXIT_FAILURE);
    }

    // 2. 创建并打开共享内存
    shm_fd = shm_open(SHM_NAME, O_CREAT | O_RDWR, 0666);
    if (shm_fd == -1) {
        perror("shm_open failed");
        sem_close(sem);
        sem_unlink(SEM_NAME);
        exit(EXIT_FAILURE);
    }

    // 3. 设置共享内存大小
    if (ftruncate(shm_fd, SHM_SIZE) == -1) {
        perror("ftruncate failed");
        shm_close(shm_fd);
        shm_unlink(SHM_NAME);
        sem_close(sem);
        sem_unlink(SEM_NAME);
        exit(EXIT_FAILURE);
    }

    // 4. 映射共享内存到进程地址空间
    shm_ptr = mmap(NULL, SHM_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, shm_fd, 0);
    if (shm_ptr == MAP_FAILED) {
        perror("mmap failed");
        shm_close(shm_fd);
        shm_unlink(SHM_NAME);
        sem_close(sem);
        sem_unlink(SEM_NAME);
        exit(EXIT_FAILURE);
    }

    // 5. 写入数据(P操作获取信号量,避免并发写)
    sem_wait(sem);  // P操作:信号量减1,若为0则阻塞
    char msg[] = "Hello, Shared Memory + Semaphore!";
    strncpy(shm_ptr, msg, strlen(msg) + 1);  // 复制字符串(包含结束符)
    printf("写进程:已写入数据:%s\n", shm_ptr);
    sem_post(sem);  // V操作:信号量加1,唤醒等待的读进程

    // 6. 延迟1秒,让读进程有时间读取
    sleep(1);

    // 7. 清理资源
    munmap(shm_ptr, SHM_SIZE);  // 解除映射
    close(shm_fd);              // 关闭共享内存文件描述符
    sem_close(sem);             // 关闭信号量
    // 不立即删除共享内存和信号量,留给读进程使用
    // shm_unlink(SHM_NAME);
    // sem_unlink(SEM_NAME);

    return 0;
}

3.2.2 读进程(read_shm.c)

3.3 编译与运行

编译写进程 gcc write_shm.c -o write_shm

编译读进程 gcc read_shm.c -o read_shm

打开两个终端,分别运行

终端1:运行写进程 ./write_shm

终端2:运行读进程 ./read_shm

运行结果:
终端1(写进程):
写进程:已写入数据:Hello, Shared Memory + Semaphore!
终端2(读进程):
读进程:已读取数据:Hello, Shared Memory + Semaphore!


四、常见问题与避坑指南

4.1 共享内存常见问题

问题1:创建失败,报错 Permission denied

  • 原因:权限未正确配置(未添加 0666 权限位),或已有共享内存不允许当前用户访问
  • 解决:创建时指定 0666 权限;必要时使用 chmod 修改权限

问题2:映射失败,报错 Invalid argument

  • 原因:未调用 ftruncate 设置共享内存大小,或大小不是 PAGE_SIZE(4096)整数倍
  • 解决:创建后必须调用 ftruncate;确保大小为 4096 的整数倍

问题3:进程异常终止后无法删除共享内存

  • 原因:进程未执行解除映射/删除操作,共享内存引用计数不为 0
  • 解决:使用 ipcs -m 查看共享内存;使用 ipcrm -m shmid 手动删除

4.2 信号量常见问题

问题1:P 操作后进程永久阻塞

  • 原因:信号量初始值为 0,或进程未执行 V 操作异常退出
  • 解决:检查初始值;保证 P/V 成对出现;使用 sem_unlink 删除信号量重建

问题2:创建失败,报错 File exists

  • 原因:旧信号量未清理,且使用了 IPC_EXCL / O_EXCL 强制创建标志
  • 解决:POSIX 使用 sem_unlink 删除;System V 使用 ipcrm -s semid 删除

问题3:多进程同步仍出现数据错乱

  • 原因:P/V 未完整包裹临界区,或误用计数信号量
  • 解决:P 操作在临界区前,V 操作在临界区后;互斥场景使用初始值为 1 的二值信号量

4.3 通用避坑技巧

  • 资源清理:进程正常/异常退出都必须清理 IPC 资源,可通过 atexit 注册自动清理函数
  • 权限控制:创建时统一指定 0666 权限,避免运行时权限拒绝
  • 调试工具:ipcs 查看共享内存/信号量;ipcrm 手动清理;strace 跟踪系统调用
  • 死锁防范:严格保证 P/V 成对;避免多信号量交叉等待

五、总结

共享内存与信号量相辅相成:共享内存负责高效传输数据,是大数据量场景首选;信号量负责安全同步资源,避免数据竞争。掌握二者核心在于理解共享内存映射原理、API 用法,明确信号量 P/V 操作本质,熟练运用二者组合方案,同时注意资源清理和避坑,可显著提升 Linux 系统编程能力,适配高并发、多进程协作等场景。

相关推荐
苏三说技术6 分钟前
为什么越来越多人用OpenWiki?
后端
Dreams_l12 分钟前
死信队列和延迟队列介绍
java·开发语言
IT_陈寒13 分钟前
Java里用Stream.parallel()翻车实录,这性能还不如单线程
前端·人工智能·后端
Bs_MoneyMagnet25 分钟前
基于springboot+vue的生态果园采摘预约系统的设计与实现 源码+文档
java·vue.js·spring boot·后端·毕业设计·计算机毕业设计
Zenova EdgeOS1 小时前
Python 工业边缘 redis-py 异步实战
开发语言·redis·python
码事漫谈1 小时前
FDE 的起源、形态与真实边界
后端
ServBay1 小时前
Grok Bot 还是 OpenClaw?聊聊真正能替人打杂的 AI 智能体
后端·aigc·grok
掘金挖土1 小时前
前端手摸手跑路之 AI 应用开发(五)
前端·后端
君顾11 小时前
西安 24 小时自助健身房系统开发实战指南
java·开发语言·健身房
白色的北极熊1 小时前
c语言 scanf 输入流有空格,逗号 说明
c语言