写在前面:这是本系列的第十五篇。

在上一讲中,借助硬件原子指令和操作系统的帮助,我们终于驯服了并发这头野兽,实现了高效的互斥锁(Mutex/Spinlock)。互斥锁能够确保保护的代码块按照某种未知的顺序原子地执行。

但是,互斥并不总是能满足并发线程协作的需求。在很多场景下,我们不仅需要原子性,还需要控制代码块执行的先后次序(确定性)。今天,我们将学习并发控制的核心武器:同步 (Synchronization),以及并发编程领域的“万能钥匙”——条件变量。

在这里插入图片描述

互斥的局限性:好消息与坏消息

好消息!!!

我们终于可以实现绝对正确的 1 + 1 了!使用自旋锁 (spin_lock) 或互斥锁 (mutex_lock),我们把并发的指令序列强行变成了串行:

lock(&lock);
sum++;
// 任意代码
unlock(&lock);

坏消息…

互斥似乎还不够。

  • 互斥实现了原子性: 保证了要么执行 $ A \rightarrow B $,要么执行 $ B \rightarrow A $,两者绝对不会交织重叠。
  • 但互斥没有给我们确定性: 我们无法控制到底是谁先谁后!
  • 我们的目标是:让共享内存的线程像齿轮一样精准咬合,按照我们预定的顺序协同工作。

同步与条件变量 (Synchronization)

什么是同步?

维基百科:“两个或两个以上随时间变化的量在变化过程中保持一定的相对关系。”
DeepSeek:“多个事件、进程或系统在时间上协调一致,确保按预定顺序同时执行。”

在程序世界里,我们希望控制事件发生的先后顺序(比如 $ A \rightarrow B \rightarrow C $)。互斥锁只能确保 A、B、C 分开执行,但做不到顺序控制。我们希望在多线程的混沌中,建立起受我们绝对控制的 “happens-before” 关系。

现实世界的同步类比

场景 1:合唱团 (避免越跑越偏)

  • 每个乐手都是一个“线程”。
  • 事件:指挥发出节拍 $ \rightarrow $ 乐手演奏节拍。
  • 如果没有严格的同步,某个乐手演奏得太长,整个乐队就会陷入混乱。
void T_player() {
    while (!end) {
        wait_next_beat(); // 同步点:等待指挥
        play_next_beat();
    }
}

场景 2:不见不散 (会合点)

  • 线程 A:第一个人到达。
  • 线程 B:第二个人到达。
  • 只有当 A 和 B 都到达后,才能共同触发事件 XXX(类似线程的 join)。
  • 核心逻辑: 一定有一瞬间,A 和 B 都完成了到达动作,且 XXX 还没有开始。这个确定的状态就是同步点。同步,就是将发散的并发程序状态“收束”到一起。
void T_A() {
    arrive_at_activity_center();  
    while (!both_A_and_B_are_here()) ;  // 不见不散,在此死等
    xxx();
}

问问 OS?引入条件变量 (Condition Variables)

我们在用户态写 while (!can_proceed) ; 会导致 CPU 疯狂空转(Busy Waiting,忙等待),不仅浪费算力,在单核系统上甚至可能引发死锁。

能不能让操作系统帮我们等待?

于是,计算机科学家“发明”了条件变量 (Condition Variables) 机制:

  • wait: 当条件不满足时,直接让出 CPU 进入睡眠等待。
  • signal** / broadcast😗* 当条件满足时,唤醒正在等待的线程。

C++11 中的条件变量与 Lambda 表达式

现代语言把底层的 API 封装得极其优雅。比如在 C++ 中,我们可以把等待的条件直接写进 Lambda 表达式里:

std::mutex mtx;                 // 互斥锁
std::condition_variable cv;     // 条件变量

void T_player() {
    std::unique_lock lk(mtx);   // 必须先获取锁!
    
    cv.wait(lk,
        []{ return can_proceed; } // 这行代码简直就是自然语言!
    );

    // 线程运行到这,意味着 can_proceed 成立,且成功拿回了互斥锁 lk
    
    cv.notify_all();    // 通知所有等待该条件的线程
    lk.unlock();        // 手动释放锁
}

极客铁律: 使用条件变量之前,必须、必须、必须带上一把互斥锁!否则连编译都过不去。不要碰瓷并发编程,老老实实使用绝对正确的范式。


条件变量的正确打开方式:生产者-消费者问题

学废你就赢了!

99% 的实际并发问题都可以用“生产者-消费者”模型来解决!

  • Producer (生产者): 生产数据。如果缓冲区满了,就睡觉等待;如果有空位,放入数据,并叫醒消费者。
  • Consumer (消费者): 消费数据。如果缓冲区空了,就睡觉等待;如果有数据,取走数据,并叫醒生产者。

一个极其简化的等价描述:打印括号

