Linux进程通信---4---信号量System V & POSIX

一、信号量的核心本质与设计初衷

先通过通俗比喻理解:信号量就像停车场的车位计数器 ------ 初始值是总车位数(比如 10),每开进一辆车(P 操作)计数器减 1,每开出一辆车(V 操作)计数器加 1;当计数器为 0 时,新的车辆必须等待(进程阻塞),直到有车开出(其他进程执行 V 操作)。

1. 官方定义

信号量是 Linux 内核维护的原子性计数器 + 等待队列的组合体,核心作用是实现多个进程 / 线程之间的:

  • 同步:控制进程 / 线程的执行顺序(比如 A 必须等 B 完成后再执行);
  • 互斥:保护共享资源(如共享内存、打印机)不被并发修改。

2. 核心原理 ------P/V 原语(原子操作)

信号量的所有逻辑都基于两个原子操作(不可被中断,避免竞态条件):

操作 英文名 逻辑 场景
P 操作 Wait/Pend sem -= 1,若结果 < 0,当前进程 / 线程阻塞并加入等待队列;若 ≥0,继续执行 获取资源
V 操作 Post/Release sem += 1,若结果 ≤0,唤醒等待队列中的一个进程 / 线程; 若 >0,无唤醒 释放资源

原子性的重要性:如果 P/V 操作可被中断,多个进程同时修改计数器会导致错误(比如两个进程同时 P 操作,计数器本该减 2 却只减 1)。Linux 内核通过自旋锁(spinlock)保证 P/V 操作的原子性。

二、Linux 中信号量的两大类型

Linux 提供两种主流实现,核心逻辑一致,但 API 和适用场景差异显著:

什么是 System V 信号量?

System V 信号量是 Linux/Unix 内核提供的进程间通信(IPC)机制 ,属于 System V IPC 家族(另外两个是共享内存、消息队列),核心作用是实现多进程间的同步与互斥,保护「临界资源」(如共享内存、打印机、标准输出)不被多个进程同时访问,避免数据竞争或操作混乱。

特性 System V 信号量 POSIX 信号量(无名 / 有名)
操作单位 信号量集(多个信号量组合) 单个信号量
标识方式 IPC 键(key_t)+ 信号量集 ID 无名(进程内)/ 文件名(跨进程)
生命周期 随内核(需手动删除) 无名:随进程;有名:随文件系统
核心优势 支持批量信号量操作、内核级持久化 轻量、接口简单、支持线程 / 进程
典型场景 多进程复杂同步(如共享内存) 简单互斥 / 同步(线程 / 进程)

规则

信号量的本质是一个整数计数器,围绕「P 操作(申请资源)」和「V 操作(释放资源)」展开,核心规则:

  • 信号量值 > 0:表示有 n 个可用资源,P 操作(减 1)后仍有剩余;
  • 信号量值 = 0:无可用资源,P 操作会阻塞,直到其他进程执行 V 操作;
  • 信号量值 < 0:(仅内核维护)表示有 -n 个进程正在阻塞等待该信号量;
  • V 操作(加 1):释放资源,若有进程阻塞则唤醒其中一个。

根据值的范围,信号量分为两类:

  • 二值信号量:值仅为 0 或 1,等价于「互斥锁」(你之前代码中用的就是这种,保护 printf 输出);
  • 计数信号量:值可大于 1,用于控制同时访问资源的进程数(如最多 3 个进程访问数据库连接池)。

System V 信号量的核心概念

在使用 System V 信号量前,必须理解以下 5 个核心概念:

1. IPC 键(key_t)

  • 作用:唯一标识内核中的 IPC 对象(信号量集 / 共享内存 / 消息队列),让不同进程能找到同一个信号量集;
  • 生成方式:通过 ftok() 函数将「文件路径 + 项目 ID」转换为唯一的 key_t 类型值;
  • 注意:路径必须是存在且可访问的(如当前目录 "."),项目 ID 通常用 0-255 的整数;若路径被删除重建,即使参数相同,生成的 key 也会变化。

