文章目录

Linux 进程间通信:从管道到共享内存,搞懂进程是怎么“聊天”的

内容简介

进程具有独立性,各自拥有独立的地址空间,但实际运行中进程又不可能真的“老死不相往来”。数据传输、资源共享、事件通知……这些需求都离不开进程间通信(IPC,Inter-Process Communication)

本文从进程间通信的基本概念讲起,依次介绍 匿名管道、命名管道、进程池、System V 共享内存、消息队列与信号量,最后再从内核角度理解 Linux 是如何组织和管理 IPC 资源的。

重点不是死记 pipeshmgetshmat 这些接口,而是搞清楚几个问题:

  • 两个相互独立的进程为什么能够通信?
  • 管道到底是什么,为什么可以用 read/write 操作?
  • fork 之后,父子进程为什么能通过同一个管道通信?
  • 命名管道为什么能让两个毫无关系的进程通信?
  • 共享内存为什么快?
  • 共享内存已经这么快了,为什么还需要信号量?
  • Linux 内核面对这么多 IPC 资源,又是如何管理它们的?

把这些问题串起来,你会发现 IPC 并不是一堆杂乱的系统调用,而是一套非常自然的设计:

进程不能直接访问彼此的数据,那就让操作系统提供一份双方都能访问的公共资源。


1. 进程间通信

1.1 为什么需要进程间通信

我们之前学习进程时知道一个非常重要的概念:

进程具有独立性。

每个进程都有自己的虚拟地址空间。

假设现在有两个进程:

进程 A                         进程 B

+------------------+          +------------------+
|     自己的代码    |          |     自己的代码    |
|     自己的数据    |          |     自己的数据    |
+------------------+          +------------------+

       你过你的                    我过我的

进程 A 中定义:

int x = 100;

正常情况下,进程 B 不能直接跑过来:

x = 200;

否则进程之间就谈不上独立了,而是大型互相拆家现场。

但问题也随之而来:

进程彼此独立,却又经常需要合作。

比如:

who | wc -l

前一个程序产生数据,后一个程序处理数据。

于是我们需要一种机制,让不同进程能够交换数据、共享资源或者通知彼此发生了什么。

这就是:

进程间通信。


1.2 进程间通信的目的

进程间通信主要有四个目的。

1.2.1 数据传输

一个进程需要把自己的数据发送给另一个进程。

例如:

进程 A
  |
  | "hello"
  ↓
进程 B

这是最容易理解的一种。


1.2.2 资源共享

多个进程可能需要共同使用某些资源。

进程 A ──┐
         ↓
       资源
         ↑
进程 B ──┘

既然大家都要用,就涉及一个新的问题:

谁先用?谁后用?能不能一起用?

这也是后面信号量要解决的问题。


1.2.3 通知事件

一个进程需要告诉另一个进程:

“有事情发生了。”

比如一个进程退出后,需要通知其他相关进程。

这种通信不一定是为了传输大量数据,有时候仅仅是为了传递一个事件


1.2.4 进程控制

某些进程需要控制另一个进程的执行。

最典型的就是:

调试器
  ↓
被调试程序

调试器需要知道程序什么时候停止、发生了什么异常,并控制它继续执行。


1.3 进程间通信的分类

进程间通信主要可以分成:

IPC
│
├── 管道
│   ├── 匿名管道 pipe
│   └── 命名管道 FIFO
│
├── System V IPC
│   ├── 消息队列
│   ├── 共享内存
│   └── 信号量
│
└── POSIX IPC
    ├── 消息队列
    ├── 共享内存
    ├── 信号量
    ├── 互斥量
    ├── 条件变量
    └── 读写锁

本文主要理解:

匿名管道
    ↓
进程池
    ↓
命名管道
    ↓
System V 共享内存
    ↓
消息队列
    ↓
信号量
    ↓
内核 IPC 管理

先不要急着记函数。

学习 IPC 最重要的问题始终是:

两个进程究竟通过什么东西产生了联系?


2. 匿名管道

2.1 什么是管道

管道,英文叫:

Pipe

名字已经很形象了。

现实中的水管:

水龙头
  |
  | 水
  ↓
====================
        水管
====================
                 |
                 ↓
               出水

进程中的管道也差不多:

进程 A
  |
  | 写数据
  ↓
====================
        管道
====================
                 |
                 | 读数据
                 ↓
               进程 B

所以可以把管道理解成:

连接两个进程的数据通道。

一个进程向管道中写数据,另一个进程从管道中读取数据,从而完成进程间通信。


2.2 创建匿名管道

Linux 中通过 pipe 创建匿名管道:

#include <unistd.h>

int pipe(int pipefd[2]);

成功:

返回 0

失败:

返回 -1

最重要的是参数:

int pipefd[2];

执行:

pipe(pipefd);

之后:

pipefd[0]; // 读端

pipefd[1]; // 写端

可以这么记:

0 —— read
1 —— write

刚好和:

0 —— stdin
1 —— stdout

比较容易对应起来。

创建完成后:

          内核

     +------------+
     |    pipe    |
     +------------+
       ↑        ↓
       |        |
   pipefd[1] pipefd[0]
      写端      读端

