写在前面这是本系列的第十五篇。在上一讲中借助硬件原子指令和操作系统的帮助我们终于驯服了并发这头野兽实现了高效的互斥锁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 $ 乐手演奏节拍。如果没有严格的同步某个乐手演奏得太长整个乐队就会陷入混乱。voidT_player(){while(!end){wait_next_beat();// 同步点等待指挥play_next_beat();}}场景 2不见不散 (会合点)线程 A第一个人到达。线程 B第二个人到达。只有当 A 和 B 都到达后才能共同触发事件 XXX类似线程的join。核心逻辑一定有一瞬间A 和 B 都完成了到达动作且 XXX 还没有开始。这个确定的状态就是同步点。同步就是将发散的并发程序状态“收束”到一起。voidT_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* 当条件满足时唤醒正在等待的线程。C11 中的条件变量与 Lambda 表达式现代语言把底层的 API 封装得极其优雅。比如在 C 中我们可以把等待的条件直接写进 Lambda 表达式里std::mutex mtx;// 互斥锁std::condition_variable cv;// 条件变量voidT_player(){std::unique_locklk(mtx);// 必须先获取锁cv.wait(lk,[]{returncan_proceed;}// 这行代码简直就是自然语言);// 线程运行到这意味着 can_proceed 成立且成功拿回了互斥锁 lkcv.notify_all();// 通知所有等待该条件的线程lk.unlock();// 手动释放锁}极客铁律使用条件变量之前必须、必须、必须带上一把互斥锁否则连编译都过不去。不要碰瓷并发编程老老实实使用绝对正确的范式。条件变量的正确打开方式生产者-消费者问题学废你就赢了99% 的实际并发问题都可以用“生产者-消费者”模型来解决Producer (生产者)生产数据。如果缓冲区满了就睡觉等待如果有空位放入数据并叫醒消费者。Consumer (消费者)消费数据。如果缓冲区空了就睡觉等待如果有数据取走数据并叫醒生产者。一个极其简化的等价描述打印括号假设生产 打印左括号(消费 打印右括号)规则不能输出错误的括号序列右括号不能比左括号多不能出现())。括号嵌套的深度不超过n缓冲区的最大容量。如果 n3连续出现 4 个((((就是错的。终极抄代码模板使用条件变量永远按照这个固定范式来写。想清楚程序继续执行的条件是什么// 生产者的条件深度 d n 可以生产voidproduce(){mutex_lock();while(!(depthn)){// 条件不满足把锁交出去并睡觉cond_wait(cv,);}assert(depthn);// 醒来并且拿到锁条件必然满足depth;printf(();// 执行生产操作cond_broadcast(cv);// 唤醒所有人mutex_unlock();// 释放锁} 极客避坑指南小心 Signal 的“虚假唤醒”原笔记中提到如果把cond_broadcast(cv)换成cond_signal(cv)会非常危险。为什么“看起来正确”其实很致命cond_signal只会随机唤醒一个正在睡觉的线程。如果一个生产者生产完后刚好唤醒了另一个生产者此时缓冲区已满被唤醒的生产者一看条件不满足继续睡。而真正该被唤醒的消费者却还在死睡最终导致全局死锁。Kimi 给出的正解永远使用两个条件变量cv_producer供生产者等待等待不满。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为每个计算节点设置一个线程和条件变量voidT_u(){// 节点 u 的逻辑...// 执行 u 的核心计算// 通知下游的 v 节点我已经算完了mutex_lock(v-lock);v-num_done;cond_signal(v-cv);mutex_unlock(v-lock);}voidT_v(){// 节点 v 的逻辑// 先等待所有的前置节点算完mutex_lock(v-lock);while(!(v-num_donev-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)将“条件检查”和“临界区休眠”完美地绑定在一起从而在宏观上掌控了多线程宇宙的混沌使得并发程序的执行变得可控、可靠且高效。掌握了条件变量与生产者-消费者模型你就真正跨过了并发编程的最难门槛。