Day1:从 0 开始写出“奇偶数交替打印”的多线程代码

13 阅读10分钟

这是我正式开始刷操作系统 / 多线程手撕题的第一天。
题目不难,也不新,但对一个刚拾起 C++ 多线程知识的新手来说,
我踩到的坑远比我想象得多。

这篇文章记录的是:
我从“只会 std::thread”到写出一个 两个线程交替打印 1~1000 的程序 的完整过程,
包括中间遇到的几个典型问题和我是怎么一点点把它们解决掉的。


一、题目 & 目标

题目:

使用两个线程,一个打印奇数,一个打印偶数,交替输出从 1 到 1000 的数字。

考察点(我现在的理解):

  • 如何用 std::thread 创建线程
  • 线程之间如何共享同一个变量(这里是 count
  • 为什么光靠 mutex 不能保证“交替”
  • 条件变量 std::condition_variable 的基本用法

二、起点:我只会“创建线程”

一开始,我只会写这种级别的多线程代码:

#include <iostream>
#include <thread>

void worker(int id) {
    std::cout << "hello from thread " << id << std::endl;
}

int main() {
    std::thread t1(worker, 1);
    std::thread t2(worker, 2);

    t1.join();
    t2.join();

    std::cout << "main thread done" << std::endl;
    return 0;
}

在 VS 2022 里跑起来,输出大概是:

hello from thread 1hello from thread 2
main thread done

第一感受是:输出粘在一起了,甚至顺序也不一定是 1 再 2。
这时候我对线程的直觉只有一句话:

“它们是真的在同时往控制台写东西。”


三、先试试“两个线程一起 ++”——初次感受到“数据竞争”

为了进一步体会“共享变量+多线程”这件事,我先写了一个简单的实验:

#include <iostream>
#include <thread>

int count = 0;

void add(int id) {
    for (int i = 0; i < 100000; ++i) {
        count++;
    }
}

int main() {
    std::thread t1(add, 1);
    std::thread t2(add, 2);

    t1.join();
    t2.join();

    std::cout << "count = " << count << std::endl;
    return 0;
}

理论上:

  • 每个线程加 100000 次
  • 两个线程一起 = 200000
  • 所以我以为 count 一定是 200000

实际跑了几次之后:

count = 173284
count = 196xxx
...

完全不稳定。

我的第一点收获:

对同一个变量做 ++,在多线程里不是“想当然”的事。

这里就出现了教科书上那个词:数据竞争(data race)
于是,我第一次认识到了:必须要学会 std::mutex


四、加上 mutex 后:数据正确了,但顺序依然“奇怪”

接下来,我给 count++ 加了一把锁:

#include <iostream>
#include <thread>  
#include <mutex>

int count = 0;
std::mutex mtx;

void add(int id) {
  for (int i = 0; i < 100; i++) {
    std::unique_lock<std::mutex> lock(mtx);
    count++;
    std::cout << "线程 id = " << id 
              << " 对 count 进行操作后 count = " << count << std::endl;
  }
}

这段代码有几个特点:

  • std::mutex mtx;:一把锁,全局共享

  • std::unique_lock<std::mutex> lock(mtx);

    • 构造时加锁
    • 离开作用域时自动解锁(这是典型的 RAII 风格)

这次,count 的最终结果是正确的,
但是输出顺序却完全不是我想象中的 “1次线程1 → 1次线程2 → 再1次线程1…” ,而是类似:

线程1 线程1 线程1 线程1 线程1 ...
线程2 线程2 线程2 ...
线程1 线程1 ...

我当时的疑惑(值得记录到博客里的一点):

为啥我用了锁,两个线程还是没做到“一人一次轮流执行”?

现在的理解是:

  • mutex 只保证:同一时间只有一个线程在这段代码里
  • 它并不保证:线程 1 一次、线程 2 一次这样公平轮换
  • 操作系统调度可能会让线程 1 连续运行多次循环,再切到线程 2

所以:

互斥锁解决的是“不要一起改”的问题,
但并不解决“要按顺序来”的问题。

“按顺序”这件事,就轮到 条件变量 出场了。


五、第一次用条件变量:先理解“等信号再干活”

在做奇偶交替打印之前,我先写了一个小 demo 帮自己理解 std::condition_variable

线程 A:等到 ready == true 才打印“开始工作”
线程 B:睡 1 秒后,把 ready 设为 true,并发信号

(这里就不贴完整代码了,核心是这两行)

std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [] { return ready; });

