17. 信号量:进程同步的核心武器

信号量这东西,我第一次接触是在大学的操作系统课上。当时觉得不就是个计数器嘛,有啥了不起的。直到后来在项目中真正遇到多进程竞争资源的问题,才发现信号量简直就是救星。说白了,它就是解决「谁先用、谁后用」这个老大难问题的。

信号量的本质

信号量本质上是一个整数,加上两个原子操作:P(减一,也叫wait)和V(加一,也叫post)。你想想看,如果多个进程都要访问同一个共享资源,没有信号量的话,数据乱成一锅粥是迟早的事。

我习惯把信号量想象成一个停车场的车位计数器:

  • 车位有空余 → 进程可以进入(P操作成功)
  • 车位满了 → 进程必须等待(P操作阻塞)
  • 有车离开 → 通知等待的进程(V操作唤醒)

核心要点:信号量的值永远不能为负数。如果P操作会导致值变成负数,进程就会被阻塞,直到其他进程执行V操作。

System V 信号量 API

Linux提供了三剑客:semgetsemopsemctl。这三个函数配合使用,就能搞定进程间同步。

semget — 创建或获取信号量集

#include <sys/sem.h>

int semget(key_t key, int nsems, int semflg);
// 返回:成功返回信号量集ID,失败返回-1

参数说明:

  • key:IPC键值,多个进程通过同一个key找到同一个信号量集
  • nsems:信号量集中信号量的个数
  • semflg:权限标志,通常用 IPC_CREAT | 0666

我的习惯:key值我喜欢用 ftok() 函数生成,这样不同项目不会冲突。曾经有个同事直接写死数字,结果跟系统里的其他程序撞上了,排查了半天。

semop — 执行P/V操作

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

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

这里有个坑:sem_op 为 -1 时是P操作(申请资源),为 +1 时是V操作(释放资源)。我刚开始学的时候老搞反,后来干脆记成「减号是拿,加号是还」。

注意:SEM_UNDO 标志很重要。如果进程异常退出,内核会自动撤销它做过的操作,防止信号量值「泄漏」。我在生产环境遇到过因为没有加这个标志,导致信号量值越来越小,最后所有进程都卡死的惨案。

semctl — 控制信号量

int semctl(int semid, int semnum, int cmd, ...);
// 常用命令:
// IPC_RMID  — 删除信号量集
// SETVAL    — 设置信号量的初始值
// GETVAL    — 获取信号量的当前值

初始化信号量时,一般这样写:

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

union semun arg;
arg.val = 1;  // 初始值为1,表示有1个资源可用
semctl(semid, 0, SETVAL, arg);

二元信号量与计数信号量

这两种信号量,说白了就是「一把锁」和「多把锁」的区别。

类型 取值范围 典型用途
二元信号量 0 或 1 互斥锁,保护临界区
计数信号量 0 ~ N 管理多个同类资源

二元信号量其实就是互斥锁(mutex)。初始值为1,P操作拿到锁,V操作释放锁。我在项目中用它保护共享内存的读写,效果很好。

计数信号量更灵活。比如一个连接池有5个连接,信号量初始值就设为5。每个进程用之前P一下,用完V一下。这样最多5个进程同时使用,第6个就得等着。

生产者消费者问题

这是进程通信的经典案例。我当年面试的时候被问过,后来带新人时也经常拿这个例子讲。场景是这样的:

  • 生产者:往缓冲区里放数据
  • 消费者:从缓冲区里取数据
  • 缓冲区大小有限,满了生产者要等,空了消费者要等

需要三个信号量:

  1. mutex(二元信号量):保护缓冲区的互斥访问
  2. empty(计数信号量):空槽位的数量,初始为N
  3. full(计数信号量):满槽位的数量,初始为0
#include <stdio.h>
#include <stdlib.h>
#include <sys/sem.h>
#include <unistd.h>

#define N 5  // 缓冲区大小

// P操作
void P(int semid, int semnum) {
    struct sembuf sb = {semnum, -1, SEM_UNDO};
    semop(semid, &sb, 1);
}

// V操作
void V(int semid, int semnum) {
    struct sembuf sb = {semnum, 1, SEM_UNDO};
    semop(semid, &sb, 1);
}

int main() {
    // 创建信号量集,包含3个信号量
    int semid = semget(IPC_PRIVATE, 3, IPC_CREAT | 0666);
    
    // 初始化:mutex=1, empty=N, full=0
    semctl(semid, 0, SETVAL, 1);   // mutex
    semctl(semid, 1, SETVAL, N);   // empty
    semctl(semid, 2, SETVAL, 0);   // full
    
    // 生产者进程
    if (fork() == 0) {
        while (1) {
            // 生产数据...
            P(semid, 1);  // 申请一个空槽位
            P(semid, 0);  // 加锁
            // 放入缓冲区...
            V(semid, 0);  // 解锁
            V(semid, 2);  // 增加一个满槽位
            sleep(1);
        }
    }
    
    // 消费者进程
    if (fork() == 0) {
        while (1) {
            P(semid, 2);  // 申请一个满槽位
            P(semid, 0);  // 加锁
            // 从缓冲区取出...
            V(semid, 0);  // 解锁
            V(semid, 1);  // 增加一个空槽位
            sleep(2);
        }
    }
    
    wait(NULL);
    wait(NULL);
    semctl(semid, 0, IPC_RMID);
    return 0;
}

关键点:P操作的顺序很重要。一定是先申请资源信号量(empty/full),再申请互斥信号量(mutex)。反过来会导致死锁——想想看,如果先锁住缓冲区,然后发现没资源可等,别的进程也进不来,不就卡死了吗?

信号量操作流程图

信号量操作流程 进程A(生产者) 进程B(消费者) P(empty) 申请空槽 P(mutex) 加锁 写入缓冲区(临界区) V(mutex) V(full) P(full) 申请满槽 P(mutex) 加锁 读取缓冲区(临界区) V(mutex) V(empty) 共享缓冲区 大小 N = 5 empty 信号量:空槽计数 full 信号量:满槽计数 mutex 信号量:互斥访问 ⚠ 死锁风险:先申请资源信号量,再申请互斥信号量 顺序颠倒会导致所有进程互相等待,谁也动不了

避坑指南

信号量用起来不难,但坑不少。我把自己踩过的坑分享给你:

  • 忘记初始化:semget创建后,信号量的值是不确定的。必须用semctl SETVAL显式初始化。我曾经因为这个,程序跑起来时好时坏,查了两天才发现。
  • P/V操作不配对:每个P必须对应一个V,否则信号量值会慢慢耗尽或溢出。用SEM_UNDO标志可以缓解,但不能完全依赖它。
  • 死锁:多个信号量时,所有进程必须按同样的顺序申请。否则就会出现A等B、B等A的僵局。
  • 信号量泄漏:进程退出后,信号量不会自动删除。用ipcrm命令手动清理,或者在程序里用atexit注册清理函数。

调试小技巧:ipcs -s 查看系统中的信号量,用 ipcrm -s semid 删除。开发阶段我经常开着两个终端,一个跑程序,一个盯着信号量状态。

信号量这东西,说白了就是给多进程世界立规矩的。没有它,进程间通信就是丛林法则,谁抢到算谁的。有了它,大家才能有序地共享资源。你在项目中用多了就会发现,信号量其实没那么神秘,就是个带等待队列的计数器而已。


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