2. 信号量集(semaphore set)

System V 信号量的最小操作单位,不是单个信号量 ------ 一个信号量集可以包含多个独立的信号量,每个信号量有自己的「下标」(从 0 开始)。

  • 比如你之前的代码中,创建了「包含 1 个信号量的集合」,下标为 0;
  • 若需同时控制多个资源(如读、写锁),可创建包含 2 个信号量的集合,下标 0 对应读锁,下标 1 对应写锁。

3. sembuf 结构体

描述「单个信号量操作」的结构体,是 semop() 函数的核心参数,定义如下:

cpp 复制代码
struct sembuf {
    unsigned short sem_num;  // 信号量在集合中的下标(如 0)
    short          sem_op;   // 操作类型:-1=P操作,1=V操作,0=等待信号量值为0
    short          sem_flg;  // 标志:0=阻塞,IPC_NOWAIT=非阻塞,SEM_UNDO=自动撤销
};
  • sem_op = -1:P 操作(申请资源,信号量减 1);
  • sem_op = 1:V 操作(释放资源,信号量加 1);
  • sem_op = 0:特殊操作,阻塞直到信号量值变为 0;
  • sem_flg = SEM_UNDO:核心优化,进程异常退出时,内核会自动撤销该进程的信号量操作(避免信号量值被永久占用导致死锁)。

4. semun 联合体

semctl() 函数的第四个参数(控制信号量的载体),现代 Linux 系统头文件不会自动定义,必须手动声明(你之前代码中已实现):

复制代码
union semun {
    int val;                // SETVAL:设置单个信号量的初始值
    struct semid_ds *buf;   // IPC_STAT/IPC_SET:获取/设置信号量集属性
    unsigned short *array;  // GETALL/SETALL:批量获取/设置所有信号量值
    struct seminfo *__buf;  // IPC_INFO:获取系统级信号量资源限制(Linux 特有)
};

不同的 semctl() 命令对应使用不同的成员(如 SETVALvalSETALLarray)。

5. 信号量集 ID(semid)

semget() 函数返回的整数,是操作信号量集的「句柄」------ 后续 semop()semctl() 都通过这个 ID 定位到具体的信号量集。

System V 信号量的核心函数

System V 信号量的使用依赖 4 个核心函数,按使用流程逐一讲解:

1. ftok ():生成 IPC 键

复制代码
#include <sys/ipc.h>
key_t ftok(const char *pathname, int proj_id);

功能 :将「文件路径 + 项目 ID」转换为唯一的 IPC 键;
参数
pathname:存在且可访问的文件 / 目录路径(如 "." 表示当前目录);
proj_id:项目标识(仅低 8 位有效,通常用 0-255 的整数,如 123);
返回值 :成功返回 key_t 类型键值,失败返回 -1(可通过 perror("ftok") 查看原因);
实战注意:若路径被删除后重建,即使参数相同,生成的 key 也会不同(内核根据文件的 inode 号计算 key)。

2. semget ():创建 / 获取信号量集

复制代码
#include <sys/sem.h>
int semget(key_t key, int nsems, int semflg);

功能 :创建新的信号量集,或获取已存在的信号量集;
参数

key:由 ftok() 生成的 IPC 键;
nsems:信号量集中包含的信号量数量(如 1 表示单个信号量);
semflg:标志位 + 权限位(组合使用):

  • IPC_CREAT:若信号量集不存在则创建;
  • IPC_EXCL:与 IPC_CREAT 配合,若信号量集已存在则返回错误(避免重复创建);
  • 权限位:同文件权限(如 0666 表示所有用户可读可写);

返回值 :成功返回信号量集 ID(semid),失败返回 -1;
示例 :你之前代码中的 semget(key, 1, IPC_CREAT | IPC_EXCL | 0666) 表示「创建包含 1 个信号量的集合,若已存在则报错」。

