Re:Linux系统篇(三十八):进程间通信ChapterSystem V 信号量深度解析|同步互斥、内核源码、C 指针多态、实战代码


观众老爷们大家好 这里是邪修KING的独家频道 本文属于系列Linux系统篇 ------动静态库 一起学Linux的小伙伴可订阅专栏: Linux系统篇

前言

我们前面学习了匿名管道、命名管道、System V 共享内存。其中共享内存是速度最快的 IPC,但是内核不会自带任何访问保护 :多个进程同时读写同一块共享内存,会产生竞态条件(race condition),直接造成数据错乱。

课堂上的思维导图核心:

  1. 多个执行流看到同一份公共资源 → 共享资源
  2. 被保护的共享资源叫临界资源
  3. 访问临界资源的代码片段叫临界区
  4. 同一时刻只允许一个执行流访问资源:互斥
  5. 多个执行流访问资源有固定先后顺序:同步
  6. 信号量就是实现互斥与同步的经典方案

很多初学者会混淆:信号(signal)≠信号量(semaphore)

  • 信号:异步通知,比如 SIGINT、SIGPIPE,内核打断进程执行
  • 信号量:同步计数器,PV 原子操作,进程主动阻塞、主动唤醒,用来协调访问顺序

同时,Linux 内核大量使用C 语言函数指针实现多态(没有 C++ 的 class,用结构体 + 函数指针表模拟面向对象),这也是我们这篇重点结合结构图讲解的内容。


一、信号量基础理论:PV 操作

信号量本质:内核维护的计数器 + 进程等待队列 ,所有操作都是原子的,不会被 CPU 调度打断。

两个核心操作:

  1. P 操作(Proberen,申请资源,sem_op=-1)
    计数器减 1;如果结果≥0,进程继续执行;小于 0,进程阻塞,挂入等待队列。
  2. 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)


五、信号量常见坑点

  1. 忘记 SEM_UNDO:进程异常崩溃,信号量值不会自动恢复,直接死锁,推荐大部分场景带上 SEM_UNDO
  2. 信号量集初始化时机:semget 创建的时候不会自动初始化,必须手动 semctl SETVAL,多进程竞争初始化会踩坑
  3. 死锁:P 顺序颠倒。比如进程 A 拿 sem1 等 sem2,进程 B 拿 sem2 等 sem1
  4. 混淆:信号量集是一组,sem_num 下标不要写错
  5. 生命周期: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 本质思想也是互斥信号量。

相关推荐
deviant-ART43 分钟前
java注解简写规则
java·后端
文丑颜不良啊1 小时前
LeetCode1181~1200题目解析
java·leetcode
坤坤子吖1 小时前
Linux网络编程基础:从 MAC 到 IP,理解网络数据是怎么传输的
linux·网络·笔记·学习
释厄6231 小时前
矢量词汇向下不向上公理即词汇归位定律——肤浅即表面
java·人工智能·算法·去中心化·1024程序员节
海绵宝宝转agent1 小时前
Leetcode100+面经
java·笔记·学习·算法·leetcode·深度优先
会编程的吕洞宾1 小时前
Spring Boot 镜像瘦身实战:从 450MB 瘦到 180MB
java·后端·spring
Shaoshing1 小时前
LeetCode100 283.移动零
java·数据结构·算法·leetcode
海绵宝宝转agent1 小时前
LeetCode100个人题解+java面经总结
java·开发语言·笔记·学习·算法
文丑颜不良啊1 小时前
LeetCode1201~1220题目解析
java·leetcode