云计算百科
云计算领域专业知识百科平台

【Linux 系统篇(三十四)】通信(四):消息队列、信号量与 IPC 资源管理

在这里插入图片描述

大家好,欢迎来到 huangjin007_ 的博客
⭐
个人主页:huangjin007_
🔥
文章收录专栏:Linux 内功修炼手册(系统篇)

总会有一些坚持能从冰封的土地里培育出十万朵怒放的蔷薇


Linux 系统篇(三十四) —— 消息队列、信号量与 IPC 资源管理

文章目录

  • Linux 系统篇(三十四) —— 消息队列、信号量与 IPC 资源管理
    • 一、前言
    • 二、System V 消息队列
      • 2.1 为什么需要消息队列?
      • 2.2 消息队列的特点
      • 2.3 消息队列的基本接口
      • 2.4 消息结构是什么样的?
      • 2.5 消息队列如何被管理?
      • 2.6 消息队列的查看与删除
    • 三、从共享内存引出同步与互斥
      • 3.1 共享资源与临界资源
      • 3.2 什么是临界区?
      • 3.3 什么是互斥?
      • 3.4 什么是同步?
    • 四、System V 信号量
      • 4.1 什么是信号量?
      • 4.2 P 操作和 V 操作
      • 4.3 为什么 P/V 操作必须具有原子性?
      • 4.4 二元信号量与计数信号量
      • 4.5 信号量与共享内存的关系
    • 五、信号量的基本接口
      • 5.1 创建或获取信号量
      • 5.2 对信号量进行操作
      • 5.3 控制信号量
    • 六、Linux 内核如何管理 IPC 资源?
      • 6.1 先描述:使用数据结构管理 IPC 资源
      • 6.2 再组织:如何管理大量 IPC 对象?
      • 6.3 key 和 id 有什么区别?
      • 6.4 公共数据结构:kern_ipc_perm
      • 6.5 共享内存与进程地址空间的关系
    • 七、三种 System V IPC 的区别
    • 结语:

一、前言

  在上一篇文章中,我们学习了 Linux 进程间通信中的共享内存。

  共享内存允许多个进程访问同一块物理内存,避免了数据在内核与用户空间之间反复拷贝,因此具有非常高的通信效率。

  不过,共享内存虽然解决了进程之间的数据共享问题,却也带来了一个新的问题:

  多个进程同时访问同一块共享内存,会不会出现数据混乱?

  例如,进程 A 正在修改共享内存中的数据,进程 B 恰好也在读取或者修改这块数据,那么最终结果可能就不是我们预期的结果。

  这就引出了两个新的概念:同步与互斥。

  而 System V IPC 中的信号量,正是用来解决这类问题的重要机制。

  当然,System V IPC 不只有共享内存,还包括消息队列和信号量。

  本篇就从这几个方面展开,了解它们各自的作用,以及操作系统是如何组织和管理这些 IPC 资源的。


二、System V 消息队列

2.1 为什么需要消息队列?

  之前学习了管道和共享内存,它们都能够实现进程间通信。

  那么,能不能让进程之间直接发送一条消息,而不需要自己管理一块共享内存呢?

  消息队列(Message Queue) 是由操作系统内核维护的一个消息链表,进程可以向队列中发送消息,也可以从队列中接收消息。

  可以把它理解成一个公共邮箱:

  • 进程 A 负责发送消息,相当于往邮箱里投递信件。
  • 进程 B 负责接收消息,相当于从邮箱里取信。
  • 操作系统负责维护这个邮箱以及其中的消息。

  消息队列中的每条消息都带有一个类型,接收进程可以根据消息类型选择自己需要的消息。

例如:

  • 进程 A 发送一条类型为 1 的消息。
  • 进程 B 发送一条类型为 2 的消息。
  • 进程 C 可以指定只接收类型为 1 的消息。

  因此,消息队列不仅能够传递数据,还能够通过消息类型对数据进行区分。

