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

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 代表一个信号量集:

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 虚拟文件系统的思想。

// 简化版,内核源码里的管道操作表
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)

#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

#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

#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;
}
编译运行
gcc write.c -o write
gcc read.c -o read
./write &
./read

效果:生产者写一条,消费者读一条,严格有序,不会读到脏数据。

如果去掉信号量,两个进程同时读写共享内存,会出现半截数据、乱序,也就是思维导图里说的没有保护机制,数据不一致。

清理 IPC 资源(非常重要,System V IPC 生命周期不随进程退出自动销毁)
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 模拟多态,理解内核的设计思想:

#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 本质思想也是互斥信号量。
在这里插入图片描述

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/mogreat/article/details/169365903

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--