跳转至

信号量

约 4512 个字 743 行代码 预计阅读时间 24 分钟

核心概念

PV操作是什么?

PV 操作是信号量(Semaphore)的**核心原语**,由荷兰计算机科学家 Dijkstra 提出,是实现进程 / 线程同步、互斥的基础。

PV 是两个操作的缩写(来自荷兰语),对应信号量的「减」和「加」操作,核心目标是**控制对共享资源的访问**:

操作 英文 / 荷兰语 核心行为 通俗理解 对应 System V 信号量操作
P Proberen(尝试) 信号量值 sem_val -= 1 「获取」资源 / 锁 sem_op = -1
V Verhogen(增加) 信号量值 sem_val += 1 「释放」资源 / 锁 sem_op = 1

一、关键规则:

  1. P 操作(获取资源)

    • 若信号量当前值 sem_val > 0:执行 sem_val -= 1,操作立即完成,进程继续执行;

    • 若信号量当前值 sem_val == 0:进程 / 线程**阻塞**,直到其他进程执行 V 操作使 sem_val > 0

  2. V 操作(释放资源)

    • 执行 sem_val += 1,操作立即完成(无阻塞);

    • 若有进程因 P 操作阻塞,会唤醒其中一个阻塞进程,让其完成 P 操作。

二、PV 操作的典型场景(互斥 / 同步)

场景 1:互斥(控制同一资源只能被一个进程访问)

比如多个进程写同一个文件,用 PV 操作保证同一时间只有一个进程写入:

  • 初始化信号量值为 1(二元信号量,也叫「互斥锁」);
  • 进程写文件前执行 P 操作(获取锁,信号量值变为 0);
  • 进程写完文件后执行 V 操作(释放锁,信号量值变回 1);
  • 其他进程执行 P 操作时,若信号量为 0 则阻塞,直到锁被释放。

场景 2:同步(控制进程执行顺序)

比如进程 A 生成数据,进程 B 消费数据,需保证 A 先生产、B 后消费:

  • 初始化「空缓冲区」信号量 empty = N(N 为缓冲区大小),「满缓冲区」信号量 full = 0
  • 进程 A 生产:执行 P (empty)(空缓冲区 - 1)→ 写数据 → 执行 V (full)(满缓冲区 + 1);
  • 进程 B 消费:执行 P (full)(满缓冲区 - 1)→ 读数据 → 执行 V (empty)(空缓冲区 + 1);
  • full = 0,B 会阻塞,直到 A 生产数据后执行 V (full)。

三、总结

  • 原子性:PV 操作是「不可中断」的原子操作,由内核保证,不会出现「一半执行、一半中断」的情况;
  • 阻塞性:P 操作可能阻塞,V 操作永远不阻塞;
  • 通用性:既可以实现互斥(信号量初始值 = 1),也可以实现同步(信号量初始值 = 0/N)。
  • P 操作:信号量值减 1,本质是「获取资源 / 锁」,资源不足时阻塞;对应 System V 信号量的 sem_op = -1
  • V 操作:信号量值加 1,本质是「释放资源 / 锁」,永远立即执行;对应 System V 信号量的 sem_op = 1
  • 核心用途:PV 操作是实现进程 / 线程同步、互斥的基础,二元信号量(初始值 = 1)用于互斥,计数信号量(初始值 = N)用于控制资源数量。

信号量和互斥锁的关系?

简单来说:互斥锁(Mutex)是信号量(Semaphore)的一个特殊子集(二元信号量),信号量是互斥锁的通用扩展(计数信号量)。两者均用于解决多进程 / 多线程间的资源竞争问题,但适用场景、核心特性差异显著。

核心关系是:互斥锁 = 二元信号量(Binary Semaphore)

信号量的核心是一个**计数器 + P/V 操作**:

  • P 操作(wait):计数器 - 1,若计数器 < 0 则阻塞;
  • V 操作(post):计数器 + 1,若计数器≤0 则唤醒一个阻塞的进程 / 线程。

而互斥锁(Mutex)的本质是**计数器只能取 0 或 1 的信号量**:

  • 锁空闲时,计数器 = 1(资源可用);
  • 加锁(P 操作):计数器→0,其他线程 / 进程再加锁会阻塞;
  • 解锁(V 操作):计数器→1,唤醒一个阻塞的线程 / 进程。

几乎所有操作系统中,互斥锁都是基于二元信号量实现的 —— 互斥锁的 “加锁 / 解锁” 等价于二元信号量的 “P/V 操作”,只是封装了更贴合 “独占资源” 的语义(如所有权、递归加锁等)。

两者对比:

特性 信号量(Semaphore) 互斥锁(Mutex)
计数器取值 非负整数(0,1,2,...N) 仅 0/1(二元)
核心用途 控制**多个**共享资源的访问(如 10 个连接池) 保证**单个**资源的独占访问(如 1 个打印机)
所有权 无所有权(任意线程 / 进程可释放) 有所有权(仅加锁的线程 / 进程可解锁)
递归加锁 支持(多次 P 操作,需对应 V 操作) 部分支持(递归互斥锁),普通 Mutex 禁止递归加锁(会死锁)
适用场景 进程 / 线程间同步、有限资源池控制 进程 / 线程间互斥、临界区保护
典型示例 限制同时访问数据库的连接数(如最多 5 个) 保护单个文件的读写操作(同一时间仅 1 个线程写)