假设:

  • 生产 = 打印左括号 (
  • 消费 = 打印右括号 )

规则:

  1. 不能输出错误的括号序列(右括号不能比左括号多,不能出现 ()))。
  2. 括号嵌套的深度不超过 n(缓冲区的最大容量。如果 n=3,连续出现 4 个 (((( 就是错的)。

终极抄代码模板

使用条件变量,永远按照这个固定范式来写。想清楚程序继续执行的条件是什么:

// 生产者的条件:深度 d < n 可以生产
void produce() {
    mutex_lock(🔒);
    
    while (!(depth < n)) {      // 条件不满足,把锁交出去并睡觉
        cond_wait(&cv, 🔒);
    }

    assert(depth < n);          // 醒来并且拿到锁,条件必然满足
    depth++;
    printf("(");                // 执行生产操作

    cond_broadcast(&cv);        // 唤醒所有人
    mutex_unlock(🔒);           // 释放锁
}

🚨 极客避坑指南:小心 Signal 的“虚假唤醒”

原笔记中提到,如果把 cond_broadcast(&cv) 换成 cond_signal(&cv) 会非常危险。

为什么“看起来正确”其实很致命?

  • cond_signal 只会随机唤醒一个正在睡觉的线程。
  • 如果一个生产者生产完后,刚好唤醒了另一个生产者(此时缓冲区已满),被唤醒的生产者一看条件不满足,继续睡。而真正该被唤醒的消费者却还在死睡!最终导致全局死锁。

Kimi 给出的正解:
永远使用两个条件变量!

  1. cv_producer:供生产者等待(等待不满)。
  2. cv_consumer:供消费者等待(等待不空)。
  • 生产者生产完后,只 signal 消费者;消费者消费完后,只 signal 生产者。这样既高效又绝对安全!

条件变量:万能的同步法

假设有三种线程分别打印 <>_,要求最终屏幕只出现 <><_><>_ 的组合。
这看起来像大脑体操,但使用条件变量,你只需要死板地回答三个问题:

  • 打印 < 的条件是什么?
  • 打印 > 的条件是什么?
  • 打印 _ 的条件是什么?
    然后套用上面的 while(!cond) wait() 模板,一切迎刃而解!

从同步走向并行:实现并发计算图 (DAG)

操作系统课程为什么要讲这个?因为掌握了同步,你就掌握了压榨现代多核 CPU 的终极钥匙!

理解你的计算任务:计算图模型 G(V,E)

任何复杂的并行计算,都可以被抽象为一张有向无环图 (DAG, Directed Acyclic Graph)

  • 节点 (V): 代表一个独立的计算任务。
  • 边 (u, v) $ \in $ E: 表示节点 v 的计算必须等待节点 u 产生的值。这就是一个绝对的 happens-before 关系!

经典例子:

  • Longest Common Subsequence (最长公共子序列): 经典的动态规划矩阵,可以沿着对角线一层层并行计算。
  • 电路模拟: 逻辑门的输出作为下一个逻辑门的输入。
  • 深度神经网络 (DNN): 神经网络的前向传播和反向传播,天生就是一张庞大的并行计算图!

同步:如何用代码实现任意计算图?

方案 1:为每个计算节点设置一个线程和条件变量

void T_u() {  // 节点 u 的逻辑
    ... // 执行 u 的核心计算
    
    // 通知下游的 v 节点:我已经算完了
    mutex_lock(v->lock);
    v->num_done++;
    cond_signal(v->cv);  
    mutex_unlock(v->lock);
}

void T_v() {  // 节点 v 的逻辑
    // 先等待所有的前置节点算完
    mutex_lock(v->lock);
    while (!(v->num_done == v->num_predecessors)) {
        cond_wait(v->cv, v->lock);
    }
    mutex_unlock(v->lock);
    
    ... // 执行 v 的核心计算
}

方案 2:工业级的任务调度器 (Task Scheduler)

为图中的每个节点单独开一个线程太浪费了。在真实工程中,我们会实现一个线程池 + 调度器

  • 一个生产者 (scheduler) 不断解析 DAG 图,把准备就绪的任务塞进队列。
  • 若干个消费者 (workers) 循环读取任务并执行。这其实就是现代计算引擎(如 TensorFlow/PyTorch 执行图)的最底层原型!

总结

Take-away messages:

同步的本质,是线程需要等待某件它所预期的事件发生。而事件的发生,总是可以用某种条件(例如深度 depth 满足要求,或者是前置节点计算完毕)来精确表达。

正因如此,计算机系统的先驱们设计了条件变量 (Condition Variables),将“条件检查”和“临界区休眠”完美地绑定在一起,从而在宏观上掌控了多线程宇宙的混沌,使得并发程序的执行变得可控、可靠且高效。

掌握了条件变量与生产者-消费者模型,你就真正跨过了并发编程的最难门槛。

Logo

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

更多推荐