和:

{
    std::lock_guard<std::mutex> lock(mtx);
    ready = true;
}
cv.notify_one();

我给自己的总结是:

  • wait(lock, 条件) =

    如果条件不满足,就释放锁并睡觉,等别人 notify 之后再醒来重新检查

  • notify_one() =

    “按门铃叫醒一个在门后睡觉的线程”

理解了这个“等条件 → 被叫醒 → 继续”的流程之后,我才敢动手写“奇偶交替打印”。


六、第一版奇偶打印:能交替,但会跑到 1002

题目版本:

从 1 到 1000,两个线程交替打印 count
一个负责“奇数打印”,一个负责“偶数打印”。

我给自己设计了这样一组共享变量:

int count = 1;
int max_number = 1000;
std::mutex mtx;
std::condition_variable cv;
bool ready = false;  // false:轮到奇数线程;true:轮到偶数线程

然后写出了第一版类似这样的代码(简化后):

void PrintOdd() {
  while (count <= max_number) {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, [] { return !ready; });

    std::cout << "奇数打印: count = " << count << std::endl;
    count++;
    ready = !ready;
    cv.notify_all();
  }
}

void PrintEven() {
  while (count <= max_number) {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, [] { return ready; });

    std::cout << "偶数打印: count = " << count << std::endl;
    count++;
    ready = !ready;
    cv.notify_all();
  }
}

表面上看,这段逻辑非常“顺眼”:

  • ready == false → 奇数线程干活
  • ready == true → 偶数线程干活
  • 每次干完活之后 ready = !ready;,并 notify_all() 叫醒对方

结果:打印过程几乎是正确的,但最后的 count 居然变成了 1002。

这一刻我非常好奇:

“我明明写了 while (count <= 1000),怎么还会多跑?”


七、问题分析:为什么会越界?

后来我意识到,问题出在:

while (count <= max_number) {
    // ...
}

这个判断是在锁外做的。

想象最后几步的情况:

  1. count == 999 时,两个线程几乎同时判断 count <= 1000 成立
  2. 它们都进入了 while 循环体
  3. 接下来谁先抢到锁、谁后抢锁,就会出现各种顺序组合
  4. 结果是:最后的那一两次循环里,两个线程都“以为自己还应该再干一次”
  5. 最终 count 就被加到了 10011002 之类的值

我给自己的总结是:

“循环是否结束”这种逻辑,不应该在锁外判断。
因为多个线程会在“没有同步保护的情况下”同时看到一个“过期的值”。

这是我写多线程时踩到的第一个比较典型的坑。


八、第二版:补丁式修正——在锁内再加一层 if

我的第一反应很自然:

既然锁外判断不靠谱,那我干脆在锁里再加一层保护。

于是写出了第二版(核心结构大概是这样):

void PrintOdd() {
  while (count < max_number) {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, [] { return !ready; });

    if (count < max_number) {      // 锁内再次确认
      count++;
      std::cout << "奇数打印: count = " << count << std::endl;
    }

    ready = !ready;
    cv.notify_all();
  }
}

这一版的特点是:

  • 外层 while (count < max_number) 在锁外
  • 内层 if (count < max_number) 在锁内
  • 就算外层判断有点“延迟”,内层也会兜底,避免多加

这版代码终于能稳定地把 count 停在正确范围内了。
功能上是对的,但结构上总感觉“有点乱”:

  • 退出条件散落在 whileif 两个地方
  • count 这个共享变量,仍然有部分是在锁外读的(理论上还是 data race)

所以,我开始考虑能不能把“退出逻辑”写得更干净一点。


九、第三版:while(true) + break,把退出逻辑放回“临界区”

最终我采用的是这样的结构:

不在锁外判断退出条件,而是:
每次被唤醒后,在锁内统一决定:还干不干 / 该不该退出。

也就是你现在看到的这一版:

#include <iostream>
#include <thread>  
#include <mutex>
#include <condition_variable>