3. semctl ():控制信号量集(初始化 / 删除 / 查询)

复制代码
#include <sys/sem.h>
int semctl(int semid, int semnum, int cmd, ...);

功能 :对信号量集执行控制操作(初始化值、删除集合、查询值等);
参数

semid:信号量集 ID(由 semget() 返回);
semnum:信号量在集合中的下标(如 0);若 cmdIPC_RMID,该参数无意义(传 0 即可);
cmd:操作命令(核心常用命令如下);

第四个参数:semun 联合体(仅部分 cmd 需要);

核心 cmd 命令

命令 作用 是否需要 semun 返回值
SETVAL 设置单个信号量的初始值 是(用 val) 成功 0,失败 -1
GETVAL 获取单个信号量的当前值 成功返回信号量值
IPC_RMID 删除整个信号量集(释放内核资源) 成功 0,失败 -1
IPC_STAT 获取信号量集的属性(如创建时间、权限) 是(用 buf) 成功 0,失败 -1

示例semctl(semid, 0, SETVAL, arg) 表示「将下标 0 的信号量初始化为 arg.val(1)」;semctl(semid, 0, IPC_RMID) 表示「删除整个信号量集」。

4. semop ():执行信号量操作(P/V 核心)

复制代码
#include <sys/sem.h>
int semop(int semid, struct sembuf *sops, size_t nsops);

功能 :执行一个或多个信号量操作(P/V 操作的核心实现);
参数

semid:信号量集 ID;
sopsstruct sembuf 类型的数组(可批量操作多个信号量);
nsops:数组长度(如 1 表示仅操作 1 个信号量);
返回值 :成功返回 0,失败返回 -1;
示例

P 操作:

复制代码
struct sembuf sops = {0, -1, 0}; // 下标 0,P 操作(-1),阻塞模式
semop(semid, &sops, 1);

V 操作:

复制代码
struct sembuf sops = {0, 1, 0}; // 下标 0,V 操作(+1),阻塞模式
semop(semid, &sops, 1);

实战注意事项(避坑关键)

1. 必须手动删除信号量集

System V 信号量集的生命周期随内核 ,进程退出后不会自动释放 ------ 若不调用 semctl(IPC_RMID),信号量集会一直残留在内核中,导致后续创建同名 key 的信号量集失败。

  • 查看残留的信号量集:ipcs -s
  • 删除残留的信号量集:ipcrm -s <semid>(semid 可从 ipcs 结果中获取)。
2. 建议添加 SEM_UNDO 标志

sembufsem_flg 中添加 SEM_UNDO

复制代码
struct sembuf sops = {0, -1, SEM_UNDO}; // P 操作

作用:进程异常退出(如被 kill)时,内核会自动撤销该进程的信号量操作(比如将 P 操作减的 1 加回来),避免信号量值被永久占用导致死锁。

3. 处理非阻塞场景

若不想让进程阻塞等待信号量,可设置 sem_flg = IPC_NOWAIT

复制代码
struct sembuf sops = {0, -1, IPC_NOWAIT};
if (semop(semid, &sops, 1) == -1) {
    if (errno == EAGAIN) {
        printf("信号量被占用,非阻塞退出\n");
    }
}
4. 避免死锁

若使用多个信号量,需保证所有进程的 P/V 操作顺序一致(如先申请信号量 A,再申请信号量 B),否则会导致死锁。

5. 权限问题

semget() 的权限位(如 0666)需合理设置,否则其他用户 / 进程无法访问该信号量集(报错 Permission denied)

完整示例:System V 信号量实现进程互斥

功能:使用System V信号量实现父子进程互斥输出

核心:通过二值信号量(初始值1)保证同一时间只有一个进程执行打印操作,避免输出混乱

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/sem.h>
#include <sys/wait.h>