我们得到的并不是“管道本身”,而是两个用于操作管道的文件描述符


2.3 管道最简单的使用

#include <unistd.h>
#include <cstring>

int main()
{
    int pipefd[2];

    pipe(pipefd);

    const char* msg = "hello pipe";

    write(pipefd[1], msg, strlen(msg));

    char buffer[1024] = {0};

    read(pipefd[0], buffer, sizeof(buffer));

    return 0;
}

过程:

"hello pipe"
      |
      | write
      ↓
+-------------+
|    pipe     |
+-------------+
      |
      | read
      ↓
   buffer

当然,这个程序只有一个进程。

相当于:

我给自己发了一条消息,然后自己点开看。

技术上没问题,实际意义不大。

管道真正有意思的地方,是:

让两个进程进行通信。

这时候 fork() 就登场了。


3. fork 与匿名管道

3.1 为什么要先 pipe,再 fork

经典写法:

int pipefd[2];

pipe(pipefd);

pid_t id = fork();

注意顺序:

pipe
 ↓
fork

为什么?

先执行:

pipe(pipefd);

父进程拥有:

父进程

pipefd[0] ─────→ 管道读端
pipefd[1] ─────→ 管道写端

然后:

fork();

创建子进程。

子进程会继承父进程已经打开的文件描述符。

于是:

父进程                          子进程

pipefd[0] ───┐            ┌─── pipefd[0]
             ↓            ↓
          +------------------+
          |       pipe       |
          +------------------+
             ↑            ↑
pipefd[1] ───┘            └─── pipefd[1]

注意:

父子进程虽然各自拥有自己的文件描述符表,但是这些文件描述符最终可以指向同一个管道

于是:

进程独立

不代表

所有内核资源都不能共享

这正是匿名管道能够完成父子进程通信的基础。


3.2 建立单向通信

假设我们希望:

父进程 → 子进程

那么:

父进程只需要写:

close(pipefd[0]);

关闭自己的读端。

子进程只需要读:

close(pipefd[1]);

关闭自己的写端。

最终:

父进程                         子进程

pipefd[1]                     pipefd[0]
    |                            ↑
    | write                      | read
    ↓                            |
+----------------------------------+
|               pipe               |
+----------------------------------+

于是:

父进程:

write(pipefd[1], ...);

子进程:

read(pipefd[0], ...);

完成通信。


3.3 为什么不用的一端要关闭

刚开始很多人会觉得:

不关闭好像也能跑啊?

有时候确实能跑。

但问题在后面。

管道判断:

还有没有写端?

并不是看:

“你程序逻辑上还写不写?”

而是看:

“还有没有文件描述符引用着写端?”

如果某个进程明明不写数据,却还一直拿着写端不关闭:

你:没人写了!

内核:?

内核:这不是还有个写端吗?

于是管道的 EOF 判断就可能受到影响。

所以:

不使用的管道端应该及时关闭。


4. 从文件描述符理解管道

4.1 管道也是“文件”

我们操作普通文件:

read(fd, ...);

write(fd, ...);

操作管道:

read(pipefd[0], ...);

write(pipefd[1], ...);

接口还是:

read
write
close

所以站在进程角度:

fd
 |
 ↓
某个可以读写的内核对象

这个对象可能是:

普通文件
管道
设备
终端
……

这就是 Linux 中非常经典的:

一切皆文件。

管道并不是磁盘上的普通文件,但 Linux 仍然通过文件描述符给进程提供统一的操作接口。


4.2 从内核角度理解管道

创建管道:

pipe(pipefd);

可以抽象成:

用户空间

pipefd[0]                  pipefd[1]
    |                          |
    +-----------+--------------+
                |
                ↓
             内核空间
                |
                ↓
         +--------------+
         |     pipe     |
         +--------------+

再执行:

fork();

子进程继承文件描述符。

于是:

父进程 fd                     子进程 fd
    \                            /
     \                          /
      +------------------------+
                  |
                  ↓
             内核管道

所以真正共享的并不是:

pipefd[0] 这个整数

而是:

文件描述符最终指向的那个内核管道对象。

理解这一点之后,匿名管道就没那么神秘了。


5. 管道的读写规则

这部分非常重要。

管道不是:

想读就读
想写就写
爱咋咋地

它有明确的规则。


5.1 管道为空时读取

假设:

+----------------------+
|                      |
|        空管道         |
|                      |
+----------------------+

此时执行:

read(pipefd[0], buffer, size);

如果没有设置非阻塞:

O_NONBLOCK disable

那么:

read 会阻塞。

也就是当前进程暂停执行,等待管道中出现数据。

可以理解成:

进程:有数据吗?

管道:没有。

进程:那我睡了,有了叫我。

5.2 非阻塞读取

如果开启:

O_NONBLOCK

管道为空时:

read();

不会一直等。

而是返回:

-1

同时:

errno = EAGAIN

意思就是:

现在没有数据,你待会再来。


5.3 管道满了继续写

反过来:

+----------------------+
|██████████████████████|
|        管道已满       |
+----------------------+

