C++多线程同步实战:互斥锁与条件变量解决力扣1116交替打印问题
1. 项目概述与核心挑战最近在力扣上刷到一道经典的多线程题目——第1116题“打印零与奇偶数”这道题可以说是多线程同步与通信的“试金石”。题目要求我们用三个不同的线程去协作打印一个序列其中一个线程专门打印0另外两个线程分别打印偶数和奇数。乍一看这像是一个简单的顺序打印问题但当你真正用代码去实现线程间的精准“握手”时才会发现里面藏着不少门道。它考察的不仅仅是你会不会创建线程更深层次的是对互斥锁、条件变量这些同步原语的理解以及如何设计一个清晰、无死锁的协作逻辑。我自己在实现这道题时最大的感触就是多线程编程难就难在“确定性”上。单线程代码你写for循环顺序是板上钉钉的。但多线程环境下几个线程像脱缰的野马同时跑你怎么确保它们能按“0 - 奇数 - 0 - 偶数 - 0 - 奇数 - ...”这样的固定节奏跳舞而不会乱成一团或者干脆卡住不动这就是我们需要用同步机制给这些“野马”套上缰绳让它们听从统一的指挥。用C来做这道题尤其有代表性因为C标准库提供的mutex和condition_variable是构建这类同步逻辑的基础工具理解它们就等于掌握了多线程协作的核心。所以这篇文章我会带你从零开始拆解这道题。我们不止步于AC通过题目更要深挖每一步背后的“为什么”。比如为什么这里要用std::unique_lock而不是std::lock_guard条件变量的wait函数内部到底做了什么如何设计状态变量才能让逻辑最清晰我会结合我调试时踩过的坑把完整的思路、代码以及那些容易忽略的细节都摊开来讲清楚。目标很简单让你不仅能写出通过的代码更能透彻理解多线程协同工作的设计模式以后遇到类似的“线程交替打印”问题都能举一反三。2. 解题思路设计与同步原理解析2.1 问题重述与状态机建模力扣1116题的官方描述是提供一个类ZeroEvenOdd它有一个带参构造函数ZeroEvenOdd(int n)和一个zero方法、一个even方法、一个odd方法。你需要启动三个线程分别调用这三个方法。zero方法只负责输出0even方法只输出偶数odd方法只输出奇数。对于输入n最终输出的序列应该是0102030405...0n当n为偶数时或0102030405...0n当n为奇数时最后一位是奇数。换句话说输出是一个0和数字交替的序列数字部分是从1到n的奇偶交错。面对这个问题最直观的暴力想法可能是让三个线程“抢着”打印但这绝对行不通因为CPU调度是不确定的结果必然是乱序。我们必须施加约束让线程的执行变成“有条件”的。这里我引入“状态机”的思考方式。我们可以把整个打印过程看作一个状态机当前应该哪个线程执行就是一个明确的“状态”。我定义了一个状态变量turn它有三种可能0: 当前应该由打印0的线程执行。1: 当前应该由打印奇数的线程执行。2: 当前应该由打印偶数的线程执行。初始状态是turn 0因为序列总是从0开始。然后状态会按照0 - 1 - 0 - 2 - 0 - 1 - 0 - 2 - ...这样的规律循环直到所有数字打印完毕。每个线程在行动前都需要检查当前的状态turn是不是轮到我了如果不是我就必须等待。当一个线程完成自己的打印任务后它要负责计算出下一个应该轮到谁并更新turn状态同时通知所有在等待的线程“状态变了你们看看是不是该自己上了。”这就是条件变量的典型应用场景等待一个条件成立。在C中std::condition_variable的wait函数会做三件事1. 释放传入的锁2. 阻塞当前线程等待被notify3. 被唤醒后重新获取锁并检查条件通常在一个while循环里检查。这个“检查-等待”的循环模式是避免“虚假唤醒”的关键。2.2 工具选型为什么是mutexcondition_variableC中线程同步有几组工具为什么这道题最适合mutex互斥锁配condition_variable条件变量互斥锁 (std::mutex): 它的核心作用是保证对共享数据状态变量turn和当前打印的数字i的访问是互斥的。想象一下如果没有锁两个线程可能同时读取和修改turn结果就是不可预测的脏数据。锁提供了基础的互斥保障。条件变量 (std::condition_variable): 它解决了互斥锁无法解决的问题——高效等待。如果只用互斥锁线程发现不轮到自己时只能循环“加锁 - 检查条件 - 解锁 - 睡眠片刻”这被称为“忙等待”非常浪费CPU。条件变量允许线程在条件不满足时主动释放锁并进入睡眠直到被其他线程唤醒这大大提高了效率。std::unique_lock在这里是必须的因为它比std::lock_guard更灵活。condition_variable::wait函数需要能够解锁和重新加锁的能力这正是unique_lock提供的通过lock()和unlock()成员函数而lock_guard在构造时加锁析构时解锁中途不能释放锁。所以我们的方案骨架就出来了一个保护共享状态的互斥锁m一个用于线程等待和通知的条件变量cv一个表示当前轮到谁的状态turn以及一个记录当前要打印数字的计数器i。注意有些初学者可能会想用原子变量std::atomic来代替锁。对于简单的计数器原子变量是高效的。但在这道题中我们的“条件”是复杂的判断turn并可能等待并且涉及“检查条件-进入等待-被唤醒”这一系列操作这本身就是一个需要原子化的“事务”。条件变量和互斥锁的配合正是为了优雅地处理这种“等待特定条件”的同步模式用单纯的原子变量实现起来会非常复杂且容易出错。3. 核心代码实现与逐行解析接下来我们进入实战环节。我会先给出完整的类定义然后分段进行详细解析包括每一行代码的意图和容易踩坑的地方。3.1 类定义与成员变量#include mutex #include condition_variable #include functional class ZeroEvenOdd { private: int n; // 需要打印的最大数字 int i; // 当前即将要打印的数字从1开始 int turn; // 状态0-zero, 1-odd, 2-even std::mutex mtx; std::condition_variable cv; public: ZeroEvenOdd(int n) { this-n n; this-i 1; // 第一个要打印的数字是1 this-turn 0; // 初始状态必须由zero线程先打印 }变量解读:n: 题目输入打印的数字范围是1到n。i: 这是一个非常关键的计数器。它表示下一个需要被odd或even线程打印的数字值。注意它从1开始因为第一个数字是1奇数。zero线程不关心i的值它只打印0。turn: 核心状态机。0、1、2分别代表三个线程的回合。初始化为0确保zero先跑。mtx和cv: 我们的同步黄金搭档。构造函数要点: 初始化顺序很重要。必须保证i和turn在任何一个线程启动前就已经处于正确的初始状态。如果先启动线程再初始化可能会发生数据竞争。3.2 zero() 方法实现打印0的线程// 打印0 void zero(std::functionvoid(int) printNumber) { for (int k 0; k n; k) { // 总共要打印n个0 std::unique_lockstd::mutex lock(mtx); // 等待条件只有当turn 0时zero线程才能工作 cv.wait(lock, [this]() { return turn 0; }); // 条件满足执行打印 printNumber(0); // 决定下一个状态根据当前数字i的奇偶性 // 如果i是奇数下一个该打印奇数(状态1) // 如果i是偶数下一个该打印偶数(状态2) // 注意此时i尚未自增代表的是即将打印的数字 turn (i % 2 1) ? 1 : 2; // 通知所有等待的线程状态已更新 cv.notify_all(); } }逐段解析:for (int k 0; k n; k):zero线程需要打印n次0因为最终序列是0,数字,0,数字,...共有n个数字所以也有n个0。std::unique_lockstd::mutex lock(mtx): 进入临界区前先加锁保护共享变量。cv.wait(lock, [this]() { return turn 0; }): 这是条件等待的核心。wait方法会检查第二个参数一个可调用对象返回bool。如果turn 0为true则wait直接返回线程继续执行。如果为false则wait会原子地释放锁mtx并将线程挂起。当其他线程调用cv.notify_all()时此线程被唤醒重新获取锁然后再次检查条件turn 0。这个循环检查是防止“虚假唤醒”的标准做法。printNumber(0): 执行实际打印。注意题目要求调用传入的printNumber函数而不是直接用std::cout。turn (i % 2 1) ? 1 : 2:状态转移逻辑。这是zero线程的职责之一。打印完0之后下一个该谁取决于即将打印的数字i是奇数还是偶数。如果是奇数下一个状态是1odd线程如果是偶数下一个状态是2even线程。这里i的值还没有变化。cv.notify_all(): 广播通知。唤醒所有正在cv上等待的线程此时主要是odd或even线程让它们去竞争锁并检查自己的条件。实操心得cv.wait的谓词第二个lambda一定要写对。我曾经写成[this]{return turn ! 0;}意思完全反了导致线程直接卡死。记住lambda返回true时线程才会继续执行。3.3 odd() 与 even() 方法实现打印奇数和偶数这两个方法逻辑高度对称我们放在一起看。// 打印奇数 void odd(std::functionvoid(int) printNumber) { while (true) { std::unique_lockstd::mutex lock(mtx); // 等待条件1. 轮到奇数线程(turn1) 且 2. 还有数字需要打印(i n) cv.wait(lock, [this]() { return turn 1 || i n; }); // 退出条件如果i n说明所有数字都已打印完毕线程结束 if (i n) { // 在退出前最好通知一下其他线程避免它们永远等待 // 但本题逻辑中当in时zero线程也已结束even线程也会因同样条件退出。 // 此处break即可锁会在unique_lock析构时自动释放。 break; } // 条件满足打印当前数字i printNumber(i); // 数字i已打印i自增准备下一个数字 i; // 状态转移奇数打印完后下一个总是0 turn 0; // 通知所有等待的线程 cv.notify_all(); } } // 打印偶数 void even(std::functionvoid(int) printNumber) { while (true) { std::unique_lockstd::mutex lock(mtx); // 等待条件1. 轮到偶数线程(turn2) 且 2. 还有数字需要打印(i n) cv.wait(lock, [this]() { return turn 2 || i n; }); if (i n) { break; } printNumber(i); i; // 状态转移偶数打印完后下一个也是0 turn 0; cv.notify_all(); } }关键点解析:循环与退出机制:odd和even线程使用while (true)循环因为它们不知道具体要打印多少次。退出条件包含在wait的谓词和后续判断中i n。当所有数字打印完i会变成n1。此时两个线程的等待条件都满足i n为真它们会从wait中返回然后进入if (i n)分支执行break退出循环。这是一个非常优雅的线程终止设计。等待条件的复合逻辑:cv.wait(..., [this]() { return turn 1 || i n; })。这个谓词是关键。它意味着线程在两种情况下可以继续一是轮到我了(turn 1/2)二是工作已经结束了(i n)。如果没有i n这个条件当所有数字打印完后odd或even线程可能永远阻塞在wait上因为再也不会有人将turn设置为1或2了。状态转移的单一性: 无论是odd还是even线程在完成打印后都将状态turn设置为0。这是因为序列规律是0 - 数字 - 0 - 数字 ...。打印完一个数字后下一个必然是0。i的自增时机: 注意i的自增是在打印之后。zero线程判断下一个状态时用的是自增前的i即将打印的数字。odd/even线程打印的也是当前的i打印完成后才将i指向下一个数字。这个顺序保证了逻辑的一致性。3.4 完整的可运行代码示例将以上部分组合起来并提供一个简单的main函数测试#include iostream #include thread #include mutex #include condition_variable #include functional class ZeroEvenOdd { private: int n; int i; int turn; // 0-zero, 1-odd, 2-even std::mutex mtx; std::condition_variable cv; public: ZeroEvenOdd(int n) { this-n n; this-i 1; this-turn 0; } void zero(std::functionvoid(int) printNumber) { for (int k 0; k n; k) { std::unique_lockstd::mutex lock(mtx); cv.wait(lock, [this]() { return turn 0; }); printNumber(0); turn (i % 2 1) ? 1 : 2; cv.notify_all(); } } void odd(std::functionvoid(int) printNumber) { while (true) { std::unique_lockstd::mutex lock(mtx); cv.wait(lock, [this]() { return turn 1 || i n; }); if (i n) break; printNumber(i); i; turn 0; cv.notify_all(); } } void even(std::functionvoid(int) printNumber) { while (true) { std::unique_lockstd::mutex lock(mtx); cv.wait(lock, [this]() { return turn 2 || i n; }); if (i n) break; printNumber(i); i; turn 0; cv.notify_all(); } } }; int main() { int n 5; // 测试 n5输出应为 0102030405 ZeroEvenOdd zeo(n); // 用于收集输出避免多线程打印交错 std::string output; std::mutex output_mtx; auto print [](int x) { std::lock_guardstd::mutex lock(output_mtx); output std::to_string(x); }; std::thread t1([]() { zeo.zero(print); }); std::thread t2([]() { zeo.odd(print); }); std::thread t3([]() { zeo.even(print); }); t1.join(); t2.join(); t3.join(); std::cout 输出序列: output std::endl; // 预期输出: 0102030405 return 0; }4. 调试技巧、常见问题与进阶思考4.1 多线程调试实战心得多线程Bug常常难以复现依赖“打印大法”有时会因为输出缓冲或时序问题而失效。以下是我常用的几种方法结构化日志与状态快照不要只打印“线程A开始”而是打印带时间戳和关键状态的日志。例如auto ts std::chrono::system_clock::now(); std::time_t t std::chrono::system_clock::to_time_t(ts); std::cout std::ctime(t) Thread Zero: turn turn , i i std::endl;这能帮你清晰地看到状态变化的时序。在VSCode或CLion中你可以将日志重定向到文件方便分析。使用调试器的条件断点以GDB为例你可以在wait函数调用处设置断点并附加条件。例如在zero线程的wait处设置条件turn ! 0这样只有当zero线程不该运行时它才会停在这里帮你检查是哪个线程错误地修改了状态。在VSCode的launch.json中配置condition字段可以实现类似功能。简化与放大问题如果程序死锁先把n设得很小比如2或3在关键操作前后打印状态。死锁通常发生在小规模运行时也能稳定复现。另外可以尝试在notify_all()之后让当前线程短暂睡眠std::this_thread::sleep_for(std::chrono::milliseconds(10))这有时会让竞争条件更容易暴露但这不是解决方案只是调试手段。静态分析工具在Linux下可以使用valgrind --toolhelgrind来检测数据竞争和死锁。它会指出哪些内存访问没有正确的锁保护以及潜在的锁顺序问题。4.2 典型问题排查清单下面表格总结了我遇到或能预见的几个典型问题及其解决方法问题现象可能原因排查与解决思路程序编译通过但运行无输出或立即结束主线程main先于子线程结束导致程序退出。确保在main函数结束前调用了所有工作线程的join()方法等待它们执行完毕。输出顺序完全混乱线程间完全没有同步。检查是否忘记了使用互斥锁mtx保护共享变量turn和i。确保每个线程在读写它们时都持有锁。程序死锁卡住不动1.等待条件错误某个线程的wait条件永远无法满足。2.notify丢失线程在调用notify_all时没有其他线程在等待。3.锁管理不当异常路径导致锁未释放。1. 仔细检查每个cv.wait的谓词lambda。确保逻辑正确特别是退出条件i n。2. 确保线程的启动顺序。如果zero线程还没开始等待odd线程就notify_all了这次通知就丢失了。但这通常不会导致死锁因为后续还有通知。更常见的是条件谓词写错。3. 使用std::unique_lock等RAII类管理锁即使发生异常也能保证锁被释放。输出结果正确但程序结束后不退出线程未终止odd或even线程的退出条件不满足。检查while循环中的退出条件if (i n) break;。确保i在适当的时候能增加到n1。同时检查zero线程的循环次数是否正确必须是n次确保最后一个0打印后i能自增到n1。输出缺失最后的数字或0循环次数计算错误或状态转移逻辑有误。zero线程循环n次打印n个0。odd/even线程总共打印n个数字。检查zero线程中for (int k 0; k n; k)确保是k n而不是k n。检查odd/even线程中i的自增逻辑是否在打印之后。在n较大时如1000程序偶尔出错可能存在虚假唤醒。condition_variable::wait可能在没有被notify的情况下返回。这是最隐蔽的问题。必须使用wait的重载版本它接受一个谓词第二个参数。就像我们代码中写的cv.wait(lock, predicate)。这个wait方法会在返回前自动重新检查谓词如果为假则继续等待从而免疫虚假唤醒。如果用的是单参数的wait(lock)返回后必须用while循环手动检查条件。4.3 方案变体与进阶思考我们上面实现的是最经典、最清晰的互斥锁条件变量方案。实际上这道题还有其他的解法思路它们各有特点信号量 (semaphore) 方案 C20标准库引入了std::counting_semaphore。可以用三个信号量zero_sem(初始为1),odd_sem(初始为0),even_sem(初始为0)来控制执行顺序。zero线程zero_sem.acquire()- 打印0 - 根据i奇偶释放odd_sem或even_sem。odd线程odd_sem.acquire()- 打印i-i- 释放zero_sem。even线程even_sem.acquire()- 打印i-i- 释放zero_sem。优点逻辑直观无需显式的状态变量turn。缺点需要处理线程终止条件信号量无法像条件变量那样方便地集成复合条件且C20之前需使用平台相关信号量或自行实现。原子变量与自旋等待 使用std::atomicint作为turn和i线程在一个循环中不断检查turn是否等于自己的标识。while (turn.load() ! my_turn) { std::this_thread::yield(); // 让出CPU时间片 } // ... 执行工作 ... turn.store(next_turn);优点在争用不激烈的情况下可能更高效。缺点忙等待浪费CPU资源。不适合生产环境但作为理解“锁”的替代方案有一定教学意义。无锁编程 尝试用std::atomic和compare_exchange_strong实现一个无锁的状态机。这属于高阶话题实现复杂且容易出错但性能理论上最优。对于这道题来说属于“杀鸡用牛刀”但作为学习挑战很有价值。选择哪种方案对于面试和力扣刷题互斥锁条件变量的方案是首选。因为它标准使用的是C标准库组件可移植性好。高效在条件不满足时线程会挂起不消耗CPU。清晰状态机模型和代码逻辑对应关系明确易于理解和维护。通用这种模式是解决线程间顺序协作的通用范式掌握后可以应用到很多类似场景。最后我再分享一个自己调试时的小技巧在复杂的状态转移逻辑中我常常会画一个简单的状态转移图或者用纸笔模拟几个线程的步骤。把turn和i的值变化列出来对照代码看往往能很快发现逻辑上的漏洞。多线程编程清晰的思路比复杂的代码更重要。当你把共享数据、同步条件和线程职责划分清楚后剩下的就是把这些翻译成mutex和condition_variable的固定“语法”了。