关键差异拆解

  1. “同步” vs “互斥”

    • 信号量:主打**同步**(让多个进程 / 线程按顺序执行),也可实现互斥;例:生产者生产数据后(V 操作),消费者才能消费(P 操作),控制 “生产 - 消费” 的顺序。

    • 互斥锁:主打**互斥**(保证同一时间只有一个执行者),无法实现复杂同步;例:多个线程写同一个文件,加互斥锁保证同一时间仅 1 个线程写,避免数据错乱。

  2. 所有权差异(最易踩坑)

    • 信号量:A 线程 P 操作获取信号量,B 线程可 V 操作释放 —— 无所有权限制,易引发逻辑错误;

    • 互斥锁:A 线程加锁,必须由 A 线程解锁(B 线程解锁会报错)—— 所有权保证更安全。

  3. 资源数量控制

    • 信号量:可设置计数器 = N,允许 N 个线程 / 进程同时访问资源(如 N=5,最多 5 个线程用连接池);

    • 互斥锁:仅允许 1 个线程 / 进程访问(计数器 = 1),本质是 “N=1 的信号量”。

System V信号量

关键函数

/* Get semaphore. 创建或获取一个信号量集合,返回semid  */
extern int semget (key_t __key, int __nsems, int __semflg) __THROW;

/* Semaphore control operation.
    SETVAL:设置信号量初值
    IPC_RMID:删除信号量集合
 */
extern int semctl (int __semid, int __semnum, int __cmd, ...) __THROW;

/* Operate on semaphore.操作信号量,例如增加、减小、判零  */
extern int semop (int __semid, struct sembuf *__sops, size_t __nsops) __THROW;

sem_op 字段指定了需执行的操作。

  • 如果 sem_op 大于 0,那么就将 sem_op 的值加到信号量值上,其结果是其他等待减小信号量值的进程可能会被唤醒并执行它们的操作。调用进程必须要具备在信号量上的修改(写)权限。
  • 如果 sem_op 等于 0,那么就对信号量值进行检查以确定它当前是否等于 0。如果等于0,那么操作将立即结束,否则 semop()就会阻塞直到信号量值变成 0 为止。调用进程必须要具备在信号量上的读权限。
  • 如果 sem_op 小于 0,那么就将信号量值减去 sem_op。如果信号量的当前值大于或等于 sem_op 的绝对值,那么操作会立即结束。否则 semop()会阻塞直到信号量值增长到在执行操作之后不会导致出现负值的情况为止。调用进程必须要具备在信号量上的修改权限。

简而言之:

  • 如果sem_op小于0,可能会阻塞,直到有其他信号量增长操作保证当前操作不为负数时唤醒;
  • 如果sem_op等于0,可能会阻塞,直到有其他信号量递减操作导致信号量为0时唤醒;
  • 如果sem_op大于0,自身不会阻塞,但有可能会唤醒其他sem_op小于0的操作;

示例代码

  • case1: 创建或获取有2个信号量的信号集,基于同一个key可以在不同进程对同一个semid进行操作;
  • case2: 如果sem_op等于0,那么就对信号量值进行检查以确定它当前是否等于 0,如果等于0,那么操作将立即结束,否则 semop()就会阻塞直到信号量值变成 0 为止;
  • case3: 如果sem_op小于 0,那么就将信号量值减去sem_op。如果信号量的当前值大于或等于sem_op的绝对值,那么操作会立即结束。否则 semop()会阻塞直到信号量值增长到在执行操作之后不会导致出现负值的情况为止;
  • case4: 如果sem_op大于 0,则不会出现阻塞;
  • case5: sem_flg字段中指定IPC_NOWAIT标记来防止 semop()阻塞。如果semop()本来要发生阻塞的话就会返回 EAGAIN 错误;
  • case6: semtimedop()系统调用与 semop()执行的任务一样,但它多了一个 timeout 参数,通过这个参数可以指定调用所阻塞的时间上限;
#include <stdio.h>
#include <string.h>
#include <sys/sem.h>
#include <sys/stat.h>
#include <stdlib.h>
#include <sys/unistd.h>
#include <sys/types.h>
#include <time.h>   // 必须包含(struct timespec)
#include "lib.h"

/**
 * case1: 创建或获取有2个信号量的信号集,基于同一个key可以在不同进程对同一个semid进行操作
 */
void system_v_sem_case1()
{
    int semid;
    char *pathname = "/home/ubuntu";
    int proj_id = 12345;
    key_t key = ftok(pathname, proj_id);

    for (int i = 0; i < 2; i++) {
        if (fork() !=0 ) {
            continue;
        }
        if (i == 0) {
            // 新建有2个信号量的信号集,如果已存在则报错
            semid = semget(key, 2, IPC_CREAT | IPC_EXCL | S_IRUSR | S_IWUSR);
            if (semid == -1) perror_exit("semget");
            printf("pid=%d, i=%d, create semaphore id=%d\n", getpid(), i, semid);

            if (semctl(semid, 0, SETVAL, 111) == -1) perror_exit("semctl SETVAL");
            if (semctl(semid, 1, SETVAL, 222) == -1) perror_exit("semctl SETVAL");

            printf("pid=%d, i=%d, val 0 is %d, val 1 is %d\n", getpid(), i ,semctl(semid, 0, GETVAL), semctl(semid, 1, GETVAL));
        }

        if (i == 1) {
            sleep(3);
            semid = semget(key, 2, S_IRUSR | S_IWUSR);
            if (semid == -1) perror_exit("semget");

            printf("pid=%d, i=%d, get exist semaphore id=%d\n", getpid(), i, semid);
            printf("pid=%d, i=%d, val 0 is %d, val 1 is %d\n", getpid(), i ,semctl(semid, 0, GETVAL), semctl(semid, 1, GETVAL));

            // 删除信号集
            if (semctl(semid, 0, IPC_RMID) == -1) perror_exit("semctl IPC_RMID");
        }

        exit(EXIT_SUCCESS);
    }

    while (wait(NULL) != -1) {}

    PRINT_SUCCESSFUL;
}