继续:

write();

如果是阻塞模式:

write 会阻塞,等待其他进程读取数据、腾出空间。

也就是:

写进程:我要写。

管道:没位置。

写进程:那我等等。

如果开启非阻塞模式,则可能:

write() → -1

errno = EAGAIN

5.4 所有写端关闭

这是管道非常重要的一条规则。

假设:

写进程
  |
 close
  X

=========== pipe ===========

                    |
                    ↓
                  读进程

如果:

所有指向管道写端的文件描述符都关闭了

那么读进程把剩余数据读取完之后,再调用:

read();

会得到:

0

即:

read(...) == 0

表示:

不会再有数据来了。

这就是 EOF。

所以:

ssize_t n = read(fd, buffer, sizeof(buffer));

if(n == 0)
{
    // 对端已经关闭
}

5.5 所有读端关闭

反过来。

如果:

所有读端都关闭

还有进程继续:

write();

这时候已经没人接收数据了。

就像:

对方:已经挂电话。

你:喂?喂?喂?

系统会向写进程发送:

SIGPIPE

默认情况下可能导致进程终止。

所以:

所有写端关闭
    ↓
read 返回 0

所有读端关闭
    ↓
write 触发 SIGPIPE

这两个规则非常重要。


5.6 PIPE_BUF 与原子性

如果多个进程同时往一个管道写:

进程 A ──┐
         ↓
        PIPE
         ↑
进程 B ──┘

例如:

A:AAAAAAAA

B:BBBBBBBB

当单次写入的数据量:

<= PIPE_BUF

Linux 会保证写入的原子性。

也就是说,不会把一次符合条件的写操作拆得乱七八糟。

当:

> PIPE_BUF

则不再保证这种原子性。

因此:

PIPE_BUF 主要与管道写入的原子性有关。


6. 管道的特点

匿名管道主要具有以下特点:

1. 通常用于具有亲缘关系的进程通信

2. 管道提供流式服务

3. 管道生命周期通常随进程

4. 内核会对管道操作进行同步与互斥

5. 管道通常是半双工的

6.1 什么叫流式服务

管道中的数据本质上是:

字节流

假设写:

hello

再写:

world

管道看到的是:

helloworld

它并不会自动帮你贴标签:

消息1:hello

消息2:world

所以:

管道没有天然的消息边界。


6.2 什么叫半双工

一般情况下,一根匿名管道中的数据只能向一个方向流动:

A ─────────→ B

如果希望:

A ←────────→ B

双方都能给对方发送数据,那么通常需要建立两根管道:

       pipe1
A ─────────────→ B

       pipe2
A ←───────────── B

一根负责:

A → B

另一根负责:

B → A

7. 使用管道实现进程池

7.1 为什么需要进程池

假设有很多任务。

最简单的方式:

任务来了
 ↓
fork
 ↓
处理
 ↓
退出

下一个任务:

任务来了
 ↓
fork
 ↓
处理
 ↓
退出

如果任务很多:

fork
fork
fork
fork
fork
fork
……

频繁创建和退出进程显然会产生额外开销。

于是可以:

提前创建一批子进程,让它们等着干活。

这就是:

进程池。


7.2 进程池基本结构

例如提前创建 4 个 Worker:

                 Master
                   |
       +-----------+-----------+
       |           |           |
       ↓           ↓           ↓
    Worker1     Worker2     Worker3
                                   \
                                    Worker4

Master 负责:

派发任务

Worker 负责:

执行任务

于是:

Master:活来了,2号你上。

Worker2:收到。

不用每次都重新 fork()


7.3 Master 如何把任务交给 Worker

前面已经有现成的通信工具:

管道。

所以可以让 Master 与每个 Worker 建立一条管道:

Master
  |
  +──── pipe1 ────→ Worker1
  |
  +──── pipe2 ────→ Worker2
  |
  +──── pipe3 ────→ Worker3
  |
  +──── pipe4 ────→ Worker4

Master 保存每个 Worker 对应的:

写端 fd
PID

例如封装成:

class Channel
{
private:
    int _wfd;
    pid_t _who;
};

于是:

Channel1 → Worker1
Channel2 → Worker2
Channel3 → Worker3
Channel4 → Worker4

7.4 初始化进程池

整体过程:

开始
 ↓
创建 pipe
 ↓
fork
 ↓
保存 Worker 信息
 ↓
继续创建下一个

最后:

Master

+--- fd1 ---> Worker1
|
+--- fd2 ---> Worker2
|
+--- fd3 ---> Worker3
|
+--- fd4 ---> Worker4

进程池的生命周期可以分成:

1. 初始化进程池

2. 派发任务

3. 清理进程池

7.5 为什么子进程要关闭历史文件描述符

这里有一个很容易踩的坑。

假设先创建:

Worker1

此时 Master 已经保存:

fd1

接下来创建 Worker2:

fork();

Worker2 会继承父进程当前打开的文件描述符。

也就是说:

Worker2

除了自己的管道
还可能继承 fd1

继续创建:

Worker3

它可能又继承:

fd1
fd2

于是:

Worker1

Worker2:fd1

