跳到主要内容
预计阅读 33 分钟

04|进程同步与通信

多个进程或线程共存时,“抢资源”和”传消息”是两个核心问题。本章将带你掌握从互斥锁到死锁预防的同步机制,以及管道、消息队列、共享内存等进程间通信手段。

📋 开篇自测:你已经知道多少?

  1. 什么是临界区?为什么需要对临界区进行保护?
  2. 互斥锁和信号量有什么区别?各自适用于什么场景?
  3. 你能列举出 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
                           [获得许可, 进入]
                             |
                             | === 临界区 ===
                             | 操作共享资源
                             | === 临界区 ===
                             |
                             | 离开临界区

一个正确的临界区保护方案必须满足三个条件:

  1. 互斥性:同一时刻最多一个执行流在临界区内
  2. 有限等待:请求进入的执行流不能无限期等待(无饥饿)
  3. 空闲让进:临界区空闲时,请求进入的执行流应能立即进入

二、互斥锁(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_tmutex / 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 流式                     |
|    消息队列   -- 带类型,异步                         |
|    共享内存   -- 最快,零拷贝                         |
|    信号       -- 异步通知                            |
|    套接字     -- 最通用,可跨网络                     |
|                                                    |
+----------------------------------------------------+

📝 结尾自测:检验你的收获

  1. 什么是竞态条件?举一个日常开发中可能遇到的竞态条件例子。
  2. 互斥锁的底层通常依靠什么 CPU 指令实现?自旋锁和阻塞锁各适用于什么场景?
  3. 用信号量实现一个生产者-消费者模型,需要几个信号量?各自的初始值是什么?
  4. 死锁的四个必要条件是什么?如何通过”资源编号法”预防死锁?
  5. 共享内存是最快的 IPC 方式,为什么?它有什么缺点需要开发者注意?

购买课程解锁全部内容

系统底层入门:10 章掌握操作系统核心

¥29.90