/**
 * case2: 如果sem_op等于0,那么就对信号量值进行检查以确定它当前是否等于 0
 * 如果等于0,那么操作将立即结束,否则 semop()就会阻塞直到信号量值变成 0 为止
 */
void system_v_sem_case2()
{
    char *pathname = "/home/ubuntu";
    int proj_id = 12345;
    key_t key = ftok(pathname, proj_id);
    int semid = semget(key, 1, IPC_CREAT | IPC_EXCL | S_IRUSR | S_IWUSR);
    // 新建有1个信号量的信号集,如果已存在则报错
    if (semid == -1) perror_exit("semget");

    // 信号量初始化为1
    if (semctl(semid, 0, SETVAL, 1) == -1) perror_exit("semctl SETVAL");

    for (int i = 0; i < 2; i++) {
        if (fork() !=0 ) {
            continue;
        }
        // 可以定义成数组,此处只涉及一个信号量
        struct sembuf sop = {};
        sop.sem_num = 0; // 信号量在信号集中的索引
        sop.sem_op = 1; // 对信号量的操作
        sop.sem_flg = 0;

        semid = semget(key, 1, S_IRUSR | S_IWUSR);

        if (i == 0) {
            /**
             * 如果sem_op等于 0,那么就对信号量值进行检查以确定它当前是否等于 0。
             * 如果等于0,那么操作将立即结束,否则 semop()就会阻塞直到信号量值变成 0 为止。
             */
            sop.sem_op = 0;
            // 由于信号量初始值为1,因此会阻塞到信号量为0为止
            if (semop(semid, &sop, 1) == -1) perror_exit("semop");
            printf("pid=%d, i=%d, val is %d\n", getpid(), i ,semctl(semid, 0, GETVAL));
        }

        if (i == 1) {
            // idx=1的进程虽然sleep 3s,但是会先于idx=0打印
            sleep(3);

            sop.sem_op = -1;
            // 由于信号量初始值为1,此处会立即执行
            if (semop(semid, &sop, 1) == -1) perror_exit("semop");
            printf("pid=%d, i=%d, val is %d\n", getpid(), i ,semctl(semid, 0, GETVAL));
        }
        exit(EXIT_SUCCESS);
    }

    while (wait(NULL) != -1) {}

    // 删除信号集
    if (semctl(semid, 0, IPC_RMID) == -1) perror_exit("semctl IPC_RMID");

    PRINT_SUCCESSFUL;
}

/**
 * case3: 如果sem_op小于 0,那么就将信号量值减去sem_op。
 * 如果信号量的当前值大于或等于sem_op的绝对值,那么操作会立即结束。
 * 否则 semop()会阻塞直到信号量值增长到在执行操作之后不会导致出现负值的情况为止
 */
void system_v_sem_case3()
{
    char *pathname = "/home/ubuntu";
    int proj_id = 12345;
    key_t key = ftok(pathname, proj_id);
    int semid = semget(key, 1, IPC_CREAT | IPC_EXCL | S_IRUSR | S_IWUSR);
    // 新建有1个信号量的信号集,如果已存在则报错
    if (semid == -1) perror_exit("semget");

    // 信号量初始化为0
    if (semctl(semid, 0, SETVAL, 0) == -1) perror_exit("semctl SETVAL");

    for (int i = 0; i < 2; i++) {
        if (fork() !=0 ) {
            continue;
        }
        // 可以定义成数组,此处只涉及一个信号量
        struct sembuf sop = {};
        sop.sem_num = 0; // 信号量在信号集中的索引
        sop.sem_op = 1; // 对信号量的操作
        sop.sem_flg = 0;

        semid = semget(key, 1, S_IRUSR | S_IWUSR);

        if (i == 0) {
            /**
             * 如果sem_op小于 0,那么就将信号量值减去sem_op。
             * 如果信号量的当前值大于或等于sem_op的绝对值,那么操作会立即结束。
             * 否则 semop()会阻塞直到信号量值增长到在执行操作之后不会导致出现负值的情况为止
             */
            sop.sem_op = -1;
            // 由于信号量初始值为0,因此会阻塞到信号量增长到不会出现负值为止
            if (semop(semid, &sop, 1) == -1) perror_exit("semop");
            printf("pid=%d, i=%d, val is %d\n", getpid(), i ,semctl(semid, 0, GETVAL));
        }

        if (i == 1) {
            // idx=1的进程虽然sleep 3s,但是会先于idx=0打印
            sleep(3);
            sop.sem_op = 1;
            // sem_op大于零,会立即执行
            if (semop(semid, &sop, 1) == -1) perror_exit("semop");
            printf("pid=%d, i=%d, val is %d\n", getpid(), i ,semctl(semid, 0, GETVAL));
        }
        exit(EXIT_SUCCESS);
    }

    while (wait(NULL) != -1) {}

    // 删除信号集
    if (semctl(semid, 0, IPC_RMID) == -1) perror_exit("semctl IPC_RMID");

    PRINT_SUCCESSFUL;
}