int count = 0;
int max_number = 1000;
std::mutex mtx;
std::condition_variable cv;
bool ready = false; // false:奇数线程;true:偶数线程

void PrintOdd() {
  while (true) {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, [] { return !ready; });

    if (count == max_number) {        // 在锁内决定:要不要退出
      ready = !ready;                 // 通知对方也有机会看到“结束态”
      cv.notify_all();
      break;
    }

    count++;
    std::cout << "奇数打印: count = " << count << std::endl;
    ready = !ready;
    cv.notify_all();
  }
}

void PrintEven() {
  while (true) {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, [] { return ready; });

    if (count == max_number) {
      ready = !ready;
      cv.notify_all();
      break;
    }

    count++;
    std::cout << "偶数打印: count = " << count << std::endl;
    ready = !ready;
    cv.notify_all();
  }
}

int main() {
  std::thread t1(PrintOdd);
  std::thread t2(PrintEven);

  t1.join();
  t2.join();

  std::cout << "end " << count << std::endl;
  return 0;
}

这版代码的几个关键点:

  1. while (true) + 内部 break

    • 所有“是否结束”的判断,都放在加锁之后
    • 退出逻辑集中在一处,比较好读
  2. 在锁内判断 count == max_number

    • 这时候没有其它线程同时在改 count
    • 决策是基于一致的状态做出的
  3. 退出前依然调用 notify_all()

    • 避免另外一个线程永远卡在 wait 上,看不到“结束信号”
  4. ready 仍然只是一个“轮到谁”的标志位

    • 奇数线程只在 ready == false 时干活
    • 偶数线程只在 ready == true 时干活
    • 每次干完活都反转 ready 并通知对方

这版代码从结构上看就清爽很多,也更符合“工程级”多线程代码的写法。


十、这一题带给我的几个小总结

对我这种刚开始学多线程的新手来说,这道“奇偶交替打印”的题给了我不少启发:

1. mutex 只能保证“不要一起改”,不能保证“轮流来”

  • 锁解决的是 数据安全 问题
  • “顺序控制”和“线程协作”,要用的是 条件变量 + 状态标志

2. 循环退出条件不要轻易写在锁外

  • 多个线程会在锁外看到同一个“旧值”,可能都以为自己还能干

  • 更稳妥的方式是:

    每次被唤醒后,在临界区里统一检查“是不是该结束了”

3. 条件变量的直觉模型

对我有效的一句心法是:

wait(lock, 条件) =
如果条件不满足 → 释放锁 + 睡觉,
notify 叫醒后 → 重新拿锁,再检查条件。

4. 实验 + 打印,是新手理解并发行为的最好方式

比如我今天做过的几个小实验,都非常有帮助:

  • 去掉锁,看 count 是否每次都等于预期值
  • 把打印放到锁外,看输出是如何“交叉乱序”的
  • 不同位置加 if (count < max_number),看 count 的最终结果是否越界

这些实验以后都可以写进自己的博客里,
相比直接贴标准答案,这种“踩坑的过程”反而更能体现真实的学习轨迹。


十一、从这道题里我提炼的一点“小方法论”

以后再遇到类似的并发手撕题,我大概会按这样的步骤来:

  1. 先写出“只有 mutex”的版本

    • 确保数据不会乱
    • 先别急着保证“漂亮的顺序”
  2. 用打印 + 多次运行观察行为

    • 看到“线程为什么没轮流”的现象
    • 逼自己去问:这个现象说明了什么?
  3. 画出“共享状态 + 线程角色”的模型

    • 例如这题里就是:count + ready + max_number
    • 想清楚:谁在等什么?谁在叫醒谁?
  4. 再慢慢引入条件变量,把协作关系写清楚

    • 先用第一版写出能跑
    • 再用第二版、第三版一点点“重构退出逻辑”

今天就先写到这里。

这是我 操作系统多线程手撕题 Day1:奇偶交替打印 的完整记录。
后面我会继续写生产者–消费者、读写锁、哲学家就餐、线程池这些经典题目,
也会把中间遇到的思考和 bug 记录下来。

如果你也刚开始学多线程,希望这篇“从 0 开始、一点点修正”的过程,
能让你觉得:并发并不是只有大神才能玩,普通人也可以从一行行实验开始。