// 注意:System V信号量的semun联合体在现代系统中不会被头文件自动定义,必须手动声明
// 该联合体是semctl函数的第四个参数,不同操作对应不同成员
union semun {
    int val;                // 用于SETVAL操作:设置单个信号量的初始值
    struct semid_ds *buf;   // 用于IPC_STAT/IPC_SET操作:获取/设置信号量集的属性
    unsigned short *array;  // 用于GETALL/SETALL操作:获取/设置所有信号量的值
    struct seminfo *__buf;  // 用于IPC_INFO操作:获取系统级的信号量信息(Linux特有)
};

/**
 * 初始化信号量的值
 * @param semid  信号量集的ID(由semget返回)
 * @param semnum 信号量集中的信号量下标(单个信号量时为0)
 * @param value  要设置的信号量初始值
 * @return       成功返回0,失败返回-1(错误原因可通过perror查看)
 */
int init_sem(int semid, int semnum, int value) {
    union semun arg;        // 定义semun联合体变量,用于传递初始化值
    arg.val = value;        // 设置要初始化的信号量值
    // semctl函数:控制信号量集
    // 参数1:信号量集ID;参数2:信号量下标;参数3:操作类型(SETVAL=设置值);参数4:联合体参数
    return semctl(semid, semnum, SETVAL, arg);
}

/**
 * P操作(申请资源/减锁):获取信号量,若信号量值为0则阻塞等待
 * 核心逻辑:将信号量值减1,实现"申请资源"的互斥逻辑
 * @param semid  信号量集的ID
 * @param semnum 信号量集中的信号量下标
 * @return       成功返回0,失败返回-1
 */
int p_sem(int semid, int semnum) {
    // sembuf结构体:描述单个信号量操作
    struct sembuf sops = {
        semnum,  // 要操作的信号量下标
        -1,      // sem_op:-1表示P操作(申请资源,信号量值减1)
        0        // sem_flg:0表示阻塞模式(信号量为0时等待,而非立即返回)
    };
    // semop函数:执行信号量操作(可批量操作多个信号量,这里只操作1个)
    // 参数1:信号量集ID;参数2:操作数组;参数3:操作数组长度
    return semop(semid, &sops, 1);
}

/**
 * V操作(释放资源/解锁):释放信号量,唤醒等待该信号量的进程
 * 核心逻辑:将信号量值加1,实现"释放资源"的互斥逻辑
 * @param semid  信号量集的ID
 * @param semnum 信号量集中的信号量下标
 * @return       成功返回0,失败返回-1
 */
int v_sem(int semid, int semnum) {
    struct sembuf sops = {
        semnum,  // 要操作的信号量下标
        1,       // sem_op:1表示V操作(释放资源,信号量值加1)
        0        // 阻塞模式(此处V操作不会阻塞,仅保持参数统一)
    };
    return semop(semid, &sops, 1);
}

/**
 * 删除信号量集(释放IPC资源)
 * @param semid 信号量集的ID
 * @return      成功返回0,失败返回-1
 */
int del_sem(int semid) {
    // IPC_RMID:删除整个信号量集,第二个参数(semnum)无意义,传0即可
    return semctl(semid, 0, IPC_RMID);
}