/**
 * case4: 如果sem_op大于 0,则不会出现阻塞
 */
void system_v_sem_case4()
{
    char *pathname = "/home/ubuntu";
    int proj_id = 12345;
    key_t key = ftok(pathname, proj_id);
    int semid = semget(key, 1, IPC_CREAT | IPC_EXCL | S_IRUSR | S_IWUSR);
    // 新建有1个信号量的信号集,如果已存在则报错
    if (semid == -1) perror_exit("semget");

    // 信号量初始化为0
    if (semctl(semid, 0, SETVAL, 0) == -1) perror_exit("semctl SETVAL");

    for (int i = 0; i < 10; i++) {
        if (fork() !=0 ) {
            continue;
        }
        // 可以定义成数组,此处只涉及一个信号量
        struct sembuf sop = {};
        sop.sem_num = 0; // 信号量在信号集中的索引
        sop.sem_op = 1; // 对信号量的操作
        sop.sem_flg = 0;

        semid = semget(key, 1, S_IRUSR | S_IWUSR);

        // 信号量增长场景,不会出现阻塞
        if (semop(semid, &sop, 1) == -1) perror_exit("semop");
        printf("pid=%d, i=%d, val is %d\n", getpid(), i ,semctl(semid, 0, GETVAL));

        exit(EXIT_SUCCESS);
    }

    while (wait(NULL) != -1) {}

    // 删除信号集
    if (semctl(semid, 0, IPC_RMID) == -1) perror_exit("semctl IPC_RMID");

    PRINT_SUCCESSFUL;
}

/**
 * case5: sem_flg字段中指定IPC_NOWAIT标记来防止 semop()阻塞。如果semop()本来要发生阻塞的话就会返回 EAGAIN 错误
 */
void system_v_sem_case5()
{
    char *pathname = "/home/ubuntu";
    int proj_id = 12345;
    key_t key = ftok(pathname, proj_id);
    int semid = semget(key, 1, IPC_CREAT | IPC_EXCL | S_IRUSR | S_IWUSR);
    // 新建有1个信号量的信号集,如果已存在则报错
    if (semid == -1) perror_exit("semget");

    // 信号量初始化为0
    if (semctl(semid, 0, SETVAL, 0) == -1) perror_exit("semctl SETVAL");

    if (fork() ==0 ) {

        struct sembuf sop = {};
        sop.sem_num = 0;
        sop.sem_op = -1;
        sop.sem_flg = IPC_NOWAIT;

        /**
         * 如果sem_op小于 0,那么就将信号量值减去sem_op。
         */
        if (semop(semid, &sop, 1) == -1) perror_exit("semop");
        printf("pid=%d, val is %d\n", getpid(),semctl(semid, 0, GETVAL));

    }

    wait(NULL);

    // 删除信号集
    if (semctl(semid, 0, IPC_RMID) == -1) perror_exit("semctl IPC_RMID");

    PRINT_SUCCESSFUL;
}


/**
 * case6: semtimedop()系统调用与 semop()执行的任务一样,但它多了一个 timeout 参数,通过这个参数可以指定调用所阻塞的时间上限
 */
void system_v_sem_case6()
{
    char *pathname = "/home/ubuntu";
    int proj_id = 12345;
    key_t key = ftok(pathname, proj_id);
    int semid = semget(key, 1, IPC_CREAT | IPC_EXCL | S_IRUSR | S_IWUSR);
    // 新建有1个信号量的信号集,如果已存在则报错
    if (semid == -1) perror_exit("semget");

    // 信号量初始化为1
    if (semctl(semid, 0, SETVAL, 1) == -1) perror_exit("semctl SETVAL");

    for (int i = 0; i < 4; i++) {
        if (fork() !=0 ) {
            continue;
        }
        struct sembuf sop = {};
        sop.sem_num = 0;
        sop.sem_op = 1;
        sop.sem_flg = 0;

        semid = semget(key, 1, S_IRUSR | S_IWUSR);

        if (i == 0) {
            // 由于信号量初始值为1,因此会阻塞到信号量为0为止
            sop.sem_op = 0;
            if (semop(semid, &sop, 1) == -1) perror_exit("semop");
            printf("pid=%d, i=%d, val is %d\n", getpid(), i ,semctl(semid, 0, GETVAL));
        }

        if (i == 1) {
            struct timespec timeout = {
                .tv_sec = 1,
                .tv_nsec = 0
            };
            // 由于信号量初始值为1,因此会1s内阻塞到信号量为0为止,否则失败
            sop.sem_op = 0;
            if (semtimedop(semid, &sop, 1, &timeout) == -1) perror_exit("semtimedop");
            printf("pid=%d, i=%d, val is %d\n", getpid(), i ,semctl(semid, 0, GETVAL));
        }

        if (i == 2) {
            struct timespec timeout = {
                .tv_sec = 5,
                .tv_nsec = 0
            };
            // 由于信号量初始值为1,因此5s内会阻塞到信号量为0为止
            sop.sem_op = 0;
            if (semtimedop(semid, &sop, 1, &timeout) == -1) perror_exit("semtimedop");
            printf("pid=%d, i=%d, val is %d\n", getpid(), i ,semctl(semid, 0, GETVAL));
        }

        if (i == 3) {
            sleep(3);
            // 由于信号量初始值为1,此处会立即执行
            sop.sem_op = -1;
            if (semop(semid, &sop, 1) == -1) perror_exit("semop");
            printf("pid=%d, i=%d, val is %d\n", getpid(), i ,semctl(semid, 0, GETVAL));
        }
        exit(EXIT_SUCCESS);
    }

    while (wait(NULL) != -1) {}

    // 删除信号集
    if (semctl(semid, 0, IPC_RMID) == -1) perror_exit("semctl IPC_RMID");

    PRINT_SUCCESSFUL;
}

