信号量基础:从概念到实战

大家好,我是老陈。今天咱们聊聊信号量——这个在多进程编程里绕不开的话题。

说实话,我刚入行那会儿,对信号量也是一头雾水。总觉得它跟互斥锁差不多,后来踩了不少坑才明白,这两者压根不是一回事。嗯,咱们今天就把信号量彻底讲透。

信号量是什么?一个简单的比喻

想象一下,你开了一家奶茶店,店里只有3个座位。顾客来了,有座位就坐下,没座位就排队等。这个「座位数量」就是信号量的值。

信号量本质上是一个计数器。它用来控制多个进程对共享资源的访问。说白了,就是「还有多少个资源可用」。

核心要点:信号量是一个整数,支持两种原子操作——P操作(申请资源)和V操作(释放资源)。

我在项目中遇到过这样一个场景:一个视频处理系统,有4个硬件编码器。多个进程需要抢着用。如果不用信号量,两个进程同时用同一个编码器,画面直接花掉。用信号量就简单了——初始值设为4,每个进程用之前P一下,用完V一下,谁也别抢谁的。

信号量与互斥锁的区别

这个问题我面试过很多人,十个有八个答不全。咱们用表格对比一下:

对比项 信号量 互斥锁
本质 计数器
用途 资源计数、同步 互斥访问
拥有者 无拥有者概念 有拥有者(谁加锁谁解锁)
值范围 0~N(N可大于1) 0或1
能否用于同步 不能(会死锁)
跨进程 原生支持 需特殊设置

你想想看,互斥锁就像厕所门上的锁——一个人进去锁上,出来解锁,别人才能进。信号量更像停车场门口的计数器——显示还剩几个车位,每进一辆减1,出一辆加1。

我曾经犯过的错:用二值信号量当互斥锁用,结果一个进程P了之后崩溃了,信号量永远变成0,其他进程全卡死。互斥锁有「所有权」机制,谁加锁谁解锁,崩溃时内核会自动释放。信号量不会!

信号量的分类

信号量分两种,咱们一个一个说。

二值信号量

值只能是0或1。说白了就是「有」或「没有」。适合控制对单个资源的访问。

我记得有个项目,主进程和子进程之间需要同步——子进程准备好数据了,主进程才能读。我就用了一个二值信号量,初始为0。子进程准备好后V一下,主进程P一下。简单粗暴,效果很好。

计数信号量

值可以是0到任意正数。适合管理多个同类资源。

比如前面说的4个编码器,信号量初始值就是4。每来一个进程用编码器,信号量减1;用完加1。如果信号量减到0,后来的进程就得等着。

我的建议:能用计数信号量就别用二值的。因为需求总会变——今天1个资源,明天可能就变成3个了。计数信号量改个初始值就行,二值的得重构代码。

信号量的创建:semget

创建信号量用 semget 函数。原型长这样:

#include <sys/sem.h>

int semget(key_t key, int nsems, int semflg);

参数说明:

  • key:IPC键值,类似文件名。多个进程用同一个key就能访问同一组信号量。
  • nsems:信号量的数量。你要创建几个信号量?通常传1。
  • semflg:权限标志。IPC_CREAT表示创建,IPC_EXCL表示排他。权限位用八进制,比如0666。

返回值是信号量标识符(semid),后面所有操作都靠它。

举个实际例子:

key_t key = ftok("/tmp/semfile", 'A');
int semid = semget(key, 1, IPC_CREAT | IPC_EXCL | 0666);
if (semid == -1) {
    // 如果已经存在,就尝试获取
    semid = semget(key, 1, 0666);
}

这里有个坑——ftok 生成的key可能冲突。我习惯用一个固定的数字当key,比如 0x1234,省心。

信号量的初始化:semctl

创建完信号量,它的初始值是随机的。必须手动初始化。用 semctl 的 SETVAL 命令:

#include <sys/sem.h>

int semctl(int semid, int semnum, int cmd, ...);

