04|进程同步与通信
多个进程或线程共存时,“抢资源”和”传消息”是两个核心问题。本章将带你掌握从互斥锁到死锁预防的同步机制,以及管道、消息队列、共享内存等进程间通信手段。
📋 开篇自测:你已经知道多少?
- 什么是临界区?为什么需要对临界区进行保护?
- 互斥锁和信号量有什么区别?各自适用于什么场景?
- 你能列举出 Linux 中至少三种进程间通信方式吗?
一、竞态条件与临界区
1.1 竞态条件(Race Condition)
当多个执行流(进程或线程)并发访问共享资源,并且执行结果取决于它们的执行顺序时,就产生了竞态条件。
经典例子——银行账户转账:
账户余额 = 1000 元
线程A (取款500): 线程B (取款300):
1. 读取余额: 1000 1. 读取余额: 1000
2. 计算: 1000 - 500 = 500 2. 计算: 1000 - 300 = 700
3. 写回余额: 500 3. 写回余额: 700
最终余额: 700 (少扣了500!)
正确结果应该是: 200
问题出在哪里?“读取-计算-写回”这三步不是原子的,中间被另一个线程插入执行了。
1.2 临界区(Critical Section)
访问共享资源的那段代码叫做临界区。为了避免竞态条件,我们需要保证在任一时刻,最多只有一个执行流在临界区内执行——这就是互斥(Mutual Exclusion)。
进入临界区的协议:
线程A 线程B
| |
| 请求进入临界区 | 请求进入临界区
v |
[获得许可, 进入] | [等待...]
| |
| === 临界区 === |
| 操作共享资源 |
| === 临界区 === |
| |
| 离开临界区 |
v v
[获得许可, 进入]
|
| === 临界区 ===
| 操作共享资源
| === 临界区 ===
|
| 离开临界区
一个正确的临界区保护方案必须满足三个条件:
- 互斥性:同一时刻最多一个执行流在临界区内
- 有限等待:请求进入的执行流不能无限期等待(无饥饿)
- 空闲让进:临界区空闲时,请求进入的执行流应能立即进入
二、互斥锁(Mutex)
2.1 基本概念
互斥锁(Mutex, Mutual Exclusion Lock)是最基本的同步工具。它只有两种状态:锁定和未锁定。
操作:
lock(mutex) -- 如果锁空闲则加锁,否则阻塞等待
unlock(mutex) -- 释放锁,唤醒等待者
使用模式:
lock(mutex)
// 临界区:安全地操作共享资源
unlock(mutex)
2.2 互斥锁的实现原理
互斥锁的核心是一个原子操作。在 x86 架构上,常用的是 CAS(Compare-And-Swap) 指令:
CAS(地址, 期望值, 新值):
原子地执行:
if (*地址 == 期望值) {
*地址 = 新值
return true // 成功
} else {
return false // 失败
}
lock(mutex):
while (!CAS(&mutex.state, UNLOCKED, LOCKED)) {
// 获取失败,等待
// 自旋 or 阻塞
}
unlock(mutex):
mutex.state = UNLOCKED
// 唤醒等待的线程
2.3 自旋锁 vs 阻塞锁
当锁被占用时,等待者有两种策略:
自旋锁(Spinlock):不停地循环检查锁是否释放(忙等待)。
while (!try_lock(mutex)) {
// 什么都不做,继续循环
// CPU 在空转
}
阻塞锁(Sleeping Lock):将自己挂起,交出 CPU,等待被唤醒。
if (!try_lock(mutex)) {
add_to_wait_queue(current_thread)
sleep() // 交出 CPU
}
// 被唤醒后继续执行
| 特性 | 自旋锁 | 阻塞锁 |
|---|---|---|
| 等待方式 | CPU 忙等 | 进入睡眠 |
| CPU 消耗 | 高(持续占用 CPU) | 低(让出 CPU) |
| 适用场景 | 临界区极短、多核系统 | 临界区较长、单核系统 |
| 上下文切换 | 无 | 有(睡眠+唤醒各一次) |
| Linux 内核使用 | spinlock_t | mutex / semaphore |
Linux 内核中,pthread_mutex 实际上采用了一种混合策略(futex):先自旋几次,如果还没获得锁才进入睡眠。
2.4 读写锁(RWLock)
很多场景是”读多写少”的。如果每次读操作也要互斥,效率太低了。读写锁允许:
- 多个读者可以同时持有锁(读锁)
- 写者必须独占锁(写锁)
- 写者和读者互斥
状态转换:
空闲 --> 读锁定(可多个读者) --> 空闲
| ^
| |
+--> 写锁定(仅一个写者) -------+
并发矩阵:
读者 写者
读者 允许 阻塞
写者 阻塞 阻塞
🤔 想一想 如果读者源源不断地来,写者是否会永远等不到锁?这叫什么问题?如何解决?
三、信号量(Semaphore)
3.1 从互斥锁到信号量
互斥锁本质上是一个”只能容纳一个人”的房间。而信号量是一个”可以容纳 N 个人”的房间。
信号量由荷兰计算机科学家 Dijkstra 于 1965 年提出,它维护一个非负整数计数器,支持两个原子操作:
P 操作 (wait / down / acquire):
if (sem > 0) {
sem-- // 获取一个资源
} else {
阻塞当前线程 // 资源耗尽,等待
}
V 操作 (signal / up / release):
sem++ // 释放一个资源
if (有线程在等待) {
唤醒一个等待线程
}
3.2 信号量的两种用途
二值信号量(初始值=1):等价于互斥锁。
sem_t mutex;
sem_init(&mutex, 0, 1); // 初始值 = 1
sem_wait(&mutex); // P操作,进入临界区
// 临界区
sem_post(&mutex); // V操作,离开临界区
计数信号量(初始值=N):控制同时访问资源的数量。
// 数据库连接池:最多5个连接
sem_t pool;
sem_init(&pool, 0, 5);
sem_wait(&pool); // 获取一个连接 (可用连接数-1)
// 使用数据库连接
sem_post(&pool); // 释放连接 (可用连接数+1)
3.3 经典同步问题:生产者-消费者
共享缓冲区大小 = N
sem_t empty = N; // 空槽位数量
sem_t full = 0; // 已占用槽位数量
sem_t mutex = 1; // 保护缓冲区的互斥锁
生产者: 消费者:
while (true) { while (true) {
item = produce(); sem_wait(&full);
sem_wait(&empty); sem_wait(&mutex);
sem_wait(&mutex); item = buffer[out];
buffer[in] = item; out = (out+1) % N;
in = (in+1) % N; sem_post(&mutex);
sem_post(&mutex); sem_post(&empty);
sem_post(&full); consume(item);
} }
缓冲区状态变化:
+---+---+---+---+---+
| A | B | | | | full=2, empty=3
+---+---+---+---+---+
^out ^in
生产者放入 C:
+---+---+---+---+---+
| A | B | C | | | full=3, empty=2
+---+---+---+---+---+
^out ^in
消费者取出 A:
+---+---+---+---+---+
| | B | C | | | full=2, empty=3
+---+---+---+---+---+
^out ^in
四、死锁
4.1 死锁的定义
死锁(Deadlock)是指两个或多个执行流互相等待对方释放资源,导致所有相关执行流都无法继续执行的状态。
经典死锁场景:
线程A 线程B
| |
| lock(资源1) 成功 | lock(资源2) 成功
| |
| lock(资源2) 等待B释放 -->|
| | lock(资源1) 等待A释放
| |
v v
[永远等待] [永远等待]
资源分配图:
线程A ----持有----> 资源1
^ |
| | (线程B请求资源1)
(线程A请求资源2) |
| v
资源2 <----持有---- 线程B
形成了环!--> 死锁
4.2 死锁的四个必要条件
死锁发生需要同时满足以下四个条件(Coffman 条件):
+-------------------------------------------------------------+
| 死锁的四个必要条件 |
+-------------------------------------------------------------+
| |
| 1. 互斥 (Mutual Exclusion) |
| 资源只能被一个执行流独占使用 |
| |
| 2. 持有并等待 (Hold and Wait) |
| 持有至少一个资源,同时等待获取其他资源 |
| |
| 3. 非抢占 (No Preemption) |
| 已分配的资源不能被强制夺走 |
| |
| 4. 循环等待 (Circular Wait) |
| 存在一个等待链:T1等T2, T2等T3, ..., Tn等T1 |
| |
+-------------------------------------------------------------+
| 破坏任何一个条件,就能防止死锁 |
+-------------------------------------------------------------+
4.3 死锁的预防与避免
预防(Prevention):从设计上破坏四个条件之一。
破坏条件2 - 持有并等待:
方案: 一次性请求所有需要的资源
lock_both(资源1, 资源2) // 要么全拿到,要么都不拿
破坏条件4 - 循环等待:
方案: 对资源编号,按顺序请求
规定: 资源1 编号 < 资源2 编号
所有线程都必须先请求编号小的资源
线程A: lock(资源1) -> lock(资源2) // 正确
线程B: lock(资源1) -> lock(资源2) // 正确(而非先2后1)
--> 不可能形成环!
避免(Avoidance):运行时动态检查,如银行家算法。
银行家算法思想:
系统共有资源: A=10, B=5, C=7
已分配 最大需求 还需要
A B C A B C A B C
P0 0 1 0 7 5 3 7 4 3
P1 2 0 0 3 2 2 1 2 2
P2 3 0 2 9 0 2 6 0 0
P3 2 1 1 2 2 2 0 1 1
P4 0 0 2 4 3 3 4 3 1
当前可用: A=3, B=3, C=2
检查: 分配后是否存在一个安全序列使所有进程都能完成?
安全序列: P1 -> P3 -> P4 -> P2 -> P0 (存在!)
--> 允许分配
检测与恢复(Detection & Recovery):允许死锁发生,但定期检测并恢复。
检测: 构建资源分配图,检测是否有环
恢复方式:
1. 终止一个或多个死锁进程
2. 强制抢占某个进程的资源
3. 回滚到之前的检查点
🤔 想一想 日常开发中你遇到过死锁吗?实际工程中最常用的死锁预防策略是什么?
五、进程间通信(IPC)
进程有独立的地址空间,无法直接访问彼此的内存。当进程之间需要传递数据时,就需要进程间通信(IPC, Inter-Process Communication)。
5.1 管道(Pipe)
管道是最简单的 IPC 方式——一端写入,另一端读出,数据按 FIFO 顺序流动。
匿名管道:
父进程 子进程
+--------+ +--------+
| 写端 | ========> | 读端 |
| fd[1] | 管道 | fd[0] |
+--------+ (内核缓冲) +--------+
Shell 中: ls | grep txt
ls 进程 grep 进程
stdout --> [管道] --> stdin
int fd[2];
pipe(fd); // fd[0]=读端, fd[1]=写端
pid_t pid = fork();
if (pid == 0) {
// 子进程: 读取
close(fd[1]); // 关闭写端
char buf[256];
read(fd[0], buf, sizeof(buf));
printf("Child received: %s\n", buf);
close(fd[0]);
} else {
// 父进程: 写入
close(fd[0]); // 关闭读端
write(fd[1], "Hello from parent", 17);
close(fd[1]);
wait(NULL);
}
命名管道(FIFO):匿名管道只能在父子进程间使用。命名管道在文件系统中有一个路径名,任何进程都可以打开它。
# 创建命名管道
mkfifo /tmp/myfifo
# 终端1: 写入
echo "Hello" > /tmp/myfifo
# 终端2: 读取
cat /tmp/myfifo
5.2 消息队列(Message Queue)
消息队列是内核中维护的一个消息链表,进程可以向队列中发送带类型的消息,其他进程按类型选择性接收。
进程A ---> [msg type=1] [msg type=2] [msg type=1] ---> 进程B
|<----- 内核中的消息队列 ----->|
优势:
- 消息有类型,可以选择性接收
- 异步通信:发送方不需要等待接收方
- 多个进程可以向同一个队列发送/接收
局限:
- 每条消息有大小限制
- 队列有长度限制
- 数据需要在用户空间和内核空间之间复制
5.3 共享内存(Shared Memory)
共享内存是最快的 IPC 方式——两个进程将同一块物理内存映射到各自的地址空间中。数据不需要在内核和用户空间之间复制。
进程A 虚拟地址空间 物理内存 进程B 虚拟地址空间
+------------------+ +------------------+
| | | |
| 0x7f001000 -----+---> +----------+ <---+----- 0x7f002000 |
| | | 共享内存 | | |
| (映射区域) | | 页面 | | (映射区域) |
| | +----------+ | |
+------------------+ +------------------+
进程A 写入 --> 进程B 立即可见 (零拷贝!)
// 创建共享内存
int shmid = shmget(IPC_PRIVATE, 4096, IPC_CREAT | 0666);
// 映射到进程地址空间
char *shm = (char *)shmat(shmid, NULL, 0);
// 写入数据
sprintf(shm, "Hello from process A");
// 读取数据 (另一个进程)
printf("Received: %s\n", shm);
// 解除映射
shmdt(shm);
共享内存的速度最快,但它需要配合同步机制(如信号量)来避免竞态条件。
5.4 信号(Signal)
信号是一种异步通知机制,用于通知进程发生了某个事件。
常用信号 (以 x86/ARM Linux 为例,其他架构编号可能不同):
信号 编号 默认行为 典型触发场景
--------------------------------------------------------------
SIGHUP 1 终止 终端断开
SIGINT 2 终止 用户按 Ctrl+C
SIGQUIT 3 终止+core dump 用户按 Ctrl+\
SIGKILL 9 终止(不可捕获) kill -9
SIGSEGV 11 终止+core dump 段错误(访问非法内存)
SIGPIPE 13 终止 向已关闭的管道写入
SIGTERM 15 终止 kill 默认信号
SIGCHLD 17 忽略 子进程状态改变
SIGSTOP 19 停止(不可捕获) 暂停进程
SIGCONT 18 继续 恢复被暂停的进程
# 发送信号
kill -SIGTERM 1234 # 优雅终止
kill -9 1234 # 强制终止(SIGKILL,不可捕获)
kill -STOP 1234 # 暂停进程
kill -CONT 1234 # 恢复进程
5.5 套接字(Socket)
套接字不仅用于网络通信,也可以用于同一台机器上的进程间通信(Unix Domain Socket)。
网络套接字 vs Unix域套接字:
网络套接字: 进程A <--TCP/IP协议栈--> 进程B (可跨机器)
Unix域套接字: 进程A <--内核缓冲区--> 进程B (仅本机,更快)
Unix域套接字路径: /var/run/mysql/mysql.sock
/tmp/.X11-unix/X0
5.6 IPC 方式对比
+----------+--------+--------+--------+--------+
| | 管道 | 消息队列| 共享内存| 信号 |
+----------+--------+--------+--------+--------+
| 通信方向 | 单向 | 双向 | 双向 | 单向 |
| 数据拷贝 | 2次 | 2次 | 0次 | N/A |
| 速度 | 中等 | 中等 | 最快 | 最快 |
| 数据量 | 流式 | 消息块 | 大块 | 仅通知 |
| 同步机制 | 内置 | 内置 | 需自备 | 异步 |
| 亲缘关系 | 需要* | 不需要 | 不需要 | 不需要 |
+----------+--------+--------+--------+--------+
* 匿名管道需要,命名管道不需要
六、现代替代方案
6.1 POSIX 共享内存
传统的 System V IPC(shmget/shmat)接口比较老旧。POSIX 提供了更现代的接口:
// 创建共享内存对象
int fd = shm_open("/my_shm", O_CREAT | O_RDWR, 0666);
ftruncate(fd, 4096);
// 映射到进程地址空间
void *ptr = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
// 使用共享内存
sprintf(ptr, "Hello POSIX shared memory");
// 清理
munmap(ptr, 4096);
shm_unlink("/my_shm");
6.2 eventfd / timerfd
Linux 特有的轻量级通知机制,配合 epoll 使用非常方便:
int efd = eventfd(0, 0);
// 线程A: 发送通知
uint64_t val = 1;
write(efd, &val, sizeof(val));
// 线程B: 等待通知
uint64_t val;
read(efd, &val, sizeof(val));
七、本章总结
+----------------------------------------------------+
| 进程同步与通信核心知识 |
+----------------------------------------------------+
| |
| 同步机制: |
| 互斥锁(Mutex) -- 二值,保护临界区 |
| 读写锁(RWLock) -- 读共享,写互斥 |
| 信号量(Sem) -- 计数,控制并发数 |
| 自旋锁(Spin) -- 忙等,适合短临界区 |
| |
| 死锁: |
| 四条件: 互斥 + 持有等待 + 非抢占 + 循环等待 |
| 预防: 破坏条件 | 避免: 银行家算法 |
| |
| IPC 方式: |
| 管道 -- 简单,FIFO 流式 |
| 消息队列 -- 带类型,异步 |
| 共享内存 -- 最快,零拷贝 |
| 信号 -- 异步通知 |
| 套接字 -- 最通用,可跨网络 |
| |
+----------------------------------------------------+
📝 结尾自测:检验你的收获
- 什么是竞态条件?举一个日常开发中可能遇到的竞态条件例子。
- 互斥锁的底层通常依靠什么 CPU 指令实现?自旋锁和阻塞锁各适用于什么场景?
- 用信号量实现一个生产者-消费者模型,需要几个信号量?各自的初始值是什么?
- 死锁的四个必要条件是什么?如何通过”资源编号法”预防死锁?
- 共享内存是最快的 IPC 方式,为什么?它有什么缺点需要开发者注意?
购买课程解锁全部内容
系统底层入门:10 章掌握操作系统核心
¥29.90