int main(int argc, char *args[])
{
    system_v_sem_case1();
    system_v_sem_case2();
    system_v_sem_case3();
    system_v_sem_case4();
    system_v_sem_case5();
    system_v_sem_case6();
}

结果输出:

pid=9582, i=0, create semaphore id=48
pid=9582, i=0, val 0 is 111, val 1 is 222
pid=9583, i=1, get exist semaphore id=48
pid=9583, i=1, val 0 is 111, val 1 is 222
[system_v_sem_case1] successful...
--------------------------------
pid=9620, i=1, val is 0
pid=9619, i=0, val is 0
[system_v_sem_case2] successful...
--------------------------------
pid=9635, i=1, val is 0
pid=9634, i=0, val is 0
[system_v_sem_case3] successful...
--------------------------------
pid=9663, i=0, val is 1
pid=9664, i=1, val is 2
pid=9665, i=2, val is 3
pid=9666, i=3, val is 4
pid=9667, i=4, val is 5
pid=9668, i=5, val is 6
pid=9669, i=6, val is 7
pid=9670, i=7, val is 8
pid=9671, i=8, val is 9
pid=9672, i=9, val is 10
[system_v_sem_case4] successful...
--------------------------------
semop: Resource temporarily unavailable
[system_v_sem_case5] successful...
--------------------------------
semtimedop: Resource temporarily unavailable
pid=9677, i=3, val is 0
pid=9674, i=0, val is 0
pid=9676, i=2, val is 0
[system_v_sem_case6] successful...
--------------------------------

实现互斥锁

基于System V信号量,实现互斥锁,设计要点:

  • 信号量初始值为1;
  • 加锁时通过P操作减1,减操作可能会产生竞争阻塞;
  • 解锁时通过V操作加1,加操作不会阻塞,会触发唤醒阻塞的进程;
#include <stdio.h>
#include <string.h>
#include <sys/sem.h>
#include <sys/stat.h>
#include <stdlib.h>
#include <sys/unistd.h>
#include <sys/types.h>
#include "lib.h"

/**
 * 创建或获取semid
 */
int create_mtx_lock() {
    char *pathname = "/home/ubuntu";
    int proj_id = 12345;
    key_t key = ftok(pathname, proj_id);

    int semid =  semget(key, 1, IPC_CREAT| S_IRUSR | S_IWUSR);
    // 初始为1
    semctl(semid, 0, SETVAL, 1);
    return semid;
}

/**
 * 获取锁,P操作对信号量减1,可能会阻塞
 */
int mtx_lock(int semid) {
    struct sembuf sem_op = {
        .sem_num = 0,   // 操作第0个信号量
        .sem_op = -1,   // P操作:减1
        .sem_flg = 0    // 阻塞等待(不设置 IPC_NOWAIT)
    };
    return semop(semid, &sem_op, 1);
}

/**
 * 释放锁,V操作对信号量加1,不会阻塞
 */
int mtx_unlock(int semid) {
    struct sembuf sem_op = {
        .sem_num = 0,   // 操作第0个信号量
        .sem_op = 1,   //  V操作:加1
        .sem_flg = 0    // 不会阻塞
    };
    return semop(semid, &sem_op, 1);
}

/**
 * 删除锁,释放信号量集合资源
 */
int release_mtx_lock(int semid) {
    return semctl(semid, 0, IPC_RMID);
}

int main() {
    int semid = create_mtx_lock();

    for (int i = 0; i < 3; i++) {
        if (fork() != 0) {
            continue;
        }
        printf("i=%d, pid=%d try to lock\n", i, getpid());
        // 加锁
        mtx_lock(semid);

        // 执行临界区代码
        sleep(1);
        printf("i=%d, pid=%d do something\n", i, getpid());

        // 释放锁
        printf("i=%d, pid=%d unlock\n", i, getpid());
        mtx_unlock(semid);

        exit(EXIT_SUCCESS);
    }

    while (wait(NULL) != -1) {}

    release_mtx_lock(semid);
}

运行结果:

i=0, pid=9916 try to lock
i=1, pid=9917 try to lock
i=2, pid=9918 try to lock
i=0, pid=9916 do something
i=0, pid=9916 unlock
i=1, pid=9917 do something
i=1, pid=9917 unlock
i=2, pid=9918 do something
i=2, pid=9918 unlock

总结和缺点

System V 信号量允许进程同步它们的动作。这在当一个进程必须要获取对某些共享资源(如一块共享内存区域)的互斥性访问时是比较有用的。

信号量的创建和操作是以集合为单位的,一个集合包含一个或多个信号量。集合中的每个信号量都是一个整数,其值永远大于或等于 0。semop()系统调用允许调用者在一个信号量上加上一个整数、从一个信号量中减去一个整数、或等待一个信号量等于 0。后两个操作可能会导致调用者阻塞。

信号量实现无需对一个新信号量集中的成员进行初始化,因此应用程序就必须要在创建完之后对它们进行初始化。当一些地位平等的进程中任意一个进程试图创建和初始化信号量时就需要特别小心以防止因这两个步骤是通过单独的系统调用来完成的而可能出现的竞争条件。