Worker3:fd1 fd2

Worker4:fd1 fd2 fd3

越来越热闹。

这些不需要的文件描述符必须关闭。

否则可能影响:

管道关闭
EOF 判断
进程退出
资源回收

所以创建子进程之后,要把继承来的无关文件描述符关闭。


7.6 dup2 与 Worker

子进程可以执行:

dup2(pipefd[0], 0);

意思是:

把管道读端重定向到标准输入 0

原来:

pipefd[0]
    |
    ↓
  管道

执行:

dup2(pipefd[0], 0);

之后:

fd 0
 |
 ↓
管道

Worker 就可以统一:

read(0, &cmd, sizeof(cmd));

而不需要关心:

我的管道 fd 到底是 3?

还是 5?

还是 8?

统一从:

0

读取即可。


7.7 Master 如何派发任务

假设有:

Worker0
Worker1
Worker2

可以依次派发:

任务1 → Worker0

任务2 → Worker1

任务3 → Worker2

任务4 → Worker0

任务5 → Worker1

……

循环进行:

0 → 1 → 2 → 0 → 1 → 2

这样 Master 就能让多个 Worker 分担任务。


7.8 进程池如何退出

Worker 一直:

read(0, &cmd, sizeof(cmd));

等待 Master 派任务。

如果 Master 关闭对应的写端:

close(wfd);

那么 Worker 读取完剩余数据以后:

read();

会返回:

0

于是:

if(n == 0)
{
    break;
}

Worker 退出。

最后 Master:

waitpid();

回收子进程。

所以整个退出过程:

Master关闭写端
      ↓
Worker read == 0
      ↓
Worker退出
      ↓
Master waitpid
      ↓
完成资源回收

前面学的管道规则,在这里全部串起来了。


8. 命名管道

8.1 为什么需要命名管道

匿名管道有一个明显限制:

通常用于具有亲缘关系的进程之间通信。

因为我们一般:

pipe
 ↓
fork

让子进程继承父进程的文件描述符。

但如果:

./server

和:

./client

是两个独立启动的程序呢?

它们没有父子关系。

总不能:

server:咱俩先认个亲再通信?

没必要。

这时候就可以使用:

命名管道 FIFO。


8.2 什么是命名管道

匿名管道:

没有名字

命名管道:

在文件系统中有名字

例如:

mkfifo myfifo

就可以创建一个命名管道:

myfifo

也可以在程序中:

#include <sys/stat.h>

int mkfifo(
    const char* filename,
    mode_t mode
);

例如:

mkfifo("mypipe", 0666);

8.3 使用命名管道通信

Server:

int rfd = open("mypipe", O_RDONLY);

Client:

int wfd = open("mypipe", O_WRONLY);

于是:

Client

  |
  | write
  ↓

========================
       mypipe
========================

                  |
                  | read
                  ↓

                Server

这样即使两个进程毫无亲缘关系,也可以通过同一个命名管道完成通信。


8.4 匿名管道与命名管道的区别

匿名管道:

pipe(fd);

创建并打开。

命名管道:

mkfifo("mypipe", 0666);

先创建。

然后:

open("mypipe", ...);

打开。

所以二者最大的区别可以概括为:

匿名管道

pipe 创建并打开


命名管道

mkfifo 创建
open 打开

完成创建和打开以后,它们都可以继续:

read
write
close

8.5 命名管道的打开规则

如果:

open("mypipe", O_RDONLY);

以读方式打开 FIFO。

在阻塞模式下:

如果还没有进程打开写端,会等待写端出现。

如果:

open("mypipe", O_WRONLY);

以写方式打开。

在阻塞模式下:

如果还没有进程打开读端,也会等待。

所以:

Server:我准备读。

Client:我准备写。

双方到齐。

开始通信。

如果使用非阻塞模式,规则会有所不同。

读端非阻塞打开:

可以立即成功

写端非阻塞打开,但没有任何读端:

打开失败
errno = ENXIO

9. System V 共享内存

9.1 什么是共享内存

正常情况下:

进程 A                      进程 B

虚拟地址空间                虚拟地址空间

+-----------+              +-----------+
|           |              |           |
|   data    |              |   data    |
|           |              |           |
+-----------+              +-----------+

         相互独立

现在我们希望:

A 写数据

B 能直接看到

怎么办?

可以让操作系统提供一块:

共享内存。

然后把这块共享内存分别关联到两个进程。

进程 A                      进程 B
   |                           |
   |                           |
   +------------+--------------+
                |
                ↓
        +----------------+
        |    共享内存     |
        +----------------+

于是:

A 写

B 读

双方通过同一块共享区域完成通信。


9.2 为什么共享内存很快

管道通信可以理解为:

进程 A
   |
   | write
   ↓
内核中的管道
   |
   | read
   ↓
进程 B

共享内存建立关联以后:

进程 A ──────┐
             ↓
         共享内存
             ↑
进程 B ──────┘

双方直接访问共享区域。

因此:

共享内存是非常高效的 IPC 方式。

它的核心优势就是:

建立共享区域之后
多个进程可以直接访问同一份共享数据

10. 共享内存的 key

