
观众老爷们大家好 这里是邪修KING的独家频道 本文属于系列Linux系统篇 ------动静态库 一起学Linux的小伙伴可订阅专栏: Linux系统篇
前言
我们前面学习了匿名管道、命名管道、System V 共享内存。其中共享内存是速度最快的 IPC,但是内核不会自带任何访问保护 :多个进程同时读写同一块共享内存,会产生竞态条件(race condition),直接造成数据错乱。
课堂上的思维导图核心:
- 多个执行流看到同一份公共资源 → 共享资源
- 被保护的共享资源叫临界资源
- 访问临界资源的代码片段叫临界区
- 同一时刻只允许一个执行流访问资源:互斥
- 多个执行流访问资源有固定先后顺序:同步
- 信号量就是实现互斥与同步的经典方案
很多初学者会混淆:信号(signal)≠信号量(semaphore)
- 信号:异步通知,比如 SIGINT、SIGPIPE,内核打断进程执行
- 信号量:同步计数器,PV 原子操作,进程主动阻塞、主动唤醒,用来协调访问顺序
同时,Linux 内核大量使用C 语言函数指针实现多态(没有 C++ 的 class,用结构体 + 函数指针表模拟面向对象),这也是我们这篇重点结合结构图讲解的内容。
一、信号量基础理论:PV 操作
信号量本质:内核维护的计数器 + 进程等待队列 ,所有操作都是原子的,不会被 CPU 调度打断。
两个核心操作:
- P 操作(Proberen,申请资源,sem_op=-1)
计数器减 1;如果结果≥0,进程继续执行;小于 0,进程阻塞,挂入等待队列。 - V 操作(Verhogen,释放资源,sem_op=+1)
计数器加 1;如果结果≤0,说明队列里有等待进程,唤醒其中一个。
两种典型用法:
- 二元信号量(初始值 = 1):互斥锁,保护临界区,同一时间只允许一个进程进入
- 计数信号量(初始值 = N):多资源同步,比如生产者消费者模型
重点:System V 信号量不是单个信号量,是信号量集(semaphore array),一次可以创建一组多个信号量,统一管理。
二、内核视角:先描述,再组织(对应课件结构图)
Linux 内核所有 IPC(管道、消息队列、共享内存、信号量)都遵循统一设计思想:先描述,再组织。
2.1 顶层统一管理:struct ipc_ids
System V 三大 IPC(msg、shm、sem)都由struct ipc_ids统一管理,全局变量:
static struct ipc_ids sem_ids; // 信号量集的全局管理对象
ipc_ids里面维护一个 id 数组,记录所有内核中存在的信号量集、消息队列、共享内存段。
每个 IPC 对象头部都包含公共头部:struct kern_ipc_perm,记录 key、uid、gid、mode 权限、seq 序列号,这是统一身份描述。
2.2 信号量集核心描述:struct sem_array(课件里的 sem_array)
一个 sem_array 代表一个信号量集:
c
struct sem_array {
struct kern_ipc_perm sem_perm; // IPC权限头部
time_t sem_otime; // 最后一次semop操作时间
time_t sem_ctime; // 最后一次修改时间
struct sem *sem_base; // 指向数组内第一个信号量
struct sem_queue *sem_pending; // 挂起等待的操作队列头
struct sem_queue **sem_pending_last;
struct sem_undo *undo; // SEM_UNDO撤销链表,进程退出自动还原信号量
unsigned short sem_nsems; // 这个集合里面有多少个信号量
};
sem_base:指向struct sem数组,每一个struct sem就是一个信号量:包含计数器semval、等待这个信号量的进程数量sem_queue:阻塞等待的进程链表,进程 P 操作资源不够时,把自己的请求挂到这个链表,V 操作的时候遍历链表唤醒
2.3 对比管道:C 语言函数指针实现内核多态(重点!)
这是课件结构图里pipe_operations的核心思想:C 没有类,用结构体里存放函数指针,实现多态,也就是 VFS 虚拟文件系统的思想。
c
// 简化版,内核源码里的管道操作表
struct pipe_operations {
ssize_t (*read)(struct pipe_inode_info *, char __user *, size_t);
ssize_t (*write)(struct pipe_inode_info *, const char __user *, size_t);
int (*release)(struct pipe_inode_info *);
};
static const struct pipe_operations pipe_ops = {
.read = pipe_read,
.write = pipe_write,
.release = pipe_release,
};
通用的struct file里面有f_op指针,指向struct file_operations:
struct file {
struct file_operations *f_op;
// ...其他成员
};
多态逻辑:
同一个 read 系统调用,内核拿到
file->f_op->read,如果是普通文件,调用磁盘文件 read;如果是管道,调用pipe_read;如果是设备,调用设备驱动 read。同一个接口,不同底层实现,这就是 C 语言函数指针实现的多态 ,Linux 内核无处不在这个设计。
管道、socket、普通文件,对外统一 read/write,底层函数不一样,和 C++ 虚函数表原理一模一样。
信号量、共享内存、消息队列不走文件 VFS 这套,但是同样遵循「先描述,再组织」:先用结构体描述对象属性,再用链表 / 数组组织起来统一管理。
2.4 信号量和管道内核差异总结
- 管道:属于 VFS,依托 inode+file_operations 函数指针多态,读写自带阻塞同步
- System V 信号量:独立 IPC 对象,sem_array+sem_queue 链表,专门做 PV 同步,本身不存业务数据
- 共享内存:shmid_kernel,只映射内存,没有任何同步逻辑,必须搭配信号量
三、System V 信号量 API 详解
头文件:
#include <sys/sem.h>
#include <sys/ipc.h>
#include <sys/types.h>
1. semget:创建 / 获取信号量集
int semget(key_t key, int nsems, int semflg);
- key:ftok 生成的键值,用来跨进程找到同一个信号量集
- nsems:集合里面信号量的个数
- semflg:IPC_CREAT | IPC_EXCL | 0666
- 返回值:semid(信号量集 id,后续操作句柄),失败返回 - 1
2. semop:PV 操作(核心)
int semop(int semid, struct sembuf *sops, unsigned nsops);
struct sembuf 结构体:
struct sembuf {
unsigned short sem_num; // 操作集合里第几个信号量,下标从0开始
short sem_op; // -1=P,+1=V,0=等待信号量变为0
short sem_flg; // SEM_UNDO:进程异常退出,自动撤销本次操作,防止死锁
};
nsops:一次批量操作几个信号量
3. semctl:控制信号量,初始化、删除、获取信息
int semctl(int semid, int sem_num, int cmd, ...);
常用 cmd:
- SETVAL:设置信号量初始值(初始化)
- IPC_RMID:删除整个信号量集
- GETVAL:获取当前信号量值
四、实战代码:生产者消费者(共享内存 + 信号量)
经典场景:共享内存传输数据,信号量做同步,必须保证:生产者写完,消费者才能读;消费者读完,生产者才能写下一轮。
两个信号量:
sem 0:空缓冲区,初始值 = 1 → 生产者可以写
sem 1:数据就绪,初始值 = 0 → 消费者等待数据
公共头(common.h)
c
#ifndef __COMMON_H__
#define __COMMON_H__
#include <stdio.h>
#include <unistd.h>
#include <sys/ipc.h>
#include <sys/sem.h>
#include <sys/shm.h>
#include <string.h>
#include <errno.h>
#define SHM_KEY 0x12345
#define SEM_KEY 0x54321
#define SHM_SIZE 1024
// 封装P、V操作
static inline int P(int semid, int index)
{
struct sembuf buf;
buf.sem_num = index;
buf.sem_op = -1;
buf.sem_flg = SEM_UNDO;
return semop(semid, &buf, 1);
}
static inline int V(int semid, int index)
{
struct sembuf buf;
buf.sem_num = index;
buf.sem_op = 1;
buf.sem_flg = SEM_UNDO;
return semop(semid, &buf, 1);
}
#endif
生产者 write.c
c
#include "common.h"
int main(void)
{
// 1. 创建信号量集,2个信号量
int semid = semget(SEM_KEY, 2, IPC_CREAT | 0666);
if(semid < 0) {
perror("semget");
return 1;
}
// 初始化:0号信号量=1(可写),1号=0(无数据)
semctl(semid, 0, SETVAL, 1);
semctl(semid, 1, SETVAL, 0);
// 2. 创建共享内存
int shmid = shmget(SHM_KEY, SHM_SIZE, IPC_CREAT | 0666);
if(shmid <0) {
perror("shmget");
return 1;
}
char *shmaddr = shmat(shmid, NULL, 0);
if(shmaddr == (void*)-1) {
perror("shmat");
return 1;
}
int cnt = 0;
while(1) {
P(semid, 0); // 申请写位置,空缓冲区-1
snprintf(shmaddr, SHM_SIZE, "生产者[%d]发送数据:%d", getpid(), cnt++);
printf("生产者写入:%s\n", shmaddr);
sleep(1); // 模拟业务耗时
V(semid, 1); // 通知消费者:数据准备好了
}
shmdt(shmaddr);
return 0;
}
消费者 read.c
c
#include "common.h"
int main(void)
{
int semid = semget(SEM_KEY, 2, 0666);
if(semid <0) {
perror("semget");
return 1;
}
int shmid = shmget(SHM_KEY, SHM_SIZE, 0666);
if(shmid <0) {
perror("shmget");
return 1;
}
char *shmaddr = shmat(shmid, NULL, 0);
if(shmaddr == (void*)-1) {
perror("shmat");
return 1;
}
while(1) {
P(semid, 1); // 等待数据就绪,没有数据就阻塞
printf("消费者[%d]读到:%s\n", getpid(), shmaddr);
sleep(1);
V(semid, 0); // 读完,释放缓冲区,通知生产者继续写
}
shmdt(shmaddr);
// 最后清理:ipcrm 或者代码里semctl IPC_RMID
return 0;
}
编译运行
bash
gcc write.c -o write
gcc read.c -o read
./write &
./read
效果:生产者写一条,消费者读一条,严格有序,不会读到脏数据。
如果去掉信号量,两个进程同时读写共享内存,会出现半截数据、乱序,也就是思维导图里说的没有保护机制,数据不一致。
清理 IPC 资源(非常重要,System V IPC 生命周期不随进程退出自动销毁)
bash
ipcs -s -m # 查看信号量、共享内存
ipcrm -s 【semid】
ipcrm -m 【shmid】
也可以在程序退出时调用:semctl(semid, 0, IPC_RMID, 0)
五、信号量常见坑点
- 忘记 SEM_UNDO:进程异常崩溃,信号量值不会自动恢复,直接死锁,推荐大部分场景带上 SEM_UNDO
- 信号量集初始化时机:semget 创建的时候不会自动初始化,必须手动 semctl SETVAL,多进程竞争初始化会踩坑
- 死锁:P 顺序颠倒。比如进程 A 拿 sem1 等 sem2,进程 B 拿 sem2 等 sem1
- 混淆:信号量集是一组,sem_num 下标不要写错
- 生命周期:System V 信号量内核持久化,进程退出不会自动删除,必须手动清理,否则会一直残留在系统里
六、横向对比(承接前面三篇博客)
表格
| IPC 方式 | 是否传数据 | 自带同步 | 同步方式 | 性能 | 配套信号量? |
|---|---|---|---|---|---|
| 匿名管道 | ✅ | ✅ | 内核 read/write 阻塞 | 中等 | 一般不需要 |
| 命名管道 FIFO | ✅ | ✅ | 内核阻塞 | 中等 | 一般不需要 |
| System V 共享内存 | ✅ | ❌ | 无任何保护 | 最快 | 必须搭配信号量 |
| System V 信号量 | ❌ | --- | PV 原子操作 | 同步工具 | 单独使用 / 配合共享内存 |
一句话总结:管道自带红绿灯;共享内存是裸高速马路,红绿灯(信号量)需要我们自己装。
七、C 语言函数指针多态拓展(面试高频)
我们前面看了 pipe_operations,再写一个极简 Demo,纯 C 模拟多态,理解内核的设计思想:
c
#include <stdio.h>
// 操作方法表
struct ops {
void (*read)(void);
void (*write)(void);
};
// 管道的实现
void pipe_read(void) { printf("管道read\n"); }
void pipe_write(void) { printf("管道write\n"); }
// 普通文件实现
void file_read(void) { printf("普通文件read\n"); }
void file_write(void) { printf("普通文件write\n"); }
struct ops pipe_ops = {pipe_read, pipe_write};
struct ops file_ops = {file_read, file_write};
// 统一接口,只认ops指针,不管底层是什么
void do_io(struct ops *op) {
op->read();
op->write();
}
int main()
{
do_io(&pipe_ops);
do_io(&file_ops);
return 0;
}
这就是 Linux VFS 的底层思想:统一接口,不同实现,C 语言靠函数指针实现多态,没有 C++ 也能做到面向对象,课件结构图里的 pipe_operations 就是这个结构体。
八、结尾:下一篇预告
到此 System V IPC 三兄弟:消息队列、共享内存、信号量全部讲完。
信号量是并发编程的基石,不仅用于进程间,线程 pthread_mutex 本质思想也是互斥信号量。