如果多个进程对该信号量减去的值是一样的,那么当条件允许时到底哪个进程会首先被允许执行操作是不确定的。但如果多个进程对信号量减去的值是不同的,那么会按照先满足条件先服务的顺序来进行并且需要小心避免出现一个进程因信号量永远无法达到允许进程操作继续往前执行的值而饿死的情况。

System V 信号量的分配和操作是以集合为单位的,并且对其增加和减小的数量可以是任意的。它们提供的功能要多于大多数应用程序所需的功能。对信号量常见的要求是单个二元信号量,它的取值只能是0和1。

System V 信号量存在的很多缺点:

  • 信号量是通过标识符而不是大多数 UNIX I/O 和 IPC 所采用的文件描述符来引用的。这使得执行诸如同时等待一个信号量和文件描述符的输入之类的操作就会变得比较困难。(通过创建一个子进程或线程来操作这个信号量)
  • 使用键而不是文件名来标识信号量增加了额外的编程复杂度。
  • 创建和初始化信号量需要使用单独的系统调用意味着在一些情况下必须要做一些额外的编程工作来防止在初始化一个信号量时出现竞争条件。
  • 内核不会维护引用一个信号量集的进程数量。这就给确定何时删除一个信号量集增加了难度并且难以确保一个不再使用的信号量集会被删除。
  • System V 提供的编程接口过于复杂。在通常情况下,一个程序只会操作一个信号量。同时操作集合中多个信号量的能力有时侯是多余的。
  • 信号量的操作存在诸多限制。这些限制是可配置的,但如果一个应用程序超出了默认限制的范围,那么在安装应用程序时就需要完成额外的工作了。

POSIX信号量

SUSv3 规定了两种类型的 POSIX 信号量。

  • 命名信号量:这种信号量拥有一个名字。通过使用相同的名字调用 sem_open(),不相关的进程能够访问同一个信号量。
  • 未命名信号量:这种信号量没有名字,相反,它位于内存中一个预先商定的位置处。未命名信号量可以在进程之间或一组线程之间共享。当在进程之间共享时,信号量必须位于一个共享内存区域中(System V、POSIX 或 mmap())。当在线程之间共享时,信号量可以位于被这些线程共享的一块内存区域中(如在堆上或在一个全局变量中)。

    • 未命名信号量要求自身内存能被多进程或多线程共享可见:未命名信号量(也被称为基于内存的信号量)是类型为 sem_t 并存储在应用程序分配的内存中的变量。通过将这个信号量放在由几个进程或线程共性的内存区域中就能够使这个信号量对这些进程或线程可用。

    • 未命名信号量还需使用另外两个函数(命名信号量使用sem_open)

      • sem_init()函数对一个信号量进行初始化并通知系统该信号量会在进程间共享还是在单个进程中的线程间共享。

      • sem_destroy(sem)函数销毁一个信号量。

POSIX 信号量的运作方式与 System V 信号量类似,即 POSIX 信号量是一个整数,其值是不能小于 0 的。如果一个进程试图将一个信号量的值减小到小于 0,那么取决于所使用的函数,调用会阻塞或返回一个表明当前无法执行相应操作的错误。

与 System V 信号量一样,一个 POSIX 信号量也是一个整数并且系统不会允许其值小于 0。但 POSIX 信号量的操作不同于 System V 信号量的操作,具体包括:

  • 修改信号量值的函数———sem_post()和 sem_wait()——一次只操作一个信号量。与之形成对比的是,System V semop()系统调用能够操作一个集合中的多个信号量。
  • sem_post()和 sem_wait()函数只对信号量值加 1 和减 1。与之形成对比的是,semop()能够加上和减去任意一个值。
  • System V信号量并没有提供一个 wait-for-zero 的操作(将 sops.sem_op 字段指定为0的 semop()调用)。

关键函数

/* Open a named semaphore NAME with open flags OFLAG.
用于命名信号量,基于给定字符串name,创建或获取已有的posix信号量
*/
extern sem_t *sem_open (const char *__name, int __oflag, ...)

/* Wait for SEM being posted.This function is a cancellation point and therefore not marked with __THROW. 
信号量P操作,会对信号量减1,如果信号量的当前值大于 0,那么 sem_wait()会立即返回。如果信号量的当前值等于 0,那么 sem_wait()会阻塞直到信号量的值大于 0 为止,当信号量值大于 0 时该信号量值就被递减并且 sem_wait()会返回。
*/

extern int sem_wait(sem_t *__sem) __nonnull ((1));

/* Post SEM.
信号量V操作,会对信号量加1,不会阻塞当前进程,并且其他某个进程(或线程)正在因等待递减这个信号量而阻塞,那么该进程会被唤醒。
*/
extern int sem_post (sem_t *__sem) __THROWNL __nonnull ((1));

/* Close descriptor for named semaphore SEM.
删除调用进程与它之前打开的一个信号量之间的关联关系
*/
extern int sem_close (sem_t *__sem) __THROW __nonnull ((1));

/* Remove named semaphore NAME.
删除一个信号量名字并将其标记为在所有进程关闭该信号量时删除该信号量
*/
extern int sem_unlink (const char *__name) __THROW __nonnull ((1));

示例代码

  • case1:命名信号量,不同进程基于相同name可以获取同一个posix命名信号量
  • case2:命名信号量,POSIX 信号量也是一个整数并且系统不会允许其值小于0,否则会阻塞
  • case3:未命名信号量,pshared配置为0,构造线程间共享信号量解决进程内多线程并发问题
  • case4:未命名信号量,pshared配置为1,基于共享内存构造进程间共享信号量解决多进程并发问题
  • case5:未命名信号量,pshared配置为1,如果sem_t不是共享内存,fork机制也无法直接用该信号量用于控制,因为sem_t在PV操作后会触发COW,并非是同一个信号量