10.1 为什么需要 key

现在系统中可能存在很多共享内存:

共享内存 A

共享内存 B

共享内存 C

共享内存 D

两个进程如果想使用同一块共享内存,就必须有办法确认:

“咱俩找的是同一个。”

于是需要:

key

可以使用:

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

生成一个 key。

例如:

key_t key = ftok(PATH_NAME, PROJ_ID);

通信双方使用相同的条件生成相同的 key,就能够找到对应的 System V IPC 资源。


11. shmget:创建共享内存

11.1 shmget 函数

创建或者获取共享内存:

#include <sys/shm.h>

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

三个参数:

key
 ↓
共享内存的 key

size
 ↓
共享内存大小

shmflg
 ↓
创建方式和权限

成功:

返回共享内存标识符 shmid

失败:

返回 -1

11.2 IPC_CREAT

例如:

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

IPC_CREAT 表示:

共享内存不存在
    ↓
创建

共享内存已经存在
    ↓
获取

11.3 IPC_CREAT | IPC_EXCL

如果:

IPC_CREAT | IPC_EXCL

则:

不存在
 ↓
创建成功

已经存在
 ↓
创建失败

也就是说:

我就是要创建一个全新的,已经有了就报错。


12. shmat:挂接共享内存

12.1 为什么创建之后还不能直接使用

执行:

int shmid = shmget(...);

只是获得:

共享内存标识符

当前进程还需要把共享内存与自己的地址空间建立联系。

这一步叫:

挂接。

使用:

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

最常见:

char* shmaddr =
    (char*)shmat(shmid, nullptr, 0);

完成之后:

进程地址空间
      |
      |
      ↓
   shmaddr
      |
      ↓
+-------------+
|   共享内存   |
+-------------+

之后程序就可以通过:

shmaddr

访问共享区域。


13. shmdt:解除共享内存关联

13.1 解除关联

使用完成以后:

shmdt(shmaddr);

表示:

当前进程不再与这块共享内存保持关联。

注意:

shmdt

并不是:

删除共享内存

它只是:

解除当前进程
        ↓
共享内存

之间的关联。

可以简单理解:

shmat
 ↓
加入群聊

shmdt
 ↓
退出群聊

退出群聊不等于:

把整个群解散。


14. shmctl:控制共享内存

14.1 shmctl 函数

接口:

int shmctl(
    int shmid,
    int cmd,
    struct shmid_ds* buf
);

它用于:

控制共享内存。

其中非常重要的一种操作:

shmctl(
    shmid,
    IPC_RMID,
    nullptr
);

用于删除共享内存。

所以:

shmat
 ↓
挂接

shmdt
 ↓
解除挂接

shmctl + IPC_RMID
 ↓
删除共享内存

千万别把:

shmdt

和:

IPC_RMID

搞混。


15. 共享内存的完整通信流程

假设:

Server
Client

进行通信。

Server:

1. ftok 获取 key

2. shmget 创建共享内存

3. shmat 挂接共享内存

4. 读取共享内存

5. shmdt 解除关联

6. shmctl 删除共享内存

Client:

1. ftok 获取相同 key

2. shmget 获取共享内存

3. shmat 挂接共享内存

4. 写入共享内存

5. shmdt 解除关联

于是:

Server                       Client

ftok                          ftok
 ↓                             ↓
key                           key
 ↓                             ↓
shmget                        shmget
 ↓                             ↓
shmid                         shmid
 ↓                             ↓
shmat                         shmat
  \                            /
   \                          /
    ↓                        ↓
      +--------------------+
      |     Shared Memory  |
      +--------------------+

通信双方最终看到的是同一块共享区域。


16. System V IPC 的生命周期

16.1 为什么程序退出了,共享内存可能还在

这是共享内存非常容易踩的坑。

匿名管道通常随着相关进程结束而释放。

System V IPC 资源则由内核维护。

假设程序:

创建共享内存
    ↓
运行
    ↓
Ctrl+C
    ↓
程序没了

然后重新运行:

shmget: File exists

你:

我程序都没了,你怎么还在?

共享内存:

你没删我啊。

因此可以使用:

ipcs -m

查看共享内存。

也可以:

ipcrm -m shmid

删除指定共享内存。

不过正常情况下:

IPC 资源应该由程序自己负责清理。

不要每次程序跑完,都让你拿着 ipcrm 在后面打扫战场。


17. 共享内存的问题:没有访问控制

17.1 快归快,但大家都能碰

共享内存最大的优势:

但它也带来了问题。

假设:

进程 A ──────┐
             ↓
          共享内存
             ↑
进程 B ──────┘

A 正在写:

ABCDEFGHIJKLMN

结果刚写:

ABCDEFG

进程被切走。

B 立刻开始读取:

ABCDEFG???

数据可能就出现问题。

原因很简单:

共享内存本身没有提供同步与互斥。

也就是说,共享内存解决了:

数据放哪里?

却没有解决:

谁先访问?

谁后访问?

能不能同时访问?

18. 使用管道辅助控制共享内存访问

18.1 数据和通知分开

可以尝试:

共享内存
 ↓
