Re:Linux系统篇(四十二)通信篇·七:SystemV标准三部曲其之三:信号量与并发编程初步认识


◆ 博主名称: 小此方-CSDN博客 大家好,欢迎来到小此方的博客。
⭐️Linux系列个人专栏: 【主题曲】Linux
⭐️此方的GitHub: github_此方
⭐️ Re系列专栏:我们思考 (Rethink) · 我们重建 (Rebuild) · 我们记录 (Record)


文章目录


概要&序論

  Hello大家好,我是此方。本文作为SystemV的最后一部分------信号量 ,不会单讲信号量,而是穿插进来后期并发编程的相关概念 。同时会在本文的后半段部分开始整合总结SystemV标准三大通信手段在内核中的关系。 内容相较前两篇复杂难懂,但尽可能讲解通俗。好的我们开始吧。

一、初步认识并发编程中的相关概念

1.1什么是互斥/临界资源

  我们还是要从进程间通信的本质入手:"让多个进程看到同一份资源 "是进程间通信的前提。多个进程看到的同一份资源我们称之为------共享资源。

  共享资源有被保护和不被保护两类区分,被保护的共享资源我们称之为"临界资源或互斥资源"。

1.2如何理解:保护共享资源

  好,我们详细讲解这句话。

  什么是被保护?首先,两个进程同时去访问一块没有被保护的共享资源 ,会引发数据不一致的问题。(这一点我们在 共享内存中提到过。本文也会讲到信号量是它的一种解决方案)

  我们要保护共享资源防止被两个及以上进程同时访问 ,就可以转化为"对进程代码的保护"------这些被保护的代码就叫做临界区。(保护也是制约

准确的说 :在进程中涉及到互斥(临界)资源的程序段叫临界区 。你写的代码=访问临界资源的代码(临界区 )+不访问临界资源的代码(非临界区)

  你可能绕不过来"为什么保护共享资源就是保护涉及到互斥(临界)资源的程序段",你可以这么想:比如我们去公园,公园的椅子,有些素质比较高的人会主动在下面丢垃圾,我们要制定政策防止这种事件发生,要保护的是这个椅子还是这个人?(我们认定保护==约束),当然是保护这个人!这里公园的椅子,就是临界资源。人就是临界区

1.3保护临界资源的方式

1.3.1同步与互斥保护临界资源

  保护临界资源的方式有两种------"互斥"与"同步"。

  • 多个执行流,访问临界资源的时候,具有一定的顺序性,叫做同步
  • 任何时刻,只允许一个执行流访问资源,叫做互斥

  讲一个简单的例子:去医院看病。诊室和医生就是临界资源,多名患者在门外排队、按顺序依次进入,这就是"同步" ;而每次诊室内只能有一位患者就诊,别人不能进去打扰,这就是"互斥"

1.3.2 同步与互斥的具体做法

  既然我们知道了需要通过"同步"与"互斥"来保护临界资源,那么在计算机底层,这具体是如何实现的呢?这里需要引入两个核心概念:原子性

1.3.2.1 什么是原子性?

  用一句话来概括原子性就是:要么不做,要么一口气做完,绝不中间掉链子。

  在多线程环境下,当一个执行流申请并获得了某项资源或执行某段核心操作时,其他执行流绝对不能在中途打断它。对它们来说,这个操作是没有"中间状态"的,这就是原子性(要么做完看到新结果,要么没做还是原结果)。

1.3.2.2 为什么要保护"写之中"?

  我们对数据的写入通常可以分为三个阶段:写之前写之中写之后

  • 写之前:数据尚未被修改,其他执行流读取它不会受到影响。
  • 写之后:数据已经完整写入,其他执行流可以读到最新的完整数据。
  • 写之中绝大部分底层代码,自身是无法保证"写之中"的原子性的。
1.3.2.3 如何解决?用"互斥锁"来守护原子性

  为了保护"写之中"不被打扰,我们就必须使用互斥锁

  当代码准备进入核心的"临界区"去修改数据时;

  1. 加锁(Lock):谁先申请到这把锁,临界区就属于谁。
  2. 执行(临界区代码) :在锁的保护下,该执行流可以安全地在"临界区"内修改数据。此时对其他执行流而言,由于锁被占用只能在外面排队死等,这个"写之中"的过程就变相具备了原子性
  3. 解锁(Unlock):操作完成后释放锁,唤醒下一个排队的执行流进入。

  既然"锁"是用来保护临界资源的,那么锁本身也必须是安全的 。也就是说,"加锁"和"解锁"这两个动作本身,必须绝对具备原子性(要么加锁成功,要么加锁失败),否则锁本身就会成为新的冲突点。

二、认识信号量相关概念

2.1什么是信号量

  信号量,又叫做信号灯。本质上是一个计数器,用来表明一个临界资源中资源数量的多少。

2.1.1资源非整体使用

  假如你的老板开了一家电影院,这个电影院的二号放映厅就是一个临界资源。这个放映厅里面有很多的小座位(子资源)。

  • 我要买票,买的是这个座位的使用权,买票本质是对资源的预定。(想要访问临界资源就得先"买票")
  • 你的老板要卖票,就要避免两个问题------票卖多了(放入过多的进程进来)、票号重复(两个以上进程访问同一个位置)。 ------为了并发访问&&不出问题

  所以信号量就要明确自己对应的临界资源内部有多少个子资源。每当一个进程申请了一个子资源,就要让计数器--,直到0。

  进程申请信号量我们就可以写一个伪代码来解释,假设信号量是16(这么说有Bug,但是好理解

cpp 复制代码
//申请信号量:
if(-- sem > 0)
{
    //申请成功;
    //预留资源块;
}
else if( -- sem < 0)
{
    //申请失败;
    //阻塞挂起;
}

2.1.2资源整体使用

  你的老板除了这种普通放映厅外,还设置了很多的SuperVIP放映厅,这个放映厅中只有一把椅子,一次只能卖给一个人。于是sem==1.

  这个时候信号量只有0和1两态。这种信号量我们称之为二元信号量 (这就是我们讲的互斥!)。反之上面的资源非整体使用我们称之为多元信号量。

2.1.3总结信号量

  做个总结:信号量就是一个计数器,描述的是临界资源中,资源数量的多少!所有进程,访问临界资源中的一小块,就必须先申请信号量 。进程访问资源前,先申请信号量的本质是:是对资源的预订机制。资源就给你了,等你随时访问没人和你抢。

  信号量本身就是共享资源,对资源预定的机制我们称之为PV操作。

  • 申请到资源:sem --, 原子性------P操作。
  • 用完了还回去:sem++,原子性------V操作。

2.1.4信号量的伪代码

  我要打破上面出于讲解方便的一个错误:信号量绝对不会是一个整数,如果是整数,它就不能实现原子。我们不能简单的让两个进程看到同一个整数 。于是我们的信号量结构体里面有一个锁,一个计数器,一个等待队列。我们的进程申请成功(count>0)count --申请失败:PCB从运行队列中取出,连接进入信号量的等待队列。大致的伪代码:

cpp 复制代码
struct sem
{
    // 锁
    // int count;
    // task_struct *waitqueue;
}

三、信号量与通信操作

  那么聊了这么多,信号量和通信到底有什么关系?

3.1通信的定义修正

  不是传递数据,才是通信IPC。只要是信息与事件的传递就是通信。比如通知,同步互斥,等等。

  怎么理解这个"控制信息传递"?举例:我们的一个进程A进去了,--count = 0 ;进程B在外面等待,A出来了++count >0,B收到这个控制信息,从阻塞挂起中起来,获取里面的资源。

  每一个进程都得看到同一个信号量,SystemV信号量解决的就是这个问题。

3.2 信号量的创建:semget

  要使用信号量,首先得在内核中申请并创建它。

c 复制代码
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>

int semget(key_t key, int nsems, int semflg);
  • key :通过 ftok 产生的键值,用来保证多个进程看到的是同一个信号量。
  • nsems注意,这个参数指的是创建的信号量集中信号量的个数! 也就是说,操作系统创建的不是单个信号量,而是一个信号量数组(信号量集)。这个信号量集你可以把它当作一个数组。
  • semflg :权限标志,常用的有 IPC_CREATIPC_EXCL,其含义与共享内存完全一致。
  • 返回值 :成功时返回一个非负整数,即 semid(信号量集标识符);失败返回 -1。

核心细节 :我们为进程创建信号量时,系统内部会返回一个 信号量集的 semid (不是信号量的semid!信号量的索引是这个信号量集的下标)。即便你只需要使用 1 个信号量,传入 nsems = 1 ,在底层操作系统依然会将其作为"包含 1 个元素的信号量集"来统一管理。

3.3 信号量的控制与初始化:semctl

  信号量的创建和初始化是分开的,且这两个操作组合在一起并不是原子性的。 创建好信号量集后,必须对其进行初始化才能正常使用。

c 复制代码
int semctl(int semid, int semnum, int cmd, ...);
  • semid :由 semget 返回的信号量集标识符。
  • semnum:你要操作该信号量集中的哪一个信号量?(数组下标,从 0 开始)。
  • cmd :控制命令。如果用来初始化信号量的初值,使用 SETVAL ;如果要删除信号量集,使用 IPC_RMID
  • 第四个可选参数 :如果使用了 SETVAL 命令,我们需要传入一个自定义的联合体 union semun 来传递初值。

3.3.1 信号量初始化的标准步骤

  在许多 Linux 系统中,union semun 联合体并没有在系统头文件中直接定义,内核要求程序员必须在自己的代码里手动写出这个定义。具体步骤如下:

第一步:在 C 文件开头手动定义联合体

c 复制代码
union semun {
    int              val;    /* 当使用 SETVAL 时对应的初值 */
    struct semid_ds *buf;    /* IPC_STAT, IPC_SET 缓存区 */
    unsigned short  *array;  /* GETALL, SETALL 数组 */
    struct seminfo  *__buf;  /* Linux 特有缓存区 */
};

第二步:实例化这个联合体,并填入你想要的初始值

c 复制代码
union semun arg;
arg.val = 1; // 如果是二元信号量(互斥锁),初值设为 1;多元信号量则设为子资源数量

第三步:调用 semctl 函数进行初始化

c 复制代码
// 假设我们需要将 semid 信号量集中下标为 0 的信号量初始化
semctl(semid, 0, SETVAL, arg);

3.4 信号量操作(P/V操作):semop

  信号量的核心就是 P 操作(申请资源,计数器减 1 )和 V 操作(释放资源,计数器加 1 )。在 Linux 中通过 semop 系统调用批量或单个完成。

c 复制代码
int semop(int semid, struct sembuf *sops, size_t nsops);
  • semid:目标信号量集。
  • sops :指向一个 struct sembuf 结构体数组的指针,用来描述具体的操作细节。
  • nsops :本次批量操作的 struct sembuf 结构体数量。

3.4.1 核心结构体 struct sembuf

c 复制代码
struct sembuf {
    unsigned short sem_num;  /* 信号量在集合中的下标 */
    short          sem_op;   /* 核心操作:-1 代表 P 操作,1 代表 V 操作 */
    short          sem_flg;  /* 操作标志,通常设为 0;或者设为 SEM_UNDO 防止进程挂掉导致死锁 */
};

  第三个参数我们不用管它。

3.4.2 批量操作伪代码案例

c 复制代码
// 批量对多个信号量做操作的场景
struct sembuf sops[N];

for (int i = 0; i < N; i++) {
    sops[i].sem_num = sem_nums[i]; // 确定要操作的信号量下标
    sops[i].sem_op = -1;           // 对当前信号量进行 P 操作(如果是释放则设为 1)
    sops[i].sem_flg = 0;
}

// 一口气将这批 P 操作注入内核,保证底层执行
semop(semid, sops, N);

3.5 信号量的命令行管理工具

  与共享内存一样,信号量作为进程间通信资源,其生命周期是随内核的。如果进程退出了但没有显式释放,信号量就会一直残留在内核中,除非重启系统或手动清理。

3.5.1 查看系统中的信号量集

  我们可以使用 -s 选项来单独查看信号量集:

bash 复制代码
ipcs -s
  • key:申请时传入的底层键值。
  • semid:内核分配的使用标识符。
  • owner:创建者。
  • perms:权限属性。
  • nsems:该信号量集所拥有的信号量个数。

3.5.2 命令行删除信号量集

bash 复制代码
ipcrm -s semid

至此,我们把SystemV的三部曲全部讲解完毕。

Re:Linux系统篇(四十)通信篇·五:SystemV标准三部曲其之一:共享内存

Re:Linux系统篇(四十一)通信篇·六:SystemV标准三部曲其之二:消息队列


四、SystemV三大部分如何在操作系统内核中组织

  如果就这样结束了,那我这篇文章也太没深度了(doge。我们还要继续来聊一聊他们三者之间的结构关系,这也很重要。

  你创建了一套SystemV全家桶,使用的是他们的同一套算法同一套接口。出来的key有没有可能冲突? 有可能。因为共享内存,消息队列,信号量,被当做了同一种资源 用key区分唯一。

4.1 IPC 组织结构的介绍

  那么IPC 资源如何组织的呢?如下,是一张SystemV的内核架构图,他是整个操作系统内核架构的冰山一角。

  图中都有什么?我们把它标记出来

4.1.1全局控制结构体与 IPC 对象指针存储数组

  • struct ipc_ids:IPC 机制的全局控制结构体。
  • struct ipc_id_ary:用于真正存储 IPC 对象指针的内部数组结构体。

  在struct ipc_ids里面有一个struct ipc_id_ary指针,指向一张ipc_id_ary,ipc_id_ary内部有一个柔性数组,它存储了所有的IPC资源的指针。

cpp 复制代码
struct ipc_id_ary
{
    int size;
    char buffer[0];
}

为什么是柔性数组?

  因为SystemV的三者都是数组下标,所以逐次递增获取这些下标,可能有越界风险,内核给出两种安保措施:

  1. 柔性数组自动扩容
  2. 设置最大容量,达到后自动回绕(使用的是你的真实的id去模上maxid

4.1.2IPC底层的多态实现原理

  这三种资源实际上放在一个数组种进行管理 怎么做到的?这三种类型的资源他们的头部都是同一种类型的结构体。我们的数组不必知道这三种资源内部到底有什么,它只会去看他们的头上有什么。通过强制类型转换insert进来。

我们的shmid,msgid,semid等等都是数组的下标!

  通过强制类型转换回原来的类型,然后指针就可以访问其他的内容了

cpp 复制代码
(msg_queue*)p[0]->other 。

  内核是如何知道你的这个下标映射的是什么类型并进行对应的强制类型转换?我们的ipc-perm内部维护了一个type字段,它会记录你是三种中的哪一个

这就是用C语言,实现多态

4.2从源码看结构

  我用的是比较老版本的源代码,

4.3 边缘问题:共享内存在内存中开辟空间是以文件为载体开辟的

  可以看到它也是基于文件形成的内存共享。

4.3.1 核心原因:基于 tmpfs 内存文件系统实现

  System V 共享内存底层依托于内核隐藏的内存文件系统 tmpfs (通常挂载在 /dev/shm 背后)。当调用 shmget 创建共享内存时,内核实质上:

  1. tmpfs 文件系统中私下创建了一个虚拟文件。
  2. 将该文件的 struct file 指针存储到核心结构体的 shm_file 成员中。

  所谓的共享内存操作,在内核视角下即是:多个进程同时通过 mmap 系统调用,把同一个文件映射到了各自的虚拟进程地址空间中

4.3.2 为什么内核要复用文件载体?

  如果不引入文件作为载体,内核必须独立编写逻辑来解决以下硬核问题:

  • 页面管理:物理内存不足时怎么换出(Swap)?哪些内存页已分配?
  • 进程生命周期:一个进程挂了,怎么保证内存不泄露?
  • 权限控制:谁能读写这块内存?

  而 Linux 的 文件子系统 + 虚拟内存系统 已经完美解决了这些问题:

  • 文件天然拥有 shm_perm 对应的权限检查。
  • 文件的 Page Cache 机制天生支持多个进程映射同一个 Page,且在物理内存中只保留一份。

4.3.3 shmget 与 shmat 的底层流程

  1. shmget 的操作 :先在物理内存中开辟空间并创建一个虚拟文件,用 struct file 描述它,然后把这个指针给到 shmid_kernel
  2. shmat 的操作
    • 在进程的 mm_struct 里面分配一个 vm_area_struct ,让其通过 mmap 机制进行指向。
    • 使用 vm_file 指针指向刚才创建的文件(共享内存)。
    • vm_startvm_end 赋值为对应的虚拟地址。
    • 动态建立页表映射关系与文件缓冲区,最后返回该虚拟地址。

好的本期内容就到这里,如果对你有帮助,还不要忘记点赞三联支持。我是此方,我们下期再见。bye! Linux、C++、算法持续连载中,欢迎关注WeChat Official Account 【此方的技术栈】。

相关推荐
画中有画8 小时前
使用yolov8实现自动化脚本
运维·yolo·自动化
Piko61410 小时前
H3C IRF2 堆叠实战:打造高可靠核心交换网络
运维·网络·笔记
JAVA面经实录91710 小时前
Linux 常用命令完整知识体系
java·linux·开发语言·汇编
万联WANFLOW13 小时前
SD-WAN 控制平面高可用怎么做?SDWAN 控制器挂了,全网会发生什么
运维·网络·分布式·架构
SkyWalking中文站15 小时前
认识 Horizon UI · AI Assistant:用日常语言查询你的可观测性数据
运维·监控·自动化运维
艾莉丝努力练剑16 小时前
OpenCode AI 编程:Ubuntu 24.04 环境安装与使用指南
linux·服务器·网络·人工智能·tcp/ip·ubuntu
崇山峻岭之间16 小时前
Keil5输出hex转换为bin的设置
linux·运维·服务器
Hoxy.R16 小时前
KingbaseES读写分离高可用集群扩容、备库重建与故障切换实战
运维·数据库
谷雪_65817 小时前
Linux 网络命名空间:从内核原理到企业级容器网络架构全景实战
linux·网络·架构