#include <stdio.h>
#include <semaphore.h>
#include <fcntl.h>   // O_CREAT/O_EXCL 定义
#include <stdlib.h>
#include <pthread.h>
#include <sys/mman.h>
#include "lib.h"

static char posix_sem_name[] = "/sem_demo";

/**
 * case1:命名信号量,不同进程基于相同name可以获取同一个posix命名信号量
 */
void posix_sem_case1()
{
    for (int i = 0; i < 3; i++) {
        if (fork() != 0) {
            continue;
        }

        // 除索引为1以外的进程均等待1s
        if (i != 1) {
            sleep(1);
        }

        // 创建或获取信号量,不同进程给的初始值不同,为i
        sem_t *sem = sem_open(posix_sem_name, O_CREAT, 0666, i + 123);

        int val = 0;
        if (sem_getvalue(sem, &val) == -1) perror_exit("sem_getvalue");

        // 预期不同进程虽然sem_open给不同初值,但实则只有第一个创建并设置成功,其他进程均为创建时的val
        printf("i=%d, pid=%d, sem val is %d\n", i, getpid(), val);

        if (sem_close(sem) == -1) perror_exit("sem_close"); 

        exit(EXIT_SUCCESS);
    }

    while (wait(NULL) != -1) {}

    if (sem_unlink(posix_sem_name) == -1) perror_exit("sem_unlink");

    PRINT_SUCCESSFUL;
}

/**
 * case2:命名信号量,POSIX 信号量也是一个整数并且系统不会允许其值小于0,否则会阻塞
 */
void posix_sem_case2()
{

    for (int i = 0; i < 3; i++) {
        if (fork() != 0) {
            continue;
        }
        // 创建信号量,初始值为1
        sem_t *sem = sem_open(posix_sem_name, O_CREAT, 0666, 1);

        // P操作,信号量减1
        printf("i=%d, pid=%d, sem wait\n", i, getpid());
        if (sem_wait(sem) == -1) perror_exit("sem_wait");

        // 临界区代码,通过sleep观察阻塞效果
        sleep(1);
        int val = 0;
        if (sem_getvalue(sem, &val) == -1) perror_exit("sem_getvalue");
        printf("i=%d, pid=%d, sem val is %d\n", i, getpid(), val);

        // V操作,信号量加1
        printf("i=%d, pid=%d, sem post\n", i, getpid());
        if (sem_post(sem) == -1) perror_exit("sem_wait");

        if (sem_close(sem) == -1) perror_exit("sem_close"); 

        exit(EXIT_SUCCESS);
    }

    while (wait(NULL) != -1) {}

    if (sem_unlink(posix_sem_name) == -1) perror_exit("sem_unlink");

    PRINT_SUCCESSFUL;
}

static void thread_func_3(void *arg)
{
    static int cnt = 0;
    sem_t *sem = (sem_t *)arg;

    // P操作,信号量减1
    printf("tid=%d, pid=%d cnt=%d, sem wait\n", pthread_self(), getpid(), cnt);
    if (sem_wait(sem) == -1) perror_exit("sem_wait");

    // 临界区代码,通过sleep观察阻塞效果
    sleep(1);
    cnt++;
    int val = 0;
    if (sem_getvalue(sem, &val) == -1) perror_exit("sem_getvalue");
    printf("tid=%d, pid=%d, cnt=%d, sem val is %d\n", pthread_self(), getpid(), cnt, val);

    // V操作,信号量加1
    printf("tid=%d, pid=%d, sem post\n", pthread_self(), getpid());
    if (sem_post(sem) == -1) perror_exit("sem_wait");
}

/**
 * case3:未命名信号量,pshared配置为0,构造线程间共享信号量解决进程内多线程并发问题
 */
void posix_sem_case3()
{
    static sem_t sem;
    /**
     * 如果 pshared 等于 0,那么信号量将会在调用进程中的线程间进行共享。
     * 在这种情况下,sem 通常被指定成一个全局变量的地址或分配在堆上的一个变量的地址。
     * 线程共享的信号量具备进程持久性,它在进程终止时会被销毁。
     */
    if (sem_init(&sem, 0, 1) == -1) perror_exit("sem_init");

    pthread_t ts[10];
    for (int i = 0; i < 5; i++) {
        pthread_create(&ts[i], NULL, thread_func_3, &sem);
    }

    for (int i = 0; i < 5; i++) {
        pthread_join(ts[i], NULL);
    }

    if (sem_destroy(&sem) == -1) perror_exit("sem_destroy"); 
    PRINT_SUCCESSFUL;
}

/**
 * case4:未命名信号量,pshared配置为1,基于共享内存构造进程间共享信号量解决多进程并发问题
 */
