Linux基础:进程间通信
文章目录
- Linux 进程间通信:从管道到共享内存,搞懂进程是怎么“聊天”的
- 1. 进程间通信
- 2. 匿名管道
- 3. fork 与匿名管道
- 4. 从文件描述符理解管道
- 5. 管道的读写规则
- 6. 管道的特点
- 7. 使用管道实现进程池
- 8. 命名管道
- 9. System V 共享内存
- 10. 共享内存的 key
- 11. shmget:创建共享内存
- 12. shmat:挂接共享内存
- 13. shmdt:解除共享内存关联
- 14. shmctl:控制共享内存
- 15. 共享内存的完整通信流程
- 16. System V IPC 的生命周期
- 17. 共享内存的问题:没有访问控制
- 18. 使用管道辅助控制共享内存访问
- 19. System V 消息队列
- 20. System V 信号量
- 21. 临界资源与临界区
- 22. 互斥与同步
- 23. P 操作与 V 操作
- 24. 为什么信号量操作必须保证原子性
- 25. 共享内存与信号量
- 26. System V IPC 三件套
- 27. 内核如何管理 IPC 资源
- 28. 从 IPC 管理理解 C 语言的“多态思想”
- 29. 把整个进程间通信串起来
- 30. IPC 整体关系
- 31. 本章重点总结
- 32. 最后总结
Linux 进程间通信:从管道到共享内存,搞懂进程是怎么“聊天”的
内容简介
进程具有独立性,各自拥有独立的地址空间,但实际运行中进程又不可能真的“老死不相往来”。数据传输、资源共享、事件通知……这些需求都离不开进程间通信(IPC,Inter-Process Communication)。
本文从进程间通信的基本概念讲起,依次介绍 匿名管道、命名管道、进程池、System V 共享内存、消息队列与信号量,最后再从内核角度理解 Linux 是如何组织和管理 IPC 资源的。
重点不是死记 pipe、shmget、shmat 这些接口,而是搞清楚几个问题:
- 两个相互独立的进程为什么能够通信?
- 管道到底是什么,为什么可以用
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 做成一张酷炫的内核通信架构图。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)