尧图网站建设 尧图网络
  • 首页
  • 关于我们
  • 服务项目
  • 案例展示
  • 建站流程
  • 资讯中心
  • 联系我们
首页/资讯中心/详情

C++多线程同步实战:互斥锁与条件变量解决力扣1116交替打印问题

C++多线程同步实战:互斥锁与条件变量解决力扣1116交替打印问题
📅 发布时间:2026/7/25 6:28:01

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方法只负责输出0,even方法只输出偶数,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. 阻塞当前线程,等待被notify;3. 被唤醒后重新获取锁,并检查条件(通常在一个while循环里检查)。这个“检查-等待”的循环模式,是避免“虚假唤醒”的关键。

2.2 工具选型:为什么是mutex+condition_variable?

C++中线程同步有几组工具,为什么这道题最适合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::function<void(int)> printNumber) { for (int k = 0; k < n; ++k) { // 总共要打印n个0 std::unique_lock<std::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(); } }

逐段解析:

  1. for (int k = 0; k < n; ++k):zero线程需要打印n次0,因为最终序列是0,数字,0,数字,...,共有n个数字,所以也有n个0。
  2. std::unique_lock<std::mutex> lock(mtx): 进入临界区前先加锁,保护共享变量。
  3. cv.wait(lock, [this]() { return turn == 0; }): 这是条件等待的核心。wait方法会检查第二个参数(一个可调用对象,返回bool)。如果turn == 0为true,则wait直接返回,线程继续执行。如果为false,则wait会原子地释放锁mtx并将线程挂起。当其他线程调用cv.notify_all()时,此线程被唤醒,重新获取锁,然后再次检查条件turn == 0。这个循环检查是防止“虚假唤醒”的标准做法。
  4. printNumber(0): 执行实际打印。注意,题目要求调用传入的printNumber函数,而不是直接用std::cout。
  5. turn = (i % 2 == 1) ? 1 : 2:状态转移逻辑。这是zero线程的职责之一。打印完0之后,下一个该谁?取决于即将打印的数字i是奇数还是偶数。如果是奇数,下一个状态是1(odd线程);如果是偶数,下一个状态是2(even线程)。这里i的值还没有变化。
  6. cv.notify_all(): 广播通知。唤醒所有正在cv上等待的线程(此时主要是odd或even线程),让它们去竞争锁并检查自己的条件。

实操心得:cv.wait的谓词(第二个lambda)一定要写对。我曾经写成[this]{return turn != 0;},意思完全反了,导致线程直接卡死。记住,lambda返回true时,线程才会继续执行。

3.3 odd() 与 even() 方法实现:打印奇数和偶数

这两个方法逻辑高度对称,我们放在一起看。

// 打印奇数 void odd(std::function<void(int)> printNumber) { while (true) { std::unique_lock<std::mutex> lock(mtx); // 等待条件:1. 轮到奇数线程(turn==1) 且 2. 还有数字需要打印(i <= n) cv.wait(lock, [this]() { return turn == 1 || i > n; }); // 退出条件:如果i > n,说明所有数字都已打印完毕,线程结束 if (i > n) { // 在退出前,最好通知一下其他线程,避免它们永远等待 // 但本题逻辑中,当i>n时,zero线程也已结束,even线程也会因同样条件退出。 // 此处break即可,锁会在unique_lock析构时自动释放。 break; } // 条件满足,打印当前数字i printNumber(i); // 数字i已打印,i自增,准备下一个数字 i++; // 状态转移:奇数打印完后,下一个总是0 turn = 0; // 通知所有等待的线程 cv.notify_all(); } } // 打印偶数 void even(std::function<void(int)> printNumber) { while (true) { std::unique_lock<std::mutex> lock(mtx); // 等待条件:1. 轮到偶数线程(turn==2) 且 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(); } }

关键点解析:

  1. 循环与退出机制:odd和even线程使用while (true)循环,因为它们不知道具体要打印多少次。退出条件包含在wait的谓词和后续判断中:i > n。当所有数字打印完,i会变成n+1。此时,两个线程的等待条件都满足(i > n为真),它们会从wait中返回,然后进入if (i > n)分支,执行break退出循环。这是一个非常优雅的线程终止设计。
  2. 等待条件的复合逻辑:cv.wait(..., [this]() { return turn == 1 || i > n; })。这个谓词是关键。它意味着线程在两种情况下可以继续:一是轮到我了(turn == 1/2),二是工作已经结束了(i > n)。如果没有i > n这个条件,当所有数字打印完后,odd或even线程可能永远阻塞在wait上,因为再也不会有人将turn设置为1或2了。
  3. 状态转移的单一性: 无论是odd还是even线程,在完成打印后,都将状态turn设置为0。这是因为序列规律是0 -> 数字 -> 0 -> 数字 ...。打印完一个数字后,下一个必然是0。
  4. 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::function<void(int)> printNumber) { for (int k = 0; k < n; ++k) { std::unique_lock<std::mutex> lock(mtx); cv.wait(lock, [this]() { return turn == 0; }); printNumber(0); turn = (i % 2 == 1) ? 1 : 2; cv.notify_all(); } } void odd(std::function<void(int)> printNumber) { while (true) { std::unique_lock<std::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::function<void(int)> printNumber) { while (true) { std::unique_lock<std::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; // 测试 n=5,输出应为 0102030405 ZeroEvenOdd zeo(n); // 用于收集输出,避免多线程打印交错 std::string output; std::mutex output_mtx; auto print = [&](int x) { std::lock_guard<std::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常常难以复现,依赖“打印大法”有时会因为输出缓冲或时序问题而失效。以下是我常用的几种方法:

  1. 结构化日志与状态快照:不要只打印“线程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中,你可以将日志重定向到文件,方便分析。

  2. 使用调试器的条件断点:以GDB为例,你可以在wait函数调用处设置断点,并附加条件。例如,在zero线程的wait处设置条件turn != 0,这样只有当zero线程不该运行时它才会停在这里,帮你检查是哪个线程错误地修改了状态。在VSCode的launch.json中配置condition字段可以实现类似功能。

  3. 简化与放大问题:如果程序死锁,先把n设得很小(比如2或3),在关键操作前后打印状态。死锁通常发生在小规模运行时也能稳定复现。另外,可以尝试在notify_all()之后让当前线程短暂睡眠std::this_thread::sleep_for(std::chrono::milliseconds(10)),这有时会让竞争条件更容易暴露(但这不是解决方案,只是调试手段)。

  4. 静态分析工具:在Linux下,可以使用valgrind --tool=helgrind来检测数据竞争和死锁。它会指出哪些内存访问没有正确的锁保护,以及潜在的锁顺序问题。

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在适当的时候能增加到n+1。同时,检查zero线程的循环次数是否正确(必须是n次),确保最后一个0打印后,i能自增到n+1。
输出缺失最后的数字或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 方案变体与进阶思考

我们上面实现的是最经典、最清晰的互斥锁+条件变量方案。实际上,这道题还有其他的解法思路,它们各有特点:

  1. 信号量 (semaphore) 方案: C++20标准库引入了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。
    • 缺点:需要处理线程终止条件(信号量无法像条件变量那样方便地集成复合条件),且C++20之前需使用平台相关信号量或自行实现。
  2. 原子变量与自旋等待: 使用std::atomic<int>作为turn和i,线程在一个循环中不断检查turn是否等于自己的标识。

    while (turn.load() != my_turn) { std::this_thread::yield(); // 让出CPU时间片 } // ... 执行工作 ... turn.store(next_turn);
    • 优点:在争用不激烈的情况下可能更高效。
    • 缺点:忙等待,浪费CPU资源。不适合生产环境,但作为理解“锁”的替代方案有一定教学意义。
  3. 无锁编程: 尝试用std::atomic和compare_exchange_strong实现一个无锁的状态机。这属于高阶话题,实现复杂且容易出错,但性能理论上最优。对于这道题来说属于“杀鸡用牛刀”,但作为学习挑战很有价值。

选择哪种方案?对于面试和力扣刷题,互斥锁+条件变量的方案是首选。因为它:

  • 标准:使用的是C++标准库组件,可移植性好。
  • 高效:在条件不满足时线程会挂起,不消耗CPU。
  • 清晰:状态机模型和代码逻辑对应关系明确,易于理解和维护。
  • 通用:这种模式是解决线程间顺序协作的通用范式,掌握后可以应用到很多类似场景。

最后,我再分享一个自己调试时的小技巧:在复杂的状态转移逻辑中,我常常会画一个简单的状态转移图,或者用纸笔模拟几个线程的步骤。把turn和i的值变化列出来,对照代码看,往往能很快发现逻辑上的漏洞。多线程编程,清晰的思路比复杂的代码更重要。当你把共享数据、同步条件和线程职责划分清楚后,剩下的就是把这些翻译成mutex和condition_variable的固定“语法”了。

相关新闻

  • Linux 7.2内核slab分配器延迟构建freelist优化解析与验证
  • C++从零实现卡尔曼滤波:二维目标跟踪实战与参数调优
  • Windows安卓子系统免费安装终极指南:在Windows 11上轻松运行安卓应用

最新新闻

  • 基于YOLOv8的绝缘子缺陷检测系统开发与实践
  • AI智能体架构解析与实战:从原理到落地
  • 大模型预训练核心技术:动态批处理与混合精度优化
  • NCM文件解密:逆向解析网易云音乐加密音频的完整指南
  • AI工具如何提升专科论文写作效率
  • LVQ神经网络在人脸朝向识别中的应用与实践

日新闻

  • 从国家条件到买方清单,深入理解 ABAP CDS 单值过滤器派生
  • 2026 年当下,齐齐哈尔专业的不锈钢闸门批发厂家哪个好,揭秘!这个工业“铁门”如何实现成本翻倍的效率提升? - 行业甄选官
  • 2026阳极氧化加工厂推荐:从设备规模看硬质氧化技术的成熟应用推荐百正机械 - 栗子测评

周新闻

  • SaaS软件行业GEO实践:AI搜索时代的品牌可见性与获客新路径
  • 什么是PCTFE?医药高端包装的“防潮王牌“材料
  • 【JVM调优实战】16-可视化利器-JConsole-VisualVM-JMC

月新闻

  • 2026年6月公司网站搭建最新热门渠道测评:四大低成本/零代码平台对比+避坑
  • 【Linux】Linux arm 编译QT程序,出现expected “}“报错
  • 【MATLAB例程】四基站二维AOA定位与距离辅助增强对比仿真。基于角度观测和测距修正的固定目标平面定位精度分析

关于尧图

  • 公司简介
  • 团队介绍
  • 企业文化
  • 荣誉资质

服务项目

  • 定制开发
  • 电商建站
  • UI 设计
  • 运维服务

快速链接

  • 案例展示
  • 建站流程
  • 常见问题
  • 资讯中心

联系方式

  • 📍北京市朝阳区互联网产业园 A 座 10 层
  • 📞400-888-8888
  • ✉️contact@rkmt.cn
  • 🕐周一至周日 9:00-21:00

© 2024 北京尧图网络科技有限公司 版权所有 | 京 ICP 备 XXXXXXXX 号