初始化代码:

union semun {
    int val;
    struct semid_ds *buf;
    unsigned short *array;
};

union semun arg;
arg.val = 3;  // 初始值设为3,表示3个可用资源

if (semctl(semid, 0, SETVAL, arg) == -1) {
    perror("semctl SETVAL failed");
    exit(1);
}

注意:union semun 在 Linux 中需要自己定义!glibc 没有默认提供。我第一次用的时候找了半天才找到原因。

信号量的PV操作:semop

PV操作是信号量的核心。P就是申请资源(减1),V就是释放资源(加1)。

#include <sys/sem.h>

int semop(int semid, struct sembuf *sops, size_t nsops);

struct sembuf 结构体:

struct sembuf {
    unsigned short sem_num;  // 信号量编号(从0开始)
    short sem_op;            // 操作:-1=P操作,+1=V操作
    short sem_flg;           // 标志:0=阻塞,IPC_NOWAIT=非阻塞
};

来个完整的例子:

// P操作(申请资源)
struct sembuf p_op = {0, -1, 0};
if (semop(semid, &p_op, 1) == -1) {
    perror("semop P failed");
    exit(1);
}

// 使用资源...
// do_something();

// V操作(释放资源)
struct sembuf v_op = {0, 1, 0};
if (semop(semid, &v_op, 1) == -1) {
    perror("semop V failed");
    exit(1);
}

这里有个小技巧——sem_flg 设成 IPC_NOWAIT 可以实现非阻塞。如果资源不够,直接返回错误,不会卡住。我在做实时系统时经常这么用。

信号量的控制:semctl

除了初始化,semctl 还能干很多事:

命令 作用 使用场景
GETVAL 获取信号量当前值 调试、监控
SETVAL 设置信号量值 初始化、重置
IPC_RMID 删除信号量 程序退出时清理
GETPID 获取最后操作信号量的进程PID 排查问题
GETALL 获取所有信号量的值 批量读取

删除信号量的代码:

if (semctl(semid, 0, IPC_RMID) == -1) {
    perror("semctl IPC_RMID failed");
}

我的习惯:程序启动时创建并初始化信号量,退出时删除。但要注意——如果程序崩溃了,信号量会残留在系统中。可以用 ipcs -s 查看,ipcrm -s semid 手动删除。

一张图看懂信号量

下面这张图展示了信号量的完整工作流程:

信号量工作流程 共享资源池 编码器1 编码器2 编码器3 编码器4 信号量 当前值: 4 P操作: 值-1 V操作: 值+1 值=0时阻塞 进程A 进程B 操作流程: 1. 进程A调用P操作,信号量值从4减为3 2. 进程A获取编码器1,开始工作 3. 进程B调用P操作,信号量值从3减为2 4. 进程B获取编码器2,开始工作 5. 进程A工作完成,调用V操作,信号量值从2加回3 6. 如果信号量减到0,后续进程必须等待 关键:P和V操作必须是原子的,不会被中断。这是信号量能保证同步的根本原因。

实战中的避坑指南

最后,分享几个我这些年踩过的坑:

  • 别忘了初始化——semget创建后,信号量的值是随机的。不初始化直接P操作,鬼知道会发生什么。
  • PV操作要配对——P了之后一定要V,少一个都不行。我曾经在错误处理分支里忘了V,结果信号量慢慢变成0,整个系统卡死。
  • 注意死锁——进程A持有信号量1,等信号量2;进程B持有信号量2,等信号量1。两个都卡死。解决办法:所有进程按相同顺序申请信号量。
  • 信号量是内核资源——程序退出后不会自动释放。用 ipcrm 清理,或者在程序里注册 atexit 处理函数。

嗯,信号量的基础就讲到这里。这东西看着简单,用好了能解决大问题,用不好也能制造大麻烦。多写代码,多踩坑,自然就熟了。

公众号:蓝海资料掘金营,微信deep3321