int main() {
    // 1. 生成唯一的IPC键值(用于标识信号量集)
    // ftok函数:将路径(当前目录".")和项目ID(123)转换为唯一的key_t类型键值
    // 注意:路径必须是存在且可访问的目录/文件,项目ID通常用0-255的整数
    key_t key = ftok(".", 123);
    if (key == -1) {  // ftok失败(如路径不存在)
        perror("ftok");  // 打印错误原因
        exit(1);         // 异常退出程序
    }

    // 2. 创建信号量集
    // semget函数:创建/获取信号量集
    // 参数1:IPC键值;参数2:信号量集中的信号量数量(这里只需要1个);参数3:标志位+权限
    // IPC_CREAT:若信号量集不存在则创建;IPC_EXCL:若已存在则返回错误(避免重复创建);0666:信号量集的访问权限(同文件权限)
    int semid = semget(key, 1, IPC_CREAT | IPC_EXCL | 0666);
    if (semid == -1) {  // semget失败(如信号量集已存在)
        perror("semget");
        exit(1);
    }

    // 3. 初始化信号量为1(二值信号量,实现互斥:1=资源可用,0=资源被占用)
    if (init_sem(semid, 0, 1) == -1) {
        perror("init_sem");
        exit(1);
    }

    // 4. 创建子进程(fork后会复制父进程的内存空间,包括semid、key等变量)
    pid_t pid = fork();
    if (pid == 0) {  // 子进程分支(fork返回0)
        // 子进程循环5次,每次都要先获取信号量再打印
        for (int i = 0; i < 5; i++) {
            p_sem(semid, 0);  // P操作:申请信号量(若被父进程占用则阻塞)
            // 临界区:只有获取到信号量的进程才能执行这部分代码
            printf("子进程(%d):打印第%d次\n", getpid(), i+1);  // 打印子进程ID和次数
            sleep(1);         // 模拟临界区内的耗时操作(如处理数据)
            v_sem(semid, 0);  // V操作:释放信号量(让父进程可以获取)
            sleep(1);         // 非临界区的休眠(让父进程有机会获取信号量,避免子进程重复抢占)
        }
        exit(0);  // 子进程正常退出
    } else if (pid > 0) {  // 父进程分支(fork返回子进程PID)
        // 父进程循环5次,逻辑与子进程一致
        for (int i = 0; i < 5; i++) {
            p_sem(semid, 0);  // P操作:申请信号量
            // 临界区:互斥打印
            printf("父进程(%d):打印第%d次\n", getpid(), i+1);
            sleep(1);         // 模拟耗时操作
            v_sem(semid, 0);  // V操作:释放信号量
            sleep(1);         // 非临界区休眠
        }
        wait(NULL);  // 等待子进程退出(避免子进程变成僵尸进程)
        del_sem(semid);  // 父进程最后删除信号量集(释放IPC资源,必须执行!)
    } else {  // fork失败(pid=-1)
        perror("fork");
        del_sem(semid);  // 即使fork失败,也要清理已创建的信号量集
        exit(1);
    }

    return 0;  // 程序正常退出
}

编译运行

复制代码
gcc sem_systemv.c -o sem_systemv
./sem_systemv

输出效果(父 / 子进程不会同时打印,实现互斥):

复制代码
父进程(1234):打印第1次
子进程(1235):打印第1次
父进程(1234):打印第2次
子进程(1235):打印第2次
...

2. POSIX 信号量(更现代、易用)

POSIX 信号量分为无名信号量 (基于内存)和命名信号量(基于文件系统),API 更简洁。

(1) 无名信号量(适用于亲缘进程 / 线程)

函数 作用 关键参数
sem_init() 初始化 pshared=0(线程共享)、value(初始值)
sem_wait() P 操作(阻塞) 信号量指针
sem_post() V 操作 信号量指针
sem_destroy() 销毁 信号量指针

(2) 命名信号量(适用于无亲缘进程)

函数 作用 关键参数
sem_open() 创建 / 打开 name(路径名如/my_sem)、value(初始值)
sem_close() 关闭 信号量指针
sem_unlink() 删除 信号量名称

完整示例:POSIX 无名信号量实现线程互斥

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <semaphore.h>

sem_t sem; // 全局无名信号量(线程共享)

// 线程1函数
void *thread1(void *arg) {
    for (int i = 0; i < 5; i++) {
        sem_wait(&sem); // P操作
        printf("线程1:打印第%d次\n", i+1);
        sleep(1);
        sem_post(&sem); // V操作
        sleep(1);
    }
    pthread_exit(NULL);
}

// 线程2函数
void *thread2(void *arg) {
    for (int i = 0; i < 5; i++) {
        sem_wait(&sem);
        printf("线程2:打印第%d次\n", i+1);
        sleep(1);
        sem_post(&sem);
        sleep(1);
    }
    pthread_exit(NULL);
}