负责数据

管道
 ↓
负责通知

例如 Client:

1. 向共享内存写数据

2. 写完以后通过管道通知 Server

Server:

1. 等待管道通知

2. 收到通知

3. 再读取共享内存

过程:

Client

  |
  | 写数据
  ↓

+------------------+
|    Shared Memory |
+------------------+

  |
  | 写完了
  ↓

====== FIFO ======

              |
              | 收到通知
              ↓

            Server
              |
              ↓
          读取共享内存

这样至少可以让两个进程的执行产生一定的顺序性。

但要真正解决共享资源的同步与互斥问题,还要继续看:

信号量。


19. System V 消息队列

19.1 什么是消息队列

消息队列也是一种 System V IPC。

可以把它理解成:

内核维护的一个消息队列。

进程 A
   |
   | 发送消息
   ↓

+------------------+
|     消息 1       |
+------------------+
|     消息 2       |
+------------------+
|     消息 3       |
+------------------+

   |
   | 获取消息
   ↓

进程 B

一个进程向消息队列发送数据,另一个进程从消息队列中获取数据。


19.2 消息可以带类型

消息队列中的每一块数据都可以带有类型。

例如:

消息类型 1
    ↓
普通消息

消息类型 2
    ↓
特殊消息

于是接收进程可以:

按照消息类型选择自己想接收的数据。

这和管道的字节流有所不同。

管道更像:

ABCDEFGHIGKLMN...

消息队列更像:

[消息1]

[消息2]

[消息3]

所以消息队列的核心特点可以理解为:

以内核维护的消息为单位进行进程间通信。


20. System V 信号量

20.1 信号量到底是什么

信号量这个名字第一次看很吓人。

其实它的核心思想非常简单:

信号量本质上是一个计数器。

这个计数器用于描述:

资源数量

例如电影院还有:

100 个座位

那么:

sem = 100

一个人订票:

sem--

有人退票:

sem++

所以信号量可以理解成:

资源数量的管理员。


21. 临界资源与临界区

21.1 什么是共享资源

多个执行流都能够访问的资源,可以称为:

共享资源。

例如:

共享内存
文件
某些公共数据
……

21.2 什么是临界资源

如果某份共享资源需要被保护,不能让多个执行流随便同时访问,那么它就是:

临界资源。

例如:

共享内存
   ↓
多个进程都能访问
   ↓
访问过程需要控制
   ↓
临界资源

21.3 什么是临界区

访问临界资源的那段代码:

临界区。

例如:

// 临界区开始

访问共享资源;

// 临界区结束

所以关系:

共享资源
   ↓
需要保护
   ↓
临界资源
   ↓
访问它的代码
   ↓
临界区

22. 互斥与同步

22.1 什么是互斥

互斥:

任何时刻,只允许一个执行流访问临界资源。

例如只有一个座位:

进程 A
进程 B
进程 C

不能:

A:我坐。

B:我也坐。

C:挤挤还能坐。

不行。

只能:

A 使用
 ↓
A 离开
 ↓
B 使用
 ↓
B 离开
 ↓
C 使用

这就是互斥。


22.2 什么是同步

同步强调的是:

多个执行流按照一定的顺序访问资源。

比如:

A 先完成
 ↓
通知 B
 ↓
B 再执行

所以可以简单记:

互斥
 ↓
不能一起

同步
 ↓
按照顺序

23. P 操作与 V 操作

23.1 P 操作

P 操作可以理解成:

申请资源。

资源数量:

sem

执行 P 操作:

sem--

例如:

sem = 3

P()

sem = 2

意思就是:

原来有3份资源

我申请走1份

剩2份

23.2 V 操作

V 操作:

释放资源。

执行:

sem++

例如:

sem = 2

V()

sem = 3

意思就是:

我用完了

资源还回去

所以:

P
 ↓
申请资源
 ↓
sem--

V
 ↓
释放资源
 ↓
sem++

24. 为什么信号量操作必须保证原子性

假设:

sem = 1

现在 A:

查看 sem

发现:
sem == 1

A 正准备:

sem--

突然进程切换。

B 开始运行。

B:

查看 sem

发现:
sem == 1

于是 B 也认为:

有资源!

最后:

A:我申请到了。

B:巧了,我也申请到了。

临界资源:

你俩不要一起过来啊。

所以:

判断资源
+
修改计数器

必须作为一个整体完成。

也就是说:

信号量相关操作必须保证原子性。

否则信号量自己都不安全,还怎么保护别人?


25. 共享内存与信号量

25.1 为什么它们经常一起出现

现在重新看共享内存。

共享内存解决:

数据共享

信号量解决:

访问控制

所以:

             信号量
                |
                ↓
         能不能访问?
                |
                ↓
        +---------------+
        |    共享内存    |
        +---------------+

访问过程可以理解为:

P 操作
 ↓
申请资源
 ↓
进入临界区
 ↓
访问共享内存
 ↓
离开临界区
 ↓
V 操作
 ↓
释放资源

因此可以记一句:

共享内存负责让大家看见同一份数据,信号量负责让大家别一窝蜂冲进去。

