在 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 之后:
- 新进程不能再通过 shm_open 打开这个共享内存了(名字没了)
- 已经提前 mmap 映射成功的进程:
- 虚拟地址还绑着物理内存页
- 照常读、照常写,完全不受影响
- 内核什么时候真正释放物理内存?
✅ 所有映射过该共享内存的进程,全部调用 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 系统编程能力,适配高并发、多进程协作等场景。