int main() {
    pthread_t tid1, tid2;

    // 初始化信号量:线程共享(pshared=0),初始值=1
    if (sem_init(&sem, 0, 1) == -1) {
        perror("sem_init");
        exit(1);
    }

    // 创建线程
    pthread_create(&tid1, NULL, thread1, NULL);
    pthread_create(&tid2, NULL, thread2, NULL);

    // 等待线程结束
    pthread_join(tid1, NULL);
    pthread_join(tid2, NULL);

    // 销毁信号量
    sem_destroy(&sem);
    return 0;
}

编译运行(需链接 pthread 库):

复制代码
gcc sem_posix.c -o sem_posix -lpthread
./sem_posix

三、信号量的典型应用场景

1. 互斥访问临界资源

二值信号量(初始值 = 1)实现 "互斥锁",保证同一时间只有一个进程 / 线程访问临界资源(如共享内存、全局变量)。

2. 生产者 - 消费者模型

需要两个信号量:

  • empty(空缓冲区):初始值 = 缓冲区大小,生产者 P 操作后生产
  • full(满缓冲区):初始值 = 0,消费者 P 操作后消费
  • 可选:加一个互斥信号量保护缓冲区修改

3. 进程同步

控制执行顺序(如 A 等 B 完成初始化):

  • 信号量初始值 = 0;
  • B 完成后执行 V 操作;
  • A 执行前 P 操作(阻塞直到 B 的 V 操作)

四、使用注意事项

1. 避免死锁

  • 死锁场景:进程持有对方需要的信号量且不释放、自身重复 P 操作
  • 解决:按固定顺序获取信号量、保证 P/V 配对、使用sem_trywait()(非阻塞 P)

2. 防止 System V 信号量泄漏

  • 查看残留信号量:ipcs -s
  • 删除指定信号量:ipcrm -s <semid>

3. 合理设置初始值

  • 互斥:1;同步:0;资源池:资源总数(如线程池大小)

4. 错误处理

所有信号量 API 都可能失败,必须检查返回值(如sem_wait返回 - 1 表示失败)

总结

  1. 核心本质:信号量是 "原子计数器 + 等待队列",通过 P(减 1 / 阻塞)、V(加 1 / 唤醒)原子操作实现进程 / 线程的同步与互斥,解决并发竞态问题
  2. 两大类型:System V 信号量(IPC 集、跨无亲缘进程、需手动删除)和 POSIX 信号量(分无名 / 命名、更易用、线程 / 进程共享),核心逻辑一致但 API 不同
  3. 关键注意事项:避免死锁(保证 P/V 配对、固定获取顺序)、防止 System V 信号量泄漏、合理设置初始值、做好 API 错误处理
相关推荐
qq_366086222 小时前
sql server 整数转百分比
运维·服务器·数据库
oMcLin2 小时前
如何排查 Linux 系统服务器的性能故障问题:使用 `top`、`htop`、`iostat` 等工具
linux·服务器·数据库
鸽芷咕2 小时前
金仓数据库性能优化全景指南:从 SQL 精调到多核 CPU 高效利用
数据库·oracle·性能优化·金仓数据库
喂自己代言2 小时前
Linux基础命令速查指南
linux·运维·服务器
bkspiderx2 小时前
详解Linux下xrandr工具:从基础配置到三显示器扩展桌面
linux·运维·计算机外设·显示器·分屏·xrandr·显示器扩展桌面
IT届小白2 小时前
探讨:20 万数据量下ROW_NUMBER和GROUP BY两条 SQL 性能差异分析(查 10 条 / 查所有)
数据库·mysql
wdfk_prog2 小时前
[Linux]学习笔记系列 -- [fs]namei
linux·笔记·学习
IT运维爱好者2 小时前
【Linux】网络诊断工具traceroute命令详解
linux·网络·traceroute
lcreek2 小时前
Linux虚拟文件系统(VFS)核心架构解析
linux·操作系统