Linux进程间通信(二):深入理解System V共享内存、消息队列、信号量与内核IPC资源管理

Linux进程间通信(二):深入理解System V共享内存、消息队列、信号量与内核IPC资源管理


🔥 星恒随风: 个人主页 ❄️ 个人专栏: 《指针合集》 | 《C语言基础》 | 《数据结构》 | 《机器学习导论》 | 《前端基础》 | 《python基础》 | 《C++从入门到入土》 | 《Linux的学习之旅》 ✨ 数据即知识,压缩即智能


文章目录

  • [Linux进程间通信(二):深入理解System V共享内存、消息队列、信号量与内核IPC资源管理](#Linux进程间通信(二):深入理解System V共享内存、消息队列、信号量与内核IPC资源管理)
  • [一、从管道过渡到System V IPC](#一、从管道过渡到System V IPC)
    • [1.1 回顾管道的工作方式](#1.1 回顾管道的工作方式)
    • [1.2 System V IPC的主要类型](#1.2 System V IPC的主要类型)
  • 二、共享内存是什么?
    • [2.1 先回顾普通进程的地址空间](#2.1 先回顾普通进程的地址空间)
    • [2.2 共享内存的基本思想](#2.2 共享内存的基本思想)
    • [2.3 为什么共享内存通常具有较好的通信性能?](#2.3 为什么共享内存通常具有较好的通信性能?)
  • [三、System V共享内存的核心接口](#三、System V共享内存的核心接口)
  • 四、shmget():创建或获取共享内存
    • [4.1 函数原型](#4.1 函数原型)
    • [4.2 什么是key?](#4.2 什么是key?)
    • [4.3 key和shmid有什么区别?](#4.3 key和shmid有什么区别?)
    • [4.4 IPC_CREAT与IPC_EXCL](#4.4 IPC_CREAT与IPC_EXCL)
    • [4.5 IPC_PRIVATE是什么?](#4.5 IPC_PRIVATE是什么?)
  • 五、shmat():将共享内存映射到进程地址空间
    • [5.1 函数原型](#5.1 函数原型)
    • [5.2 shmat()失败如何判断?](#5.2 shmat()失败如何判断?)
    • [5.3 映射成功以后怎样访问?](#5.3 映射成功以后怎样访问?)
  • 六、shmdt()与shmctl():解除映射和删除资源
    • [6.1 shmdt()函数](#6.1 shmdt()函数)
    • [6.2 shmctl()函数](#6.2 shmctl()函数)
    • [6.3 生命周期为什么不同于匿名管道?](#6.3 生命周期为什么不同于匿名管道?)
  • 七、通过Server和Client实现共享内存通信
    • [7.1 实验目标](#7.1 实验目标)
    • [7.2 准备实验目录](#7.2 准备实验目录)
    • [7.3 Server代码](#7.3 Server代码)
    • [7.4 Client代码](#7.4 Client代码)
    • [7.5 编译运行](#7.5 编译运行)
    • [7.6 为什么两个进程不需要read和write?](#7.6 为什么两个进程不需要read和write?)
  • [八、使用ipcs和ipcrm管理System V IPC资源](#八、使用ipcs和ipcrm管理System V IPC资源)
    • [8.1 为什么需要管理IPC资源?](#8.1 为什么需要管理IPC资源?)
    • [8.2 ipcs命令](#8.2 ipcs命令)
    • [8.3 ipcrm命令](#8.3 ipcrm命令)
    • [8.4 为什么不建议只依靠手动清理?](#8.4 为什么不建议只依靠手动清理?)
  • 九、共享内存为什么需要同步和互斥?
    • [9.1 共享内存能够共享数据,但不能自动保护数据](#9.1 共享内存能够共享数据,但不能自动保护数据)
    • [9.2 什么是临界资源?](#9.2 什么是临界资源?)
    • [9.3 什么是临界区?](#9.3 什么是临界区?)
    • [9.4 什么是互斥?](#9.4 什么是互斥?)
    • [9.5 什么是同步?](#9.5 什么是同步?)
  • 十、为什么使用管道辅助共享内存?
  • [十一、System V消息队列](#十一、System V消息队列)
    • [11.1 为什么还需要消息队列?](#11.1 为什么还需要消息队列?)
    • [11.2 消息队列的特点](#11.2 消息队列的特点)
    • [11.3 核心系统调用](#11.3 核心系统调用)
    • [11.4 消息结构](#11.4 消息结构)
  • 十二、编写一个消息队列通信实验
  • [十三、System V信号量](#十三、System V信号量)
    • [13.1 为什么需要信号量?](#13.1 为什么需要信号量?)
    • [13.2 信号量的基本思想](#13.2 信号量的基本思想)
    • [13.3 P操作](#13.3 P操作)
    • [13.4 V操作](#13.4 V操作)
  • 十四、用电影院理解信号量
  • [十五、System V信号量接口](#十五、System V信号量接口)
    • [15.1 semget()](#15.1 semget())
    • [15.2 semop()](#15.2 semop())
    • [15.3 semctl()](#15.3 semctl())
  • 十六、通过信号量实现父子进程同步
  • 十七、如何将共享内存和信号量结合起来?
    • [17.1 一个生产者、一个消费者](#17.1 一个生产者、一个消费者)
    • [17.2 为什么仅靠共享内存不够?](#17.2 为什么仅靠共享内存不够?)
    • [17.3 和课件中FIFO辅助共享内存的实验有什么区别?](#17.3 和课件中FIFO辅助共享内存的实验有什么区别?)
  • [十八、内核究竟怎样管理System V IPC资源?](#十八、内核究竟怎样管理System V IPC资源?)
    • [18.1 操作系统需要管理IPC资源](#18.1 操作系统需要管理IPC资源)
    • [18.2 不同IPC类型存在共同属性](#18.2 不同IPC类型存在共同属性)
    • [18.3 为什么还需要类型专用结构?](#18.3 为什么还需要类型专用结构?)
    • [18.4 如何理解语言多态?](#18.4 如何理解语言多态?)
  • [十九、System V IPC与POSIX IPC的区别](#十九、System V IPC与POSIX IPC的区别)
  • 二十、如何选择合适的进程间通信方式?
  • 二十一、常见错误总结
    • [21.1 共享内存就是多个进程共享同一个虚拟地址?](#21.1 共享内存就是多个进程共享同一个虚拟地址?)
    • [21.2 shmget()成功就可以直接访问共享内存?](#21.2 shmget()成功就可以直接访问共享内存?)
    • [21.3 shmat()失败返回NULL?](#21.3 shmat()失败返回NULL?)
    • [21.4 shmdt()会删除共享内存?](#21.4 shmdt()会删除共享内存?)
    • [21.5 进程退出以后,System V IPC资源一定会自动删除?](#21.5 进程退出以后,System V IPC资源一定会自动删除?)
    • [21.6 共享内存天然支持同步和互斥?](#21.6 共享内存天然支持同步和互斥?)
    • [21.7 消息队列和管道完全一样?](#21.7 消息队列和管道完全一样?)
    • [21.8 msgsnd()的长度应该使用整个消息结构体的sizeof?](#21.8 msgsnd()的长度应该使用整个消息结构体的sizeof?)
    • [21.9 信号量只是一个普通int变量?](#21.9 信号量只是一个普通int变量?)
    • [21.10 P操作一定是简单地将值减1?](#21.10 P操作一定是简单地将值减1?)
  • 二十二、完整知识体系总结
  • 总结

前言:为什么有了管道,还需要其他IPC机制?

在上一篇文章中,我们学习了 Linux 中的管道通信:

c 复制代码
pipe()

以及命名管道:

c 复制代码
mkfifo()

还通过匿名管道实现了简单进程池。

我们已经知道,两个进程可以借助内核管道交换数据。

例如:

text 复制代码
进程A写入管道
      |
      v
内核管道缓冲区
      |
      v
进程B读取管道

这种设计简单、实用,但是还有一些问题。

例如:

  • 如果两个进程需要频繁交换大量数据,是否有更直接的通信方式?
  • 如果我们希望区分不同类型的消息,该怎么办?
  • 如果多个进程同时访问同一份数据,怎样避免发生冲突?
  • 进程退出后,操作系统是否会自动清除全部 IPC 资源?
  • 内核究竟如何管理多个进程共同使用的 IPC 对象?

这些问题,就需要进一步学习:

System V IPC。

System V IPC 主要包括三种机制:

  1. 共享内存(Shared Memory)。
  2. 消息队列(Message Queue)。
  3. 信号量(Semaphore)。

本篇文章以共享内存为重点,进一步介绍消息队列、信号量和内核 IPC 资源管理机制。


一、从管道过渡到System V IPC

1.1 回顾管道的工作方式

假设进程 A 需要将一批数据传递给进程 B。

使用管道时,通常需要:

c 复制代码
write(fd, buffer, size);

进程 B 则调用:

c 复制代码
read(fd, buffer, size);

数据通过内核管道缓冲区传递。

这里需要经过相应的系统调用,内核负责管理数据在管道中的读写。

如果两个进程频繁交换大量数据,那么不断进行数据传输和系统调用就可能产生额外开销。

于是我们可以思考:

能不能让两个进程直接访问同一块内存?

这就是共享内存的核心思想。

1.2 System V IPC的主要类型

IPC类型 主要作用 常见接口
共享内存 多个进程访问同一块共享存储区域 shmget()、shmat()、shmdt()、shmctl()
消息队列 进程之间传递带有类型信息的消息 msgget()、msgsnd()、msgrcv()、msgctl()
信号量 协调进程对共享资源的访问 semget()、semop()、semctl()

这三种机制解决的问题并不完全相同。

共享内存重点解决数据共享,消息队列重点解决消息传递,信号量重点解决同步与互斥。


二、共享内存是什么?

2.1 先回顾普通进程的地址空间

我们已经知道,不同进程具有独立的虚拟地址空间。

例如:

c 复制代码
int value = 100;

对于两个独立进程来说,即使它们各自拥有名称相同的变量,也不意味着它们操作的是同一块物理内存。

因此:

text 复制代码
进程A的value

和:

text 复制代码
进程B的value

一般不会自动互相影响。

但共享内存允许两个进程将同一块共享存储区域映射到各自的地址空间。

2.2 共享内存的基本思想

例如:

text 复制代码
进程A虚拟地址空间
        |
        v
    共享内存映射
        |
        v
    同一组物理页
        ^
        |
    共享内存映射
        ^
        |
进程B虚拟地址空间

两个进程的虚拟地址可以不同。

但它们最终能够访问同一块共享存储区域。

所以:

共享内存并不是让两个进程共享全部虚拟地址空间,而是在各自的地址空间中建立指向共同存储区域的映射。

2.3 为什么共享内存通常具有较好的通信性能?

对于管道:

text 复制代码
进程A
   |
   | write
   v
内核管道缓冲
   |
   | read
   v
进程B

而共享内存建立完成后:

text 复制代码
进程A
   |
   v
共享内存
   ^
   |
进程B

双方可以通过普通内存访问指令读写共享区域。

因此,在完成必要映射后,交换共享数据本身通常不需要每次都调用 read()、write() 把数据从一个进程传递到另一个进程。

这减少了额外的数据搬运和系统调用开销。

但是需要注意:

共享内存不代表通信过程完全不需要操作系统,也不代表它在所有场景下都一定比管道更快。

创建、映射、解除映射和资源管理仍然需要内核参与。

此外,进程间同步、缓存一致性、数据布局和并发竞争同样可能影响性能。


三、System V共享内存的核心接口

要使用 System V 共享内存,主要需要掌握四个函数:

c 复制代码
shmget()
shmat()
shmdt()
shmctl()

可以先从整体流程理解。

text 复制代码
创建或获取共享内存
        |
        v
shmget()
        |
        v
取得shmid
        |
        v
shmat()
        |
        v
映射到当前进程地址空间
        |
        v
读写共享数据
        |
        v
shmdt()
        |
        v
解除当前进程的映射
        |
        v
shmctl()
        |
        v
按需要删除IPC资源

这里要注意:

解除共享内存映射,与删除共享内存对象,是两个不同操作。


四、shmget():创建或获取共享内存

4.1 函数原型

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

int shmget(key_t key, size_t size, int shmflg);

参数:

参数 作用
key System V IPC资源键值
size 共享内存大小
shmflg 创建标志和权限等信息

返回值:

text 复制代码
成功:返回非负整数shmid

失败:返回-1

这里的:

c 复制代码
shmid

表示共享内存对象的标识符。

4.2 什么是key?

假设 Server 和 Client 是两个独立启动的进程。

它们需要找到同一块共享内存。

那么,二者就需要约定一个可以用于查找共享内存的键值。

例如:

c 复制代码
key_t key = ftok("./ipc.key", 'M');

ftok() 可以根据路径所指对象的相关信息和项目标识生成一个 System V IPC key。

函数原型:

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

key_t ftok(const char* pathname, int proj_id);

这里:

text 复制代码
pathname

应当指向已经存在的文件系统对象。

而:

text 复制代码
proj_id

用于参与生成 key。

注意:

ftok不保证不同输入一定产生不同的key,它不是全局唯一ID生成器。

实际程序仍然需要考虑键值冲突。

4.3 key和shmid有什么区别?

这两个概念非常容易混淆。

text 复制代码
key

主要用于让进程能够通过约定的键值查找或创建某个 IPC 资源。

而:

text 复制代码
shmid

是内核为共享内存对象提供的标识符,供后续共享内存操作使用。

可以理解为:

text 复制代码
key
 |
 | shmget
 v
shmid
 |
 +-- shmat
 |
 +-- shmctl

所以:

key用于查找IPC对象,shmid用于操作已经取得的共享内存对象。

4.4 IPC_CREAT与IPC_EXCL

常见写法:

c 复制代码
IPC_CREAT

表示如果对应 key 的共享内存不存在,可以创建它。

如果已经存在,则尝试获取已有资源。

如果使用:

c 复制代码
IPC_CREAT | IPC_EXCL

则表示要求创建一个新的对象。

如果相同 key 的共享内存已经存在,调用通常会失败,并设置:

c 复制代码
errno = EEXIST;

例如:

c 复制代码
int shmid = shmget(
    key,
    4096,
    IPC_CREAT | IPC_EXCL | 0600
);

这里:

text 复制代码
4096

表示请求的共享内存大小。

text 复制代码
0600

表示请求创建的访问权限位。

4.5 IPC_PRIVATE是什么?

还有一种特殊 key:

c 复制代码
IPC_PRIVATE

例如:

c 复制代码
shmget(IPC_PRIVATE, 4096, IPC_CREAT | 0600);

它会请求创建一个新的共享内存对象。

虽然名称中包含:

text 复制代码
PRIVATE

但不能因此认为:

text 复制代码
只有当前进程能够访问

它并不是进程私有内存的意思。

持有相应共享内存标识符并具有访问权限的其他进程,仍然可能访问该 IPC 对象。


五、shmat():将共享内存映射到进程地址空间

5.1 函数原型

c 复制代码
#include <sys/shm.h>

void* shmat(
    int shmid,
    const void* shmaddr,
    int shmflg
);

参数:

参数 作用
shmid 共享内存标识符
shmaddr 希望使用的映射地址
shmflg 映射选项

通常可以写:

c 复制代码
void* addr = shmat(shmid, NULL, 0);

其中:

c 复制代码
NULL

表示让内核选择合适的映射地址。

成功后返回映射到当前进程地址空间中的地址。

5.2 shmat()失败如何判断?

这是一个很容易写错的地方。

正确写法:

c 复制代码
void* addr = shmat(shmid, NULL, 0);

if (addr == (void*)-1)
{
    perror("shmat");
    return 1;
}

为什么不是:

c 复制代码
if (addr == NULL)

因为 shmat() 的失败返回值是:

c 复制代码
(void*)-1

而不是 NULL。

这与很多常见的指针返回函数不同,需要特别记忆。

5.3 映射成功以后怎样访问?

例如:

c 复制代码
char* buffer = (char*)addr;

现在可以使用:

c 复制代码
strcpy(buffer, "hello shared memory");

写入共享区域。

其他已经映射同一共享内存对象的进程,可以通过自己的映射地址读取这段数据。

当然,实际写入需要保证:

  • 不越过共享内存边界。
  • 双方约定相同的数据格式。
  • 读写操作之间具有正确的同步关系。

六、shmdt()与shmctl():解除映射和删除资源

6.1 shmdt()函数

函数原型:

c 复制代码
int shmdt(const void* shmaddr);

作用:

解除指定共享内存段与当前进程地址空间之间的映射关系。

例如:

c 复制代码
shmdt(addr);

成功返回:

text 复制代码
0

失败返回:

text 复制代码
-1

但这里有一个非常重要的结论:

shmdt()只解除当前进程的共享内存映射,不等于删除共享内存对象。

6.2 shmctl()函数

函数原型:

c 复制代码
int shmctl(
    int shmid,
    int cmd,
    struct shmid_ds* buf
);

可以用于查询、修改或删除共享内存资源。

常见命令:

命令 作用
IPC_STAT 查询共享内存状态信息
IPC_SET 修改部分可设置的属性
IPC_RMID 标记共享内存对象以供删除

例如:

c 复制代码
shmctl(shmid, IPC_RMID, NULL);

表示请求删除该共享内存对象。

如果仍有进程附着到共享内存,Linux 通常会在相关映射全部解除后才最终销毁对应对象。

因此:

text 复制代码
删除IPC对象

和:

text 复制代码
解除当前进程映射

并不是同一回事。

6.3 生命周期为什么不同于匿名管道?

对于匿名管道,相关文件描述符全部关闭、管道不再被引用之后,内核会回收相应资源。

但 System V 共享内存具有内核管理的持久 IPC 对象语义。

即使创建它的进程退出,共享内存对象也可能继续存在。

如果没有正确执行资源删除操作,就可能留下不再使用的 IPC 资源。

也就是说:

System V IPC资源的生命周期通常不随某一个进程的退出而立即结束。


七、通过Server和Client实现共享内存通信

前面只学习了接口,现在实现两个独立进程访问同一块共享内存。

7.1 实验目标

我们希望:

text 复制代码
Server
   |
   | 创建共享内存
   v
Shared Memory
   ^
   | 读取共享数据
   |
Client

注意:本实验先通过人为规定启动顺序避免并发读写。

目的是观察共享内存的映射和数据共享机制。

它不是完整的并发同步实现。

7.2 准备实验目录

bash 复制代码
mkdir -p ~/ipc_shm
cd ~/ipc_shm

创建供 ftok() 使用的文件:

bash 复制代码
touch ipc.key

Server 和 Client 都必须通过同一个文件系统对象生成 key。


7.3 Server代码

server.c:

c 复制代码
#include <stdio.h>
#include <sys/ipc.h>
#include <sys/shm.h>

#define SHM_SIZE 4096

int main(void)
{
    // 1. 生成key
    key_t key = ftok("./ipc.key", 'M');

    if (key == (key_t)-1)
    {
        perror("ftok");
        return 1;
    }

    // 2. 创建共享内存
    int shmid = shmget(
        key,
        SHM_SIZE,
        IPC_CREAT | IPC_EXCL | 0600
    );

    if (shmid < 0)
    {
        perror("shmget");
        return 1;
    }

    // 3. 映射共享内存
    void* addr = shmat(shmid, NULL, 0);

    if (addr == (void*)-1)
    {
        perror("shmat");
        shmctl(shmid, IPC_RMID, NULL);
        return 1;
    }

    // 4. 写入一段测试数据
    snprintf(
        (char*)addr,
        SHM_SIZE,
        "hello from shared memory"
    );

    printf("server: shmid = %d\n", shmid);
    printf("server: data is ready\n");
    printf("run client, then press Enter to clean up\n");

    // 暂停,等待Client读取
    getchar();

    // 5. 解除映射
    if (shmdt(addr) < 0)
    {
        perror("shmdt");
    }

    // 6. 删除IPC资源
    if (shmctl(shmid, IPC_RMID, NULL) < 0)
    {
        perror("shmctl");
        return 1;
    }

    return 0;
}

7.4 Client代码

client.c:

c 复制代码
#include <stdio.h>
#include <sys/ipc.h>
#include <sys/shm.h>

#define SHM_SIZE 4096

int main(void)
{
    // 1. 获取约定的key
    key_t key = ftok("./ipc.key", 'M');

    if (key == (key_t)-1)
    {
        perror("ftok");
        return 1;
    }

    // 2. 获取已有共享内存
    int shmid = shmget(key, SHM_SIZE, 0);

    if (shmid < 0)
    {
        perror("shmget");
        return 1;
    }

    // 3. 映射共享内存
    void* addr = shmat(shmid, NULL, SHM_RDONLY);

    if (addr == (void*)-1)
    {
        perror("shmat");
        return 1;
    }

    // 4. 读取共享数据
    printf(
        "client received: %s\n",
        (const char*)addr
    );

    // 5. 解除映射
    if (shmdt(addr) < 0)
    {
        perror("shmdt");
        return 1;
    }

    return 0;
}

这里 Client 使用:

c 复制代码
SHM_RDONLY

以只读方式附着到共享内存,符合当前实验只读取数据的需要。

7.5 编译运行

编译:

bash 复制代码
gcc -Wall -Wextra server.c -o server
gcc -Wall -Wextra client.c -o client

打开两个终端,进入相同实验目录。

终端一:

bash 复制代码
./server

应该显示类似:

text 复制代码
server: shmid = 12345
server: data is ready
run client, then press Enter to clean up

保持 Server 运行,不要立即按 Enter。

终端二:

bash 复制代码
./client

预期:

text 复制代码
client received: hello from shared memory

然后回到终端一按 Enter,让 Server 解除映射并删除共享内存对象。

7.6 为什么两个进程不需要read和write?

因为:

c 复制代码
shmat()

已经让两个进程能够访问同一块共享内存。

因此:

c 复制代码
snprintf((char*)addr, ...);

写入的是共享区域。

而:

c 复制代码
printf("%s", (const char*)addr);

读取的是该共享区域中的数据。

不需要通过管道把数据重新传输一次。

但要强调:

共享内存本身并不解决"什么时候可以安全读取"的问题。

当前实验只是通过启动顺序与人工等待保证先写后读。

如果 Server 和 Client 同时频繁读写,就需要进一步设计同步机制。


八、使用ipcs和ipcrm管理System V IPC资源

8.1 为什么需要管理IPC资源?

假设 Server 创建共享内存后,突然被:

bash 复制代码
kill -9

终止。

如果没有提前把共享内存标记为删除,IPC 对象可能继续存在。

下一次 Server 调用:

c 复制代码
shmget(
    key,
    SHM_SIZE,
    IPC_CREAT | IPC_EXCL | 0600
);

就可能出现:

text 复制代码
File exists

这是因为对应 key 的共享内存对象仍然存在。

8.2 ipcs命令

查看所有 System V IPC 类型:

bash 复制代码
ipcs

查看共享内存:

bash 复制代码
ipcs -m

查看消息队列:

bash 复制代码
ipcs -q

查看信号量:

bash 复制代码
ipcs -s

例如:

text 复制代码
------ Shared Memory Segments --------
key        shmid      owner      perms      bytes
...

这里:

text 复制代码
key

是 IPC 键值。

text 复制代码
shmid

是共享内存标识符。

text 复制代码
bytes

表示共享内存大小。

8.3 ipcrm命令

假设确定某个实验共享内存的 shmid 是:

text 复制代码
12345

可以执行:

bash 复制代码
ipcrm -m 12345

请求删除这个共享内存资源。

注意:

只应删除自己确认不再使用、且有权限管理的IPC资源,不要随意清理其他进程的资源。

8.4 为什么不建议只依靠手动清理?

因为实际程序应该负责管理自己创建的资源。

合理的程序通常需要设计:

text 复制代码
创建资源
    |
    v
使用资源
    |
    v
解除映射
    |
    v
删除资源

以及发生错误时的资源清理路径。

例如:

c 复制代码
shmctl(shmid, IPC_RMID, NULL);

这正是 System V IPC 与普通局部变量最大的不同之一。


九、共享内存为什么需要同步和互斥?

9.1 共享内存能够共享数据,但不能自动保护数据

假设两个进程都能访问共享内存中的:

c 复制代码
int counter;

两个进程都执行:

c 复制代码
counter++;

从源代码上看,似乎只是增加 1。

但这个操作在一般情况下并不是一个不可分割的跨进程原子操作。

它可能涉及:

text 复制代码
读取counter
计算counter+1
写回counter

如果两个进程并发执行,就可能产生竞争。

例如:

text 复制代码
counter原来等于0

进程A读取0
进程B读取0

进程A计算1
进程B计算1

进程A写回1
进程B写回1

最终:

text 复制代码
counter = 1

而不是预期的:

text 复制代码
counter = 2

这种情况属于并发访问中的典型竞争问题。

9.2 什么是临界资源?

多个进程都能够访问,并且需要适当协调访问的资源,可以视为需要保护的共享资源。

例如:

text 复制代码
共享内存中的计数器
共享任务队列
共享数据结构

其中,某些资源或操作要求在特定时间内只允许一个执行流访问或修改。

这就需要互斥控制。

9.3 什么是临界区?

例如:

c 复制代码
counter++;

如果这段代码操作了需要互斥保护的共享资源,那么它就属于需要保护的临界区代码。

注意:

临界区是访问共享资源的代码区域,不是共享内存中的某个固定物理区域。

因此,保护共享资源,实际上就是保护访问这些资源的关键操作。

9.4 什么是互斥?

互斥要求:

在需要排他访问的时间段内,不允许多个执行流同时进入相应临界区。

例如:

text 复制代码
进程A进入临界区
      |
      v
进程B必须等待
      |
      v
进程A离开临界区
      |
      v
进程B才能进入

9.5 什么是同步?

同步主要关注不同执行流之间的执行顺序或条件关系。

例如:

text 复制代码
生产者先生成数据
        |
        v
通知消费者数据已准备好
        |
        v
消费者再读取数据

这里强调:

text 复制代码
先写入

与:

text 复制代码
后读取

之间的顺序约束。

互斥与同步密切相关,但不完全相同。


十、为什么使用管道辅助共享内存?

还有一个实验:

借助管道辅助共享内存访问控制。

其基本思路是:

  1. Server 和 Client 都映射同一块共享内存。
  2. Client 将数据写入共享内存。
  3. Client 通过管道发送通知。
  4. Server 收到通知后读取共享数据。

可以理解为:

text 复制代码
Client写共享内存
        |
        v
Client发送管道通知
        |
        v
Server收到通知
        |
        v
Server读取共享内存

这里结合了两种 IPC:

共享内存负责保存实际数据。

管道负责通知另一进程。

这个思路十分重要,因为共享内存本身没有自动提供消息到达通知。

但是需要注意:

这只是同步机制的入门示例。

如果 Client 可以在 Server 读取期间再次修改同一份共享数据,就仍然可能发生竞争。

因此,单向通知并不等同于严格互斥。

实际需要根据读写方式,进一步使用:

  • 信号量。
  • 进程间互斥锁。
  • 双向确认协议。
  • 合理设计的原子变量与内存同步机制。

等方式协调访问。

本篇接下来重点介绍 System V 信号量。


十一、System V消息队列

课件将消息队列列为选学内容,这里先掌握其基本原理与接口。

11.1 为什么还需要消息队列?

管道提供的是:

text 复制代码
字节流

它不会自动替程序区分:

text 复制代码
消息A
消息B
消息C

的边界。

但有些应用希望按照一条一条独立消息进行通信。

例如:

text 复制代码
消息1:登录请求

消息2:查询请求

消息3:退出请求

这时可以使用消息队列。

11.2 消息队列的特点

System V 消息队列由内核管理。

发送方可以向队列中发送消息。

接收方可以根据消息类型等规则读取消息。

与简单管道不同,消息队列保留了消息边界,并且每条消息具有一个正的消息类型。

11.3 核心系统调用

c 复制代码
#include <sys/msg.h>

创建或获取队列:

c 复制代码
int msgget(key_t key, int msgflg);

发送消息:

c 复制代码
int msgsnd(
    int msqid,
    const void* msgp,
    size_t msgsz,
    int msgflg
);

接收消息:

c 复制代码
ssize_t msgrcv(
    int msqid,
    void* msgp,
    size_t msgsz,
    long msgtyp,
    int msgflg
);

控制或删除消息队列:

c 复制代码
int msgctl(
    int msqid,
    int cmd,
    struct msqid_ds* buf
);

11.4 消息结构

例如:

c 复制代码
struct Message
{
    long mtype;
    char mtext[64];
};

其中:

c 复制代码
mtype

必须放在消息结构的最前面,用于表示消息类型。

c 复制代码
mtext

保存消息正文。

注意:

调用 msgsnd() 时,msgsz 参数不包含 mtype 所占的字节数。

这与很多初学者直接传递 sizeof(struct Message) 的做法不同。


十二、编写一个消息队列通信实验

为了减少文件数量,先使用 fork() 在同一个程序中创建两个进程。

这里使用:

c 复制代码
IPC_PRIVATE

创建新的消息队列。

msg_demo.c:

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#include <sys/ipc.h>
#include <sys/msg.h>
#include <sys/wait.h>
#include <unistd.h>

struct Message
{
    long mtype;
    char mtext[64];
};

int main(void)
{
    // 1. 创建新的消息队列
    int id = msgget(
        IPC_PRIVATE,
        IPC_CREAT | 0600
    );

    if (id < 0)
    {
        perror("msgget");
        return 1;
    }

    fflush(stdout);

    pid_t pid = fork();

    if (pid < 0)
    {
        perror("fork");
        msgctl(id, IPC_RMID, NULL);
        return 1;
    }

    if (pid == 0)
    {
        // 子进程发送消息
        struct Message msg = {
            1,
            "hello message queue"
        };

        size_t len = strlen(msg.mtext) + 1;

        if (msgsnd(id, &msg, len, 0) < 0)
        {
            perror("msgsnd");
            _exit(1);
        }

        _exit(0);
    }

    // 父进程接收类型为1的消息
    struct Message received;

    ssize_t n = msgrcv(
        id,
        &received,
        sizeof(received.mtext),
        1,
        0
    );

    if (n < 0)
    {
        perror("msgrcv");
    }
    else
    {
        printf(
            "message: %s\n",
            received.mtext
        );
    }

    waitpid(pid, NULL, 0);

    // 删除消息队列
    msgctl(id, IPC_RMID, NULL);

    return n < 0 ? 1 : 0;
}

编译:

bash 复制代码
gcc -Wall -Wextra msg_demo.c -o msg_demo

运行:

bash 复制代码
./msg_demo

预期:

text 复制代码
message: hello message queue

这里需要注意:

msgrcv() 中的:

c 复制代码
msgtyp = 1

表示接收符合类型条件的消息。

如果使用:

c 复制代码
msgtyp = 0

则通常按照队列顺序接收第一条消息。

消息队列还支持非阻塞等选项,具体可以继续查阅 msgrcv(2)。


十三、System V信号量

13.1 为什么需要信号量?

前面共享内存部分已经知道:

c 复制代码
counter++;

可能在多进程并发访问时产生竞争。

因此,我们需要一种机制协调多个执行流的访问。

信号量就是其中一种经典方案。

13.2 信号量的基本思想

可以先将信号量理解为:

由操作系统管理、支持原子操作的计数型同步资源。

例如:

text 复制代码
信号量初始值 = 1

表示有一个可用资源许可。

当某个进程申请资源时:

text 复制代码
信号量减1

当某个进程释放资源时:

text 复制代码
信号量加1

但信号量并不只是普通整数。

关键区别在于:

信号量操作由内核以规定的原子语义执行,并且可以使执行流在资源条件不满足时等待。

13.3 P操作

P 操作通常用于申请资源。

可以理解为:

text 复制代码
检查是否有可用资源
        |
        +-- 有
        |    |
        |    v
        |  计数减1
        |    |
        |    v
        |  继续执行
        |
        +-- 没有
             |
             v
           等待

对于二元信号量,常见初始值为:

text 复制代码
1

某个进程执行 P 操作后变为:

text 复制代码
0

其他需要获取同一许可的进程就可能等待。

13.4 V操作

V 操作通常用于释放资源。

可以理解为:

text 复制代码
资源使用完成
      |
      v
计数加1
      |
      v
允许等待的执行流继续竞争资源

需要注意:

当存在多个等待者时,不应该把信号量理解为严格按照进程创建顺序依次唤醒。

具体调度行为还涉及内核调度机制。


十四、用电影院理解信号量

假设电影院只有:

text 复制代码
3个座位

那么可以设置:

text 复制代码
Semaphore = 3

第一位顾客进入:

text 复制代码
Semaphore = 2

第二位顾客进入:

text 复制代码
Semaphore = 1

第三位顾客进入:

text 复制代码
Semaphore = 0

第四位顾客想进入时,需要等待。

直到有人离开:

text 复制代码
Semaphore = 1

才再次出现可用许可。

因此,计数型信号量可以用于管理有限数量的并发资源。

如果初始计数设置为:

text 复制代码
1

就可以用来实现简单的互斥访问控制。

但实际使用时仍需要确保所有相关进程都遵守同一套申请与释放协议。


十五、System V信号量接口

15.1 semget()

函数原型:

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

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

作用:

创建或获取一个信号量集合。

例如:

c 复制代码
int semid = semget(
    IPC_PRIVATE,
    1,
    IPC_CREAT | 0600
);

这里:

text 复制代码
nsems = 1

表示创建包含一个信号量的集合。

System V 信号量操作的资源单位可以是一个信号量集合,而不一定只有单个信号量。

15.2 semop()

函数原型:

c 复制代码
int semop(
    int semid,
    struct sembuf* sops,
    size_t nsops
);

其中:

c 复制代码
struct sembuf
{
    unsigned short sem_num;
    short sem_op;
    short sem_flg;
};

是信号量操作描述结构。

例如 P 操作:

c 复制代码
struct sembuf p = {
    0,
    -1,
    0
};

表示对集合中编号为 0 的信号量尝试执行减 1 操作。

V 操作:

c 复制代码
struct sembuf v = {
    0,
    1,
    0
};

表示加 1。

semop() 可以将一组信号量操作作为一个原子操作序列提交给内核。

15.3 semctl()

函数原型:

c 复制代码
int semctl(
    int semid,
    int semnum,
    int cmd,
    ...
);

可以用于:

  • 初始化信号量的值。
  • 获取信号量相关状态。
  • 修改部分信号量配置。
  • 删除信号量集合。

常用命令:

c 复制代码
SETVAL
GETVAL
IPC_RMID

其中:

c 复制代码
SETVAL

可以设置指定信号量的初始值。

IPC_RMID 用于删除整个信号量集合。


十六、通过信号量实现父子进程同步

这个实验不涉及复杂共享数据,先验证:

子进程必须等到父进程发送许可以后才能继续执行。

sem_demo.c:

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>

#include <sys/ipc.h>
#include <sys/sem.h>
#include <sys/wait.h>
#include <unistd.h>

// 某些系统头文件不会直接定义semun
union semun
{
    int val;
    struct semid_ds* buf;
    unsigned short* array;
};

// 对信号量执行P或V操作
int ChangeSem(int semid, short delta)
{
    struct sembuf op = {
        0,
        delta,
        0
    };

    while (semop(semid, &op, 1) < 0)
    {
        if (errno == EINTR)
        {
            continue;
        }

        perror("semop");
        return -1;
    }

    return 0;
}

int main(void)
{
    // 1. 创建一个信号量
    int semid = semget(
        IPC_PRIVATE,
        1,
        IPC_CREAT | 0600
    );

    if (semid < 0)
    {
        perror("semget");
        return 1;
    }

    // 2. 初始化为0:暂时没有许可
    union semun arg;

    arg.val = 0;

    if (semctl(semid, 0, SETVAL, arg) < 0)
    {
        perror("semctl");
        semctl(semid, 0, IPC_RMID);
        return 1;
    }

    fflush(stdout);

    pid_t pid = fork();

    if (pid < 0)
    {
        perror("fork");
        semctl(semid, 0, IPC_RMID);
        return 1;
    }

    if (pid == 0)
    {
        // 子进程执行P操作
        if (ChangeSem(semid, -1) < 0)
        {
            _exit(1);
        }

        printf("child: received signal\n");
        fflush(stdout);

        _exit(0);
    }

    printf("parent: release token\n");
    fflush(stdout);

    // 父进程执行V操作
    ChangeSem(semid, 1);

    waitpid(pid, NULL, 0);

    // 删除信号量集合
    semctl(semid, 0, IPC_RMID);

    return 0;
}

编译:

bash 复制代码
gcc -Wall -Wextra sem_demo.c -o sem_demo

运行:

bash 复制代码
./sem_demo

预期输出:

text 复制代码
parent: release token
child: received signal

这里的重要逻辑是:

text 复制代码
初始信号量 = 0
        |
        v
子进程执行P操作
        |
        v
没有资源许可,等待
        |
        v
父进程执行V操作
        |
        v
信号量增加
        |
        v
子进程可以继续

这说明信号量不仅可以用于互斥,也可以用于执行流之间的同步通知。


十七、如何将共享内存和信号量结合起来?

现在我们已经掌握两种机制:

text 复制代码
共享内存

用于保存共享数据。

text 复制代码
信号量

用于协调访问顺序。

可以将它们组合起来。

17.1 一个生产者、一个消费者

例如,Producer 负责写数据:

text 复制代码
Producer

Consumer 负责读取数据:

text 复制代码
Consumer

我们希望:

  1. Producer 先写入共享内存。
  2. 写入完成后通知 Consumer。
  3. Consumer 收到通知再读取数据。
  4. Consumer 处理完成后通知 Producer 可以写入下一批数据。

可以设计两个信号量:

text 复制代码
empty:表示共享缓冲区可写

full:表示共享缓冲区有数据可读

初始值:

text 复制代码
empty = 1
full = 0

生产者:

text 复制代码
P(empty)
    |
    v
写共享内存
    |
    v
V(full)

消费者:

text 复制代码
P(full)
    |
    v
读共享内存
    |
    v
V(empty)

这样就可以形成一套基本的单槽缓冲区同步协议。

17.2 为什么仅靠共享内存不够?

因为共享内存只能提供共同的数据访问区域。

它无法自动判断:

text 复制代码
数据是否写完?
消费者是否已经读完?
生产者是否可以覆盖旧数据?

信号量可以用来建立这些访问条件之间的关系。

17.3 和课件中FIFO辅助共享内存的实验有什么区别?

课件中的 FIFO 方案重点体现:

text 复制代码
写入数据
   |
   v
发送通知
   |
   v
读取数据

而两个信号量组成的生产者-消费者协议,还能够建立:

text 复制代码
生产者等待可写
消费者等待可读

的双向协调关系。

因此,在单槽共享缓冲区的教学场景中,这种方案更适合解释同步控制。


十八、内核究竟怎样管理System V IPC资源?

最后回到课件中的一个重要问题:

Linux 内核如何组织和管理共享内存、消息队列和信号量这些不同类型的对象?

18.1 操作系统需要管理IPC资源

假设系统中创建了:

text 复制代码
10个共享内存对象
5个消息队列
8个信号量集合

内核需要保存每个 IPC 对象的相关信息。

例如:

  • IPC 标识符。
  • 对象 key。
  • 所有者和所属组。
  • 访问权限。
  • 对象当前状态。
  • 对应类型特有的管理信息。

因此,内核不能简单地把这些对象当成几块完全没有关系的原始内存。

它需要对应的数据结构和资源查找机制。

18.2 不同IPC类型存在共同属性

虽然共享内存、消息队列和信号量功能不同,但都需要某些共同管理信息。

例如:

text 复制代码
key
uid
gid
权限
IPC对象标识

因此,内核实现可以抽象出某些公共管理结构。

课件结合较早版本的 Linux 内核源码,介绍了类似:

c 复制代码
struct kern_ipc_perm

这样的内部结构。

它用于表达 System V IPC 对象的权限、标识等共同信息。

但需要注意:

内核内部结构的成员和组织形式可能随 Linux 版本变化。

不能直接把较老版本内核中的结构定义视为所有现代 Linux 版本的固定 ABI。

18.3 为什么还需要类型专用结构?

因为三种 IPC 的功能并不相同。

共享内存需要管理:

text 复制代码
共享内存大小
映射或附着情况
相关共享页和内存管理状态

消息队列需要管理:

text 复制代码
队列中的消息
消息数量
消息类型
消息队列容量

信号量需要管理:

text 复制代码
信号量集合
当前计数值
等待中的操作

因此可以将其理解为:

text 复制代码
共同的IPC管理信息
        |
        +-- 共享内存特有信息
        |
        +-- 消息队列特有信息
        |
        +-- 信号量特有信息

这是一种常见的系统软件设计思路:

将共同属性进行抽象,同时保留不同对象自己的专有状态与操作。

18.4 如何理解语言多态?

在 C++ 中,我们可以通过:

cpp 复制代码
virtual

和虚函数实现多态。

但是 Linux 内核主要使用 C 语言实现。

C 没有 C++ 的虚函数语法。

不过它仍然可以利用:

text 复制代码
公共结构成员
结构体嵌入
函数指针
不同对象对应不同操作实现

等机制表达相似的抽象设计。

例如下面只是一个用于理解的简化示例,并不是 Linux 内核 IPC 的真实结构定义:

c 复制代码
struct ipc_object;

struct ipc_ops
{
    void (*destroy)(struct ipc_object*);
};

struct ipc_object
{
    int id;
    const struct ipc_ops* ops;
};

不同对象可以设置不同的:

c 复制代码
destroy

实现。

调用者通过统一入口:

c 复制代码
obj->ops->destroy(obj);

实现针对不同对象的操作分发。

这种设计思想可以帮助我们理解:

即使不使用C++虚函数,C语言仍然能够借助数据结构和函数指针实现一定程度的多态抽象。

但实际 System V IPC 内核代码的组织方式,需要结合具体内核版本查看,不能把上面的教学示例直接当作内核源码。


十九、System V IPC与POSIX IPC的区别

前面介绍了 System V IPC,但现代 Linux 也支持 POSIX IPC。

它们都可以用于进程间通信,但接口和部分资源管理语义不同。

例如:

类型 System V POSIX
共享内存 shmget()、shmat() shm_open()、mmap()
消息队列 msgget()、msgsnd() mq_open()、mq_send()
信号量 semget()、semop() sem_open()、sem_wait()

这里不展开 POSIX IPC 的全部实现。

但至少应该知道:

System V IPC 并不是 Linux 唯一的进程间通信接口体系。

根据项目需求,实际开发中可能选择 System V IPC、POSIX IPC、管道或 Socket 等不同机制。


二十、如何选择合适的进程间通信方式?

现在我们已经了解了几种典型 IPC。

可以进行一个初步比较。

通信方式 主要特点 适合的场景
匿名管道 字节流、使用文件描述符 父子进程通信、Shell管道
命名管道 通过文件系统路径访问字节流 独立进程之间的简单通信
System V共享内存 多进程访问共同内存区域 高频、大量数据共享
System V消息队列 保留消息边界和类型 进程之间传递结构化消息
System V信号量 同步、互斥、资源计数 控制共享资源访问顺序

需要强调:

共享内存不一定能独立完成整个通信协议。

如果应用要求多个进程安全读写共享数据,通常还需要合适的同步机制。

而消息队列与管道虽然更便于表达数据传递关系,但也需要处理容量、阻塞、失败和清理等问题。


二十一、常见错误总结

21.1 共享内存就是多个进程共享同一个虚拟地址?

不是。

不同进程可以通过不同的虚拟地址映射同一块共享存储区域。

21.2 shmget()成功就可以直接访问共享内存?

不可以。

shmget() 返回的是:

c 复制代码
shmid

需要通过:

c 复制代码
shmat()

取得当前进程可使用的映射地址。

21.3 shmat()失败返回NULL?

不是。

失败返回:

c 复制代码
(void*)-1

21.4 shmdt()会删除共享内存?

不会。

它解除当前进程的映射。

资源删除需要适当的管理操作,例如:

c 复制代码
shmctl(shmid, IPC_RMID, NULL);

21.5 进程退出以后,System V IPC资源一定会自动删除?

不是。

System V IPC 资源可以独立于创建进程的生命周期继续存在。

21.6 共享内存天然支持同步和互斥?

不支持。

需要另行设计访问协调机制。

21.7 消息队列和管道完全一样?

不是。

管道主要提供字节流。

System V 消息队列保留消息边界,并包含消息类型信息。

21.8 msgsnd()的长度应该使用整个消息结构体的sizeof?

通常不应该。

msgsz 表示消息正文的字节数,不包含最前面的 long mtype。

21.9 信号量只是一个普通int变量?

不是。

信号量的核心在于内核提供的原子操作、资源计数和等待机制。

21.10 P操作一定是简单地将值减1?

不完全准确。

对于 System V 信号量,semop() 需要遵循信号量值、操作参数和等待条件等规则。

例如,当资源不足时,负数操作可能阻塞等待。


二十二、完整知识体系总结

可以将 System V IPC 分成三个层次理解。

第一层:

如何交换数据?

text 复制代码
共享内存
消息队列

第二层:

如何协调进程对资源的访问?

text 复制代码
信号量
同步
互斥

第三层:

操作系统如何管理这些IPC对象?

text 复制代码
key
IPC对象标识符
权限
内核IPC数据结构
创建与删除

进一步联系之前的内容:

text 复制代码
进程控制
    |
    +-- fork
    +-- exec
    +-- waitpid

基础IO
    |
    +-- open
    +-- read
    +-- write
    +-- dup2

匿名和命名管道
    |
    +-- pipe
    +-- mkfifo

System V IPC
    |
    +-- 共享内存
    +-- 消息队列
    +-- 信号量

这些机制共同构成了 Linux 多进程程序设计的重要基础。


总结

本篇重点介绍了 Linux System V IPC 的三种基本机制。

首先,共享内存允许多个进程访问同一块共享存储区域。

通过:

c 复制代码
shmget()
shmat()
shmdt()
shmctl()

可以完成资源创建、映射、解除映射和删除等操作。

其中最重要的结论是:

共享内存解决的是数据共享问题,但不自动解决并发同步问题。

随后,我们学习了消息队列:

c 复制代码
msgget()
msgsnd()
msgrcv()
msgctl()

认识了消息边界和消息类型。

最后,我们通过:

c 复制代码
semget()
semop()
semctl()

学习了信号量的基本原理,以及 P/V 操作如何帮助进程完成同步与互斥。

结合内核 IPC 资源管理机制,可以发现:

进程间通信不仅涉及数据怎样传递,还涉及共享对象怎样创建、查找、保护、回收,以及多个执行流之间怎样协调。

这也说明,从单进程程序走向多进程程序之后,程序设计需要考虑的不仅是算法本身,还包括并发执行、资源生命周期和访问一致性。

掌握这部分内容后,我们已经具备继续学习进程间同步、生产者-消费者模型、Socket 网络通信以及多进程服务器架构的基础。


相关推荐
商业白皮书1 小时前
企业通过API调用大模型,推荐哪些具备数据隐私保护能力的平台?
大数据·人工智能·笔记
yangmu32031 小时前
PLC 设备介绍:工业自动化的“控制大脑”
运维·自动化
IMPYLH1 小时前
Linux 文件检索:find、locate、grep 与 which 实战指南
linux·运维·服务器
可信计算1 小时前
从单视口渲染到分支击穿:关于概率、脑神经与“创世启动器”的一些构想
人工智能·笔记
li星野1 小时前
【学习记录】PCB设计常用操作全解析:单位、图层、过滤器、布线、规则与快捷键
学习
腾讯蓝鲸智云1 小时前
【运维自动化-监控平台】如何配置进程监控之基于进程名规则匹配
运维·自动化·云计算·sass·paas
优化Henry1 小时前
LTE基站传输割接后S1连接失败排查实录
运维·网络·笔记·学习·信息与通信
西索斯coding1 小时前
Claude Code 工具调用返回空怎么办?claude-opus-5.5 的 input_schema 校验踩坑记录与修复方案
服务器·前端·javascript·ai
流烟默1 小时前
ClickHouse Part 机制与写入优化实践指南
大数据·运维·clickhouse