一个负责:

共享

一个负责:

秩序

26. System V IPC 三件套

现在统一来看:

System V IPC

主要包括:

共享内存
消息队列
信号量

它们分别解决不同问题。


26.1 共享内存

解决:

数据怎么高效共享?

特点:

多个进程共享一块内存
速度快
本身没有同步与互斥

26.2 消息队列

解决:

进程之间怎么按消息传递数据?

特点:

内核维护消息队列
消息具有类型
以消息为单位通信

26.3 信号量

解决:

多个进程怎么安全访问共享资源?

特点:

本质是计数器
资源预订
同步
互斥

所以:

共享内存
 ↓
共享数据

消息队列
 ↓
传递消息

信号量
 ↓
控制访问

别把它们当成三个毫无关系的章节。

它们实际上是在解决:

进程协作中的不同问题。


27. 内核如何管理 IPC 资源

27.1 IPC 资源也需要被管理

系统中可能存在:

共享内存1
共享内存2
共享内存3

消息队列1
消息队列2

信号量1
信号量2
信号量3
……

这些东西显然不能:

创建完往内核里一扔

然后:

随缘管理

内核必须知道:

它是谁?

属于谁?

权限是什么?

key 是多少?

状态如何?

所以 IPC 资源同样需要被:

描述和组织。

这和之前学习进程管理时的思想非常相似:

现实对象
 ↓
描述
 ↓
形成数据结构
 ↓
组织起来
 ↓
完成管理

也就是我们熟悉的:

先描述,再组织。


27.2 不同 IPC 资源存在公共属性

想一下。

共享内存需要:

key
权限
拥有者
……

消息队列也需要:

key
权限
拥有者
……

信号量还是需要:

key
权限
拥有者
……

于是就出现了:

公共属性

可以抽象理解成:

             IPC公共属性
                 |
       +---------+---------+
       |         |         |
       ↓         ↓         ↓
    共享内存   消息队列   信号量

不同 IPC 对象虽然具体功能不同,但它们都属于:

IPC 资源。

所以它们自然会存在可以统一管理的公共部分。


28. 从 IPC 管理理解 C 语言的“多态思想”

28.1 C 没有 class,就不能面向对象了吗

我们熟悉的 C++:

class Base
{
};

class Derived : public Base
{
};

可以通过继承建立:

公共部分
   ↓
具体对象

但 Linux 内核主要使用 C。

C 没有:

class

public

private

virtual

难道就没办法表达类似思想了吗?

当然不是。

语法没有,不代表思想没有。


28.2 结构体可以保存公共属性

例如可以有一个公共结构:

struct Base
{
    int id;
};

然后具体对象:

struct A
{
    struct Base base;

    int a;
};

另一个:

struct B
{
    struct Base base;

    int b;
};

结构:

A
+--------------+
|     Base     |
+--------------+
|    A自己数据 |
+--------------+

B
+--------------+
|     Base     |
+--------------+
|    B自己数据 |
+--------------+

于是:

公共部分统一处理

具体部分各自处理

这已经有点“继承”的味道了。


28.3 函数指针实现不同操作

C 中还可以在结构体中保存函数指针:

struct Object
{
    void (*func)(struct Object*);
};

不同对象:

Object A
   |
   └── func → FuncA

Object B
   |
   └── func → FuncB

调用时:

obj->func(obj);

表面调用方式相同:

func

实际执行:

不同对象
 ↓
不同函数

这就具有了非常明显的:

多态思想。

所以:

C 没有 class

并不代表:

C 不能写出面向对象思想

Linux 内核中大量使用:

结构体
+
函数指针

来完成类似的抽象设计。


29. 把整个进程间通信串起来

学到这里,重新回到最开始的问题:

进程彼此独立,到底是怎么通信的?


29.1 匿名管道

父子进程:

父进程
   |
   ↓
 PIPE
   |
   ↓
子进程

通过 fork 继承文件描述符,从而共同指向同一个管道。

解决:

亲缘进程之间的数据传输

29.2 命名管道

匿名管道不好让两个毫无关系的进程找到彼此。

于是:

进程 A
   |
   ↓
 FIFO
   |
   ↓
进程 B

给管道一个名字。

解决:

无亲缘关系进程之间的通信

29.3 共享内存

如果希望多个进程直接访问同一份数据:

进程 A ─────┐
            ↓
        共享内存
            ↑
进程 B ─────┘

解决:

高效的数据共享

但是:

谁先访问?
谁后访问?

又成了问题。


29.4 信号量

于是:

进程
 ↓
P操作
 ↓
信号量
 ↓
允许访问
 ↓
临界资源
 ↓
V操作

解决:

同步
互斥
资源预订

29.5 消息队列

如果希望:

不是单纯的字节流

而是一条一条消息

可以使用:

Message Queue

解决:

以消息为单位的数据传输

30. IPC 整体关系

最后可以用一张图把整个知识体系串起来:

                     进程间通信 IPC
                           |
          +----------------+----------------+
          |                                 |
         管道                           System V IPC
          |                                 |
     +----+----+                 +----------+----------+
     |         |                 |          |          |
     ↓         ↓                 ↓          ↓          ↓
