这是我正式开始刷操作系统 / 多线程手撕题的第一天。
题目不难,也不新,但对一个刚拾起 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) {
// ...
}
这个判断是在锁外做的。
想象最后几步的情况:
count == 999时,两个线程几乎同时判断count <= 1000成立- 它们都进入了
while循环体 - 接下来谁先抢到锁、谁后抢锁,就会出现各种顺序组合
- 结果是:最后的那一两次循环里,两个线程都“以为自己还应该再干一次”
- 最终
count就被加到了1001、1002之类的值
我给自己的总结是:
“循环是否结束”这种逻辑,不应该在锁外判断。
因为多个线程会在“没有同步保护的情况下”同时看到一个“过期的值”。
这是我写多线程时踩到的第一个比较典型的坑。
八、第二版:补丁式修正——在锁内再加一层 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 停在正确范围内了。
功能上是对的,但结构上总感觉“有点乱”:
- 退出条件散落在
while和if两个地方 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;
}
这版代码的几个关键点:
-
while (true)+ 内部break- 所有“是否结束”的判断,都放在加锁之后
- 退出逻辑集中在一处,比较好读
-
在锁内判断
count == max_number- 这时候没有其它线程同时在改
count - 决策是基于一致的状态做出的
- 这时候没有其它线程同时在改
-
退出前依然调用
notify_all()- 避免另外一个线程永远卡在
wait上,看不到“结束信号”
- 避免另外一个线程永远卡在
-
ready仍然只是一个“轮到谁”的标志位- 奇数线程只在
ready == false时干活 - 偶数线程只在
ready == true时干活 - 每次干完活都反转
ready并通知对方
- 奇数线程只在
这版代码从结构上看就清爽很多,也更符合“工程级”多线程代码的写法。
十、这一题带给我的几个小总结
对我这种刚开始学多线程的新手来说,这道“奇偶交替打印”的题给了我不少启发:
1. mutex 只能保证“不要一起改”,不能保证“轮流来”
- 锁解决的是 数据安全 问题
- “顺序控制”和“线程协作”,要用的是 条件变量 + 状态标志
2. 循环退出条件不要轻易写在锁外
-
多个线程会在锁外看到同一个“旧值”,可能都以为自己还能干
-
更稳妥的方式是:
每次被唤醒后,在临界区里统一检查“是不是该结束了”
3. 条件变量的直觉模型
对我有效的一句心法是:
wait(lock, 条件)=
如果条件不满足 → 释放锁 + 睡觉,
被notify叫醒后 → 重新拿锁,再检查条件。
4. 实验 + 打印,是新手理解并发行为的最好方式
比如我今天做过的几个小实验,都非常有帮助:
- 去掉锁,看
count是否每次都等于预期值 - 把打印放到锁外,看输出是如何“交叉乱序”的
- 不同位置加
if (count < max_number),看count的最终结果是否越界
这些实验以后都可以写进自己的博客里,
相比直接贴标准答案,这种“踩坑的过程”反而更能体现真实的学习轨迹。
十一、从这道题里我提炼的一点“小方法论”
以后再遇到类似的并发手撕题,我大概会按这样的步骤来:
-
先写出“只有 mutex”的版本
- 确保数据不会乱
- 先别急着保证“漂亮的顺序”
-
用打印 + 多次运行观察行为
- 看到“线程为什么没轮流”的现象
- 逼自己去问:这个现象说明了什么?
-
画出“共享状态 + 线程角色”的模型
- 例如这题里就是:
count + ready + max_number - 想清楚:谁在等什么?谁在叫醒谁?
- 例如这题里就是:
-
再慢慢引入条件变量,把协作关系写清楚
- 先用第一版写出能跑
- 再用第二版、第三版一点点“重构退出逻辑”
今天就先写到这里。
这是我 操作系统多线程手撕题 Day1:奇偶交替打印 的完整记录。
后面我会继续写生产者–消费者、读写锁、哲学家就餐、线程池这些经典题目,
也会把中间遇到的思考和 bug 记录下来。
如果你也刚开始学多线程,希望这篇“从 0 开始、一点点修正”的过程,
能让你觉得:并发并不是只有大神才能玩,普通人也可以从一行行实验开始。