2.2 消息队列的特点

  • 消息队列由内核维护,进程不需要自己管理消息链表。
  • 消息具有类型,接收方可以根据类型选择消息。
  • 消息队列支持阻塞式发送和接收,队列暂时没有符合条件的消息时,接收进程可以等待。
  • 消息队列属于 System V IPC 资源,进程退出后不会自动删除,需要显式清理,或者等待系统重启。
  • 2.3 消息队列的基本接口

      使用消息队列,需要包含以下头文件:

    #include <sys/types.h>
    #include <sys/ipc.h>
    #include <sys/msg.h>

    常用接口如下:

    函数作用
    ftok() 生成 IPC key
    msgget() 创建或获取消息队列
    msgsnd() 向消息队列发送消息
    msgrcv() 从消息队列接收消息
    msgctl() 控制消息队列

      其中,前两个函数与共享内存中的使用方式基本一致。

    1. 创建或获取消息队列

    int msgget(key_t key, int msgflg);

    • key:消息队列的标识键。
    • msgflg:创建标志和权限。

    常见的标志:

    • IPC_CREAT:不存在则创建,存在则获取。
    • IPC_CREAT | IPC_EXCL:不存在则创建,存在则报错。

    2. 发送消息

    int msgsnd(int msqid, const void *msgp,
    size_t msgsz, int msgflg);

    • msqid:消息队列标识符。
    • msgp:指向待发送消息的指针。
    • msgsz:消息正文的大小,不包括消息类型。
    • msgflg:发送标志。

    3. 接收消息

    ssize_t msgrcv(int msqid, void *msgp,
    size_t msgsz, long msgtyp,
    int msgflg);

      其中 msgtyp 用来指定接收消息的类型。

    • msgtyp = 0:接收队列中的第一条消息。
    • msgtyp > 0:接收指定类型的第一条消息。
    • msgtyp < 0:按照消息类型的规则选择符合条件的消息。

    4. 控制消息队列

    int msgctl(int msqid, int cmd, struct msqid_ds *buf);

    常用命令:

    • IPC_RMID:删除消息队列。
    • IPC_STAT:获取消息队列的状态信息。

    2.4 消息结构是什么样的?

      消息队列中的消息不仅包含正文,还需要包含一个消息类型。

    例如:

    struct msgbuf
    {
    long mtype; // 消息类型,必须大于 0
    char mtext[100]; // 消息正文
    };

      这里需要注意两个细节:

  • mtype 必须是大于 0 的整数。
  • msgsnd() 和 msgrcv() 中的 msgsz 指的是消息正文的大小,不包括 mtype。
  •   也就是说,发送消息时,消息类型用于区分消息,而正文才是真正需要传递的数据。

    2.5 消息队列如何被管理?

      既然消息队列由操作系统内核维护,那么内核就需要知道:

    • 消息队列的权限是什么?
    • 当前队列中有多少条消息?
    • 队列中占用了多少字节?
    • 最后一次发送和接收消息的时间是什么时候?

      因此,Linux 内核使用 struct msqid_ds 描述消息队列的相关信息。

      其中比较重要的成员有:

    成员含义
    msg_perm IPC 权限信息
    msg_stime 最后一次发送消息的时间
    msg_rtime 最后一次接收消息的时间
    msg_ctime 最后一次修改队列的时间
    msg_qnum 当前消息数量
    msg_qbytes 队列允许的最大字节数
    msg_lspid 最后发送消息的进程 PID
    msg_lrpid 最后接收消息的进程 PID

      这里我们不需要死记所有成员,知道内核会通过一个数据结构描述和管理消息队列即可。

    2.6 消息队列的查看与删除

      Linux 提供了两个常用命令:

    ipcs -q

      查看当前系统中的消息队列。

    ipcrm -q msqid

      删除指定的消息队列。

      注意:System V IPC 资源不会因为创建它的进程退出而自动消失,因此使用完毕后要记得清理。


    三、从共享内存引出同步与互斥

      消息队列介绍完了,接下来重点了解信号量。

      不过,在学习信号量之前,需要先弄清楚几个并发编程中的基本概念。

    3.1 共享资源与临界资源

      假设现在有两个进程 A 和 B。

      它们都可以访问同一块共享内存:

    在这里插入图片描述

      这块共享内存就是它们共同能够访问的资源。

      共享资源:多个执行流能够访问的公共资源。

      如果这个共享资源需要进行保护,避免多个执行流同时访问时出现问题,那么它就成为需要保护的临界资源。

      临界资源:一次只允许一个执行流访问,或者需要按照特定规则进行访问的共享资源。

    3.2 什么是临界区?

      既然临界资源需要保护,那么具体应该保护什么?

      假设有一段代码:

    int count = 10;

    // 对共享资源进行操作
    count++;

      这里的 count 是共享资源,而 count++ 就是访问共享资源的代码。

      临界区:进程中访问临界资源的代码段。

      与之对应,不访问临界资源的代码段叫作非临界区。

      因此,一个进程的代码可以简单划分为:

    在这里插入图片描述

      这里有一个非常重要的理解:

      保护共享资源,本质上就是保护访问共享资源的代码。

      因为真正可能产生并发问题的,是多个执行流同时执行访问共享资源的代码。

    3.3 什么是互斥?

      假设进程 A 和进程 B 同时访问同一个临界资源。

      如果两个进程同时修改数据,就可能导致数据不一致。

      那么,我们能不能规定:

      同一时刻,只允许一个进程访问临界资源?

      这就是互斥。

      互斥(Mutual Exclusion):任何时刻,只允许一个执行流访问临界资源。

      可以把它理解成房间只有一把钥匙:

    • A 拿到钥匙,进入房间。
    • B 没有钥匙,只能等待。
    • A 使用完毕,归还钥匙。
    • B 才能进入。

      通过这种方式,保证同一时间只有一个执行流访问临界资源。

    3.4 什么是同步?

      互斥解决的是多个执行流同时访问资源的问题。

      但是,有时候我们不仅要求不能同时访问,还要求多个执行流按照一定的顺序执行。

    例如:

      进程 A 负责生产数据,进程 B 负责消费数据。

      如果 A 还没有生产数据,B 就开始消费,那么显然会出现问题。

    所以我们希望:

  • A 先生产数据。
  • A 通知 B 数据已经准备好。
  • B 再开始消费数据。
  •   这就是同步。

      同步(Synchronization):多个执行流访问共享资源时,按照一定的顺序或条件进行协同。

      可以简单记忆:

    • 互斥:你用的时候我不能用。
    • 同步:你完成之后,我才能继续。

    四、System V 信号量

    4.1 什么是信号量?

      前面我们已经知道,共享资源可能出现并发访问问题,因此需要互斥和同步机制。

      那么,操作系统如何实现这些机制呢?

      System V IPC 提供了一种重要的工具:信号量(Semaphore)。

      信号量本质上是一个计数器,用来表示可用资源的数量。

      可以把它理解成电影院的座位管理系统。

      假设电影院有 16 个座位:

    • 初始时,计数器为 16。
    • 每卖出一张票,可用座位数量减 1。
    • 每退回一张票,可用座位数量加 1。
    • 当没有空座位时,新的观众只能等待。

      它实际上是一种资源预订机制:

      想要使用资源,必须先申请;使用完毕之后,再释放资源。

    4.2 P 操作和 V 操作

      信号量主要通过两种操作来管理资源。

      P 操作:申请资源。

      申请资源时,信号量的值减 1。

      如果没有足够的资源,执行流就需要等待,直到资源可用。

    V 操作:释放资源。

      释放资源时,信号量的值加 1。

    例如:

    // 假设初始有 16 个可用资源
    int sem = 16;

    // P 操作:申请资源
    sem—;

    // 使用资源
    // …

    // V 操作:释放资源
    sem++;

      上面只是帮助理解的伪代码,真正的信号量 P/V 操作必须由操作系统保证原子性,不能直接使用普通的 sem++ 和 sem– 代替。

    4.3 为什么 P/V 操作必须具有原子性?

      这里又出现了一个问题。

      假设两个进程同时申请资源:

    sem—;

      这看起来只是一个简单的减法,但在实际执行时,它通常包含读取、修改、写回等多个步骤。

      如果两个进程同时执行,就可能发生:

    • A 读取到 sem = 1。
    • B 也读取到 sem = 1。
    • A 将 sem 修改为 0。
    • B 也将 sem 修改为 0。

      原本只有一个资源,却让两个进程都认为自己成功申请到了资源。

      这就出现了问题。

      所以,P/V 操作必须保证原子性。

      原子性:一个操作要么完整执行,要么完全不执行,中间不能被其他执行流干扰。

      也就是说,申请资源和释放资源的过程必须是一个不可分割的整体。

    4.4 二元信号量与计数信号量

      根据资源数量的不同,信号量可以分为两种常见形式。

    1. 二元信号量

      信号量的值只有 0 和 1 两种状态。

      可以用来实现互斥:

    • 1:资源可以使用。
    • 0:资源已经被占用。

    2. 计数信号量

      信号量的值可以大于 1,用来管理多个同类资源。

    例如:

      一个电影院有 16 个座位,那么就可以使用计数信号量管理这 16 个座位。

      简单总结:

    • 二元信号量:控制一个资源是否可用。
    • 计数信号量:控制多个资源的可用数量。

    4.5 信号量与共享内存的关系

      现在可以把前面的知识联系起来了。

      共享内存负责解决数据共享的问题,而信号量负责解决多个进程访问共享资源时的同步与互斥问题。

      两者配合使用,可以让进程间通信更加安全。

    例如:

    在这里插入图片描述

      共享内存本身并不提供同步和互斥机制,信号量也不负责传递实际业务数据。

      它们各自负责不同的工作,通常需要配合使用。


    五、信号量的基本接口

      使用 System V 信号量,需要包含:

    #include <sys/types.h>
    #include <sys/ipc.h>
    #include <sys/sem.h>

    常用接口如下:

    函数作用
    ftok() 生成 IPC key
    semget() 创建或获取信号量集合
    semop() 对信号量进行 P/V 操作
    semctl() 控制信号量

    5.1 创建或获取信号量

    int semget(key_t key, int nsems, int semflg);

    参数:

    • key:信号量集合的标识键。
    • nsems:集合中信号量的数量。
    • semflg:创建标志和权限。

      返回值:成功返回信号量集合的标识符,失败返回 -1。

      注意:这里创建的是信号量集合,一个集合中可以包含多个信号量。

    5.2 对信号量进行操作

    int semop(int semid, struct sembuf *sops,
    size_t nsops);

      其中,struct sembuf 用来描述具体的操作:

    struct sembuf
    {
    unsigned short sem_num; // 信号量编号,从 0 开始
    short sem_op; // 操作类型
    short sem_flg; // 操作标志
    };

      最重要的是 sem_op:

    • sem_op = -1:P 操作,申请资源。
    • sem_op = +1:V 操作,释放资源。

      sem_num 用来指定对集合中的哪个信号量进行操作。

    5.3 控制信号量

    int semctl(int semid, int semnum,
    int cmd, ...);

      常见命令:

    命令作用
    IPC_RMID 删除信号量集合
    IPC_STAT 获取信号量集合状态
    SETVAL 设置指定信号量的值
    GETVAL 获取指定信号量的值

      这里需要特别注意一个问题:

    信号量创建之后,需要显式设置初始值。

      通常使用:

    semctl(semid, 0, SETVAL, arg);

      将集合中编号为 0 的信号量设置为指定值。

      其中 arg 是 semctl 所需的联合体参数,通常需要根据系统头文件定义 union semun。


    六、Linux 内核如何管理 IPC 资源?

      前面学习了消息队列、共享内存和信号量。

      现在换一个角度:

      假设操作系统中存在大量消息队列、共享内存和信号量,内核应该如何管理它们?

      总不能把所有资源随意放在内存中,然后每次使用时都到处寻找吧?

      这就涉及操作系统中的一个重要思想:

      先描述,再组织。

    6.1 先描述:使用数据结构管理 IPC 资源

      操作系统需要管理资源,就必须知道资源的各种属性。

    例如:

    • 资源的标识是什么?
    • 谁创建了这个资源?
    • 当前资源的权限是什么?
    • 资源是否已经被删除?

      因此,内核会使用不同的数据结构描述不同的 IPC 对象。

    例如:

    IPC 类型内核数据结构(Linux 2.6.11 示例)
    消息队列 msg_queue
    共享内存 shmid_kernel
    信号量 sem_array

      每种 IPC 资源都有自己的数据结构,用来记录对应的信息。

    6.2 再组织:如何管理大量 IPC 对象?

      只有描述还不够。

      如果系统中有很多 IPC 对象,内核还需要将这些对象组织起来,方便查找和管理。

      在 Linux 2.6.11 的相关实现中,System V IPC 使用了统一的管理机制。

      可以简单理解为:

    在这里插入图片描述

      虽然三种 IPC 资源的具体功能不同,但它们具有一些共同属性:

    • key:用于标识 IPC 对象。
    • uid/gid:所有者信息。
    • mode:访问权限。
    • 序列号:辅助管理对象标识。

    6.3 key 和 id 有什么区别?

      前面创建 IPC 资源时,我们经常使用:

    key_t key = ftok(".", 0x66);

      然后通过:

    int shmid = shmget(key, 4096, IPC_CREAT | 0666);

      创建共享内存。

      这里出现了两个不同的概念:

    key:用户层用于查找和约定 IPC 对象的键值。

    id:内核创建或获取 IPC 对象之后返回的标识符。

    例如:

    在这里插入图片描述

      不同进程只要使用相同的 key,就可以尝试获取同一个 IPC 对象。

      不过,key 并不保证绝对唯一,ftok() 也可能发生碰撞,因此不能把它理解成永远不会重复的唯一编号。

      另外,shmid、msqid 和 semid 都是内核返回的标识符,不应该简单地把它们当作 key 使用。

    6.4 公共数据结构:kern_ipc_perm

      在 Linux 2.6.11 的源码实现中,三种 IPC 对象都有一个共同的管理信息结构:

    struct kern_ipc_perm
    {
    spinlock_t lock;
    int deleted;
    key_t key;
    uid_t uid;
    gid_t gid;
    uid_t cuid;
    gid_t cgid;
    mode_t mode;
    unsigned long seq;
    };

      它主要记录:

    • lock:用于保护相关数据。
    • deleted:删除状态。
    • key:IPC 对象的键值。
    • uid/gid:所有者信息。
    • cuid/cgid:创建者信息。
    • mode:访问权限。
    • seq:序列号。

      为什么要设计一个公共的数据结构?

      因为消息队列、共享内存和信号量虽然用途不同,但它们都需要管理权限、标识和删除状态。

      把共同的部分抽取出来,就可以使用统一的方式进行管理。

      可以把它理解成面向对象中的继承思想,只不过这里是通过 C 语言结构体组合等方式实现的。

    6.5 共享内存与进程地址空间的关系

      最后再回顾一下上一篇文章中的共享内存。

      共享内存为什么能够让多个进程访问同一块物理内存?

      因为内核会将同一块共享内存映射到不同进程的虚拟地址空间中。

      从内核管理的角度看,可以简单理解为:

    在这里插入图片描述

      这里涉及几个重要结构:

    • shm_file:shmid_kernel 中用于关联内核文件对象的成员,指向一个 struct file 对象。Linux 内核借助它支持共享内存的映射管理。
    • mm_struct:描述和管理进程的虚拟地址空间。
    • vm_area_struct:描述进程虚拟地址空间中的一段连续区域。

      通过这些结构,内核能够管理共享内存与各个进程地址空间之间的映射关系。

      其中,shm_nattch 可以用来记录当前关联到该共享内存段的进程数量。

      以上内核数据结构主要参考 Linux 2.6.11 的实现,不同 Linux 内核版本的具体字段和组织方式可能存在差异。


    七、三种 System V IPC 的区别

      前面分别介绍了消息队列、共享内存和信号量,现在把它们放在一起比较。

    对比维度消息队列共享内存信号量
    主要作用 传递消息 共享数据 同步与互斥
    是否传递数据 是 是 否
    通信方式 内核消息队列 直接访问共享内存 通过计数器控制资源
    通信效率 需要内核参与消息传递 效率最高 主要用于控制
    同步机制 有阻塞与唤醒机制 本身不提供 本身用于同步与互斥
    生命周期 随内核 随内核 随内核

    简单记忆:

    • 消息队列:把消息放进队列,其他进程来取。
    • 共享内存:多个进程共同访问一块内存。
    • 信号量:通过计数器管理资源,控制访问顺序。

    结语:

      今天的内容到这里就结束了,希望你能有所收获~

    干货整理到手抖,觉得有用的话,赏个三连回回血?__(:ᗤ」ㄥ)_ _

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 【Linux 系统篇(三十四)】通信(四):消息队列、信号量与 IPC 资源管理
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!