匿名管道     命名管道          共享内存    消息队列     信号量
 pipe        FIFO               shm        msg        sem
     |         |                 |          |          |
     ↓         ↓                 ↓          ↓          ↓
亲缘进程    无关进程          共享数据    传递消息    同步互斥
     |         |
     +----+----+
          |
          ↓
       字节流通信

31. 本章重点总结

31.1 匿名管道

核心:

pipe
 ↓
得到读写两个 fd
 ↓
fork
 ↓
父子进程继承 fd
 ↓
共同访问同一个管道
 ↓
完成通信

记住:

pipefd[0]; // 读

pipefd[1]; // 写

31.2 管道读写规则

重点:

管道为空
+
阻塞读
=
read 阻塞
管道满
+
阻塞写
=
write 阻塞
所有写端关闭
=
read 返回 0
所有读端关闭
=
write 触发 SIGPIPE
写入数据 <= PIPE_BUF
=
保证写入原子性

31.3 进程池

核心:

提前创建 Worker
 ↓
Master 使用管道派发任务
 ↓
Worker 等待任务
 ↓
执行任务
 ↓
继续等待

不要频繁:

任务 → fork → 退出
任务 → fork → 退出

而是:

先 fork 一批
 ↓
重复使用

31.4 命名管道

匿名管道:

通常用于亲缘进程

命名管道:

可以让无亲缘关系的进程通信

创建:

mkfifo();

打开:

open();

通信:

read();

write();

31.5 共享内存

核心流程:

ftok
 ↓
生成 key
 ↓
shmget
 ↓
创建/获取共享内存
 ↓
shmat
 ↓
挂接
 ↓
访问共享内存
 ↓
shmdt
 ↓
解除挂接
 ↓
shmctl + IPC_RMID
 ↓
删除

其中:

shmdt != 删除共享内存

这一点非常容易搞混。


31.6 消息队列

核心:

进程
 ↓
发送消息
 ↓
消息队列
 ↓
接收消息
 ↓
另一个进程

并且:

消息可以携带类型

31.7 信号量

核心:

信号量本质上是计数器。

记住:

P 操作
 ↓
申请资源
 ↓
--

V 操作
 ↓
释放资源
 ↓
++

主要用于:

同步
互斥
资源预订

32. 最后总结

进程间通信这一章看起来东西很多:

pipe
fork
mkfifo
shmget
shmat
shmdt
shmctl
消息队列
信号量
P/V
……

如果一个一个死记,很容易学成:

这个函数三个参数。

那个函数返回 int。

再下一个函数……

三天以后:

全忘了。

真正应该建立的是下面这条主线:

进程具有独立性
        ↓
不能直接访问彼此的数据
        ↓
但是进程需要合作
        ↓
需要 IPC
        ↓
操作系统提供公共通信资源
        ↓
+-------------------------------+
|                               |
↓                               ↓
管道                         System V IPC
|                               |
├─ 匿名管道                    ├─ 共享内存
│                              │
├─ 命名管道                    ├─ 消息队列
│                              │
└─ 进程池                      └─ 信号量

再进一步:

管道
 ↓
通过内核中的管道传递字节流


共享内存
 ↓
多个进程直接共享数据


消息队列
 ↓
按照消息进行通信


信号量
 ↓
解决同步、互斥和资源预订

最后再回到内核:

IPC资源越来越多
       ↓
内核也必须管理
       ↓
先描述,再组织
       ↓
抽取公共属性
       ↓
统一管理不同IPC资源

所以整章真正值得记住的,不是某个函数到底有几个参数,而是这几句话:

1. 进程具有独立性,所以进程通信需要借助双方都能访问的媒介。

2. 管道本质上是一种通信通道,进程通过文件描述符对它进行读写。

3. fork 之后,父子进程继承文件描述符,因此可以共同使用同一个匿名管道。

4. 命名管道给管道增加了名字,从而让没有亲缘关系的进程也能建立通信。

5. 共享内存让多个进程共享同一份数据,因此效率很高。

6. 共享内存解决的是“共享”,却没有自动解决“谁先访问”的问题。

7. 信号量本质是计数器,通过 P/V 操作实现资源预订、同步与互斥。

8. System V IPC 资源由内核管理,因此创建资源的同时也要考虑资源清理。

9. Linux 管理 IPC 资源依然遵循“先描述,再组织”的思想。

10. 学习 IPC 最重要的不是背 API,而是搞清楚:进程到底通过什么公共资源建立了联系。

把最后这个问题真正想明白之后,再去看:

pipe();
mkfifo();
shmget();
shmat();
shmdt();
shmctl();

这些函数就不会再是一堆突然冒出来的 API。

因为你知道它们分别在做什么:

建立通道
找到通道
创建资源
建立关联
解除关联
管理资源

API 只是工具,进程之间如何建立联系,才是这一章真正的核心。

我也可以继续给这篇博客生成一张 16:9「Linux 进程间通信」技术博客封面,把 Pipe、Shared Memory、Message Queue、Semaphore 做成一张酷炫的内核通信架构图。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