void posix_sem_case4()
{
    // 采用共享匿名映射生成一块未命名信号量内存
    sem_t *sem = mmap(NULL, sizeof(sem_t), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
    if (sem == MAP_FAILED) perror_exit("mmap");

    /**
     * 如果 pshared 不等于 0,那么信号量将会在进程间共享。
     * 在这种情况下,sem 必须是共享内存区域(一个 POSIX 共享内存对象、一个使用 mmap()创建的共享映射、或一个System V 共享内存段)中的某个位置的地址。
     * 信号量的持久性与它所处的共享内存的持久性是一样的。
     */
    if (sem_init(sem, 1, 1) == -1) perror_exit("sem_init");

    if (fork() == 0) {
        // 子进程PV操作,子进程抢锁后多等待3s,预期比父进程先输出打印内容
        if (sem_wait(sem) == -1) perror_exit("sem_wait");
        sleep(3);
        printf("this is child process\n");
        if (sem_post(sem) == -1) perror_exit("sem_post");
        exit(EXIT_SUCCESS);
    }

    // 父进程PV操作,父进程先sleep让子进程先抢到锁
    sleep(1);
    if (sem_wait(sem) == -1) perror_exit("sem_wait");
    printf("this is parent process\n");
    if (sem_post(sem) == -1) perror_exit("sem_post");

    wait(NULL);
    if (sem_destroy(sem) == -1) perror_exit("sem_destroy"); 
    PRINT_SUCCESSFUL;
}

/**
 * case5:未命名信号量,pshared配置为1,如果sem_t不是共享内存,fork机制也无法直接用该信号量用于控制,因为sem_t在PV操作后会触发COW,并非是同一个信号量
 */
void posix_sem_case5()
{
    static sem_t sem;
    int val = 0;
    /**
     * 如下虽然pshared配置成1,当子进程PV操作时,继承的sem会触发COW,与父进程则不再共享同一块内存
     * 因此在各自sem_post后,val计数是各自计数,结果均为11
     */
    if (sem_init(&sem, 1, 10) == -1) perror_exit("sem_init");

    if (fork() == 0) {
        // 子进程V操作
        if (sem_post(&sem) == -1) perror_exit("sem_post");
        if (sem_getvalue(&sem, &val) == -1) perror_exit("sem_getvalue");
        printf("this is child process, val=%d\n", val);

        exit(EXIT_SUCCESS);
    }

    // 父进程V操作
    if (sem_post(&sem) == -1) perror_exit("sem_post");
    if (sem_getvalue(&sem, &val) == -1) perror_exit("sem_getvalue");
    printf("this is parent process, val=%d\n", val);

    wait(NULL);
    if (sem_destroy(&sem) == -1) perror_exit("sem_destroy"); 
    PRINT_SUCCESSFUL;
}

int main(int argc, char *args[])
{
    posix_sem_case1();
    posix_sem_case2();
    posix_sem_case3();
    posix_sem_case4();
}

结果输出:

i=1, pid=15983, sem val is 124
i=0, pid=15982, sem val is 124
i=2, pid=15984, sem val is 124
[posix_sem_case1] successful...
--------------------------------
i=0, pid=16003, sem wait
i=1, pid=16004, sem wait
i=2, pid=16005, sem wait
i=0, pid=16003, sem val is 0
i=0, pid=16003, sem post
i=1, pid=16004, sem val is 0
i=1, pid=16004, sem post
i=2, pid=16005, sem val is 0
i=2, pid=16005, sem post
[posix_sem_case2] successful...
--------------------------------
tid=-136694080, pid=15978 cnt=0, sem wait
tid=-145086784, pid=15978 cnt=0, sem wait
tid=-153479488, pid=15978 cnt=0, sem wait
tid=-161872192, pid=15978 cnt=0, sem wait
tid=-170264896, pid=15978 cnt=0, sem wait
tid=-136694080, pid=15978, cnt=1, sem val is 0
tid=-136694080, pid=15978, sem post
tid=-145086784, pid=15978, cnt=2, sem val is 0
tid=-145086784, pid=15978, sem post
tid=-153479488, pid=15978, cnt=3, sem val is 0
tid=-153479488, pid=15978, sem post
tid=-161872192, pid=15978, cnt=4, sem val is 0
tid=-161872192, pid=15978, sem post
tid=-170264896, pid=15978, cnt=5, sem val is 0
tid=-170264896, pid=15978, sem post
[posix_sem_case3] successful...
--------------------------------
this is child process
this is parent process
[posix_sem_case4] successful...
--------------------------------

信号量 vs Pthreads互斥体

POSIX 信号量与 Pthreads 互斥体对比?

POSIX 信号量和 Pthreads 互斥体都可以用来同步同一个进程中的线程的动作,并且它们的性能也是相近的。然而互斥体通常是首选方法,因为互斥体的所有权属性能够确保代码具有良好的结构性(只有锁住互斥体的线程才能够对其进行解锁)。与之形成对比的是,一个线程能够递增一个被另一个线程递减的信号量。这种灵活性会导致产生结构糟糕的同步设计。(正是因为这个原因,信号量有时候会被称为并发式编程中的“goto”。)

todo,代码示例????

总结

POSIX 信号量允许进程或线程同步它们的动作。POSIX 信号量有两种:命名的和未命名的。命名信号量是通过一个名字标识的,它可以被所有拥有打开这个信号量的权限的进程共享。未命名信号量没有名字,但可以将它放在一块由进程或线程共享的内存区域中,使得这些进程或线程能够共享同一个信号量(如放在一个 POSIX 共享内存对象中以供进程共享,或放在一个全局变量中以供线程共享)。

POSIX 信号量接口比 System V 信号量接口简单。信号量的分配和操作是一个一个进行的,并且等待和发布操作只会将信号量值调整 1。

与 System V 信号量相比,POSIX 信号量具备很多优势,但它们的可移植性要稍差一点。对于多线程应用程序中的同步来讲,互斥体一般来讲要优于信号量。