这是我操作系统 / 多线程手撕题的 Day2。
Day1 我刚刚搞懂了std::mutex+std::condition_variable,写出了两个线程交替打印奇偶数。
Day2 我想往真实一点的场景靠近:实现一个 生产者–消费者模型。
这篇文章记录的是我从零开始实现 “阻塞队列 + 生产者–消费者” 的整个过程:
-
先写了一个 类版
BlockingQueue -
中间踩了好几个新坑:
- lambda 里为什么要
[this]? std::thread t(q.Put())为什么不对?- 外面还要不要再
lock一次? - 输出看起来像“先生产一坨,再消费一坨”,到底对不对?
- 到底要一个条件变量,还是两个?
- lambda 里为什么要
-
最后又写了一版 不用类、只用两个函数 + 全局状态 的简化模型
一、题目:一个简单但非常经典的模型
题目版本(Day2 目标):
用 C++ 实现一个有界阻塞队列
BlockingQueue<int>,
一个线程负责生产数据Put(),另一个线程负责消费Take()。
当队列满时,生产者阻塞;当队列空时,消费者阻塞。
典型考察点:
- 共享缓冲区:
std::queue<int> - 互斥访问:
std::mutex - 条件变量:
std::condition_variable - 线程间同步:生产者等“队列不满”,消费者等“队列不空”
二、第一步:类版 BlockingQueue 的基本设计
一开始我还是沿用 Day1 的思路:把共享状态封装进一个类里。
大致骨架:
#include <queue>
#include <mutex>
#include <condition_variable>
class BlockingQueue {
public:
explicit BlockingQueue(size_t capacity)
: capacity_(capacity) {}
void Put(int value);
int Take();
private:
std::queue<int> queue_;
size_t capacity_;
std::mutex mtx_;
std::condition_variable cv_; // 第一版我只用了一个 cv
};
思路很直接:
queue_存数据capacity_是最大容量mtx_保护队列cv_用来 wait + notify
2.1 第一版 Put/Take:一个条件变量 + 谓词
我一开始写的是这样的(核心逻辑):
void BlockingQueue::Put(int value) {
std::unique_lock<std::mutex> lock(mtx_);
cv_.wait(lock, [this] { return queue_.size() < capacity_; }); // 等“没满”
queue_.push(value);
cv_.notify_all();
}
int BlockingQueue::Take() {
std::unique_lock<std::mutex> lock(mtx_);
cv_.wait(lock, [this] { return !queue_.empty(); }); // 等“非空”
int value = queue_.front();
queue_.pop();
cv_.notify_all();
return value;
}
这一版里,我遇到的第一个疑问就是:
问题一:为什么 lambda 里要写 [this]?
我当时写的是:
cv_.wait(lock, [this] { return queue_.size() < capacity_; });
一开始非常不理解:为什么 lambda 前面要加 [this],不写行不行?
后来我理了一下:
- lambda 是个“新函数对象”,它本身不是成员函数
queue_和capacity_都是类的成员,访问它们其实是this->queue_/this->capacity_- 在成员函数里面,编译器自动帮我加了那个
this->,但 lambda 里没有这个隐式的this
所以这里的 [this] 的意思就是:
把当前对象指针
this捕获到 lambda 里面,让 lambda 能访问成员变量 / 成员函数。
我也可以写成比较啰嗦的形式:
cv_.wait(lock, [this] {
return this->queue_.size() < this->capacity_;
});
就很直观了。
反过来总结一下:
- lambda 里只用局部变量:
[&]/[=]就够了,不需要this - lambda 里要用成员变量 / 成员函数:就得显式把
this捕进去([this]或[=, this])
这个点是我 Day2 的第一个小收获。
三、问题二:std::thread t1(q.Put()); 为什么是错的?
写完 BlockingQueue 之后,我想当然地在 main() 里这么写:
BlockingQueue q(10);
// ❌ 错误写法:
std::thread t1(q.Put());
std::thread t2(q.Take());
编译器直接给我一脸报错。
3.1 问题出在哪?
std::thread 的构造函数期待的是:
std::thread t(函数指针 或 可调用对象, 参数...);
而我写的是:
std::thread t1(q.Put());
这实际上等价于:
auto tmp = q.Put(); // 先在主线程里直接调用一次 Put()
std::thread t1(tmp); // 再用 Put 的返回值去构造线程
但是 Put() 是 void,压根没有返回值,所以第二步就直接崩溃了。
3.2 正确的方式(成员函数版)
如果一定要直接在线程里调用成员函数,应该这么写:
std::thread t1(&BlockingQueue::Put, &q, 42); // 42 是 Put 的参数
含义是:
&BlockingQueue::Put:成员函数指针&q:告诉 thread “在 q 这个对象上调用 Put”42:传给 Put 的参数
对比之后,我觉得对我这种刚学的人来说,这样太绕了。
3.3 我最后用的方式:外面再包一层普通函数
所以我最后采用了更简单的写法:
BlockingQueue q(10);
void Product() {
for (int i = 0; i < 30; ++i) {
q.Put(i);
}
}
void Consumer() {
for (int i = 0; i < 30; ++i) {
int x = q.Take();
// 打印 / 处理 x
}
}
int main() {
std::thread t1(Product);
std::thread t2(Consumer);
t1.join();
t2.join();
}
这对于手撕题来说更直观,而且不容易被 std::thread 的模板参数绕晕。
四、问题三:我还需要在类外再锁一次吗?
有一段时间,我还干过这样的事(这是错误示范):
std::mutex outer_mtx;
void Product() {
for (int i = 0; i < 30; ++i) {
std::unique_lock<std::mutex> lock(outer_mtx);
q.Put(i); // Put 内部自己还有一把 mtx_
}
}
当时的想法是:“是不是要在外面也加把锁保护调用过程?”
后来我反应过来,这其实是个完全错误的方向:
既然我写的是一个“线程安全的 BlockingQueue”,
那 对队列的所有并发访问,应该完全由队列内部自己负责。
外部只需要调Put/Take,不应该再包一层“外锁”。
多加这一把 outer_mtx 有几个问题:
-
它和
BlockingQueue内部的mtx_完全不是一把锁- 它锁不住队列内部状态
-
如果未来队列内部逻辑复杂起来,很容易制造“嵌套加锁” → 死锁风险
-
从设计角度讲,它破坏了封装:
- “谁负责线程安全”变得不清不楚
所以,我最后给自己的设计原则是:
队列自己有自己的锁;调用者不再对队列加锁。
这一点,我会在后面写线程池的时候继续沿用。
五、问题四:为什么看起来总是“先生产一堆,再消费一堆”?这算不算错?
当我把类版 BlockingQueue 跑起来之后,日志大概是这样的:
生产者生产了一道菜
现在餐桌上还剩下 1 道菜
生产者生产了一道菜
现在餐桌上还剩下 2 道菜
...
(连着生产到 10)
...
消费者食用了一道菜
现在餐桌上还剩下 9 道菜
消费者食用了一道菜
现在餐桌上还剩下 8 道菜
...
一开始我心里是有点慌的:
“这和我想象中的‘你一下我一下’不一样啊,会不会写错了?”
后来我想明白了,这个现象其实很合理,也完全符合生产者–消费者模型。
5.1 为什么“先生产一坨再消费一坨”是正常的?
原因有三:
-
队列有容量上限(例如 10)
- 队列没满之前,生产者不会被阻塞,能高频 push
- 消费者如果一开始跑得慢,很容易一上来就被
wait在“队列为空”
-
线程调度不保证平均轮换
- OS 可能先让生产者跑一段时间
- 生产者一路 push 到队列满了,才第一次在
wait卡住 - 这时才轮到消费者连续跑一阵
-
模型的正确定义里,并没有要求“完美交错输出”
生产者–消费者模型的要求是:- 满则等、空则等
- 不丢数据,不重复消费
- 保证线程安全和顺序正确
并没有说:“必须严格一生产就立刻一消费”。
所以我现在的理解是:
只要 满的时候生产者会阻塞、空的时候消费者会阻塞,数据量对得上,
输出是“批量生产再批量消费”,也是完全正确的行为。
如果想看到更“交错”的输出,其实可以:
- 把队列容量调小一点(比如 1 或 2)
- 在
Put/Take里加一点sleep人为减慢节奏
但那就是为了观察现象,不是为了“符合标准答案” 。
六、一个条件变量 vs 两个条件变量
我最开始的 BlockingQueue 用的是 一个 std::condition_variable cv_ ,生产和消费都在这一个上面 wait/notify。后来在改成函数版时,我写了两把 cv:
std::condition_variable not_full_cv; // 等队列不满的线程(生产者)
std::condition_variable not_empty_cv; // 等队列不空的线程(消费者)
这里我自己也问了一个问题:
一个 cv 和两个 cv 有什么区别?一个不就够了吗?
6.1 一个 cv:可以,用谓词就行
单 cv 写法大概是这样的(这是我类版的思路):
std::condition_variable cv;
void Put(int value) {
std::unique_lock<std::mutex> lock(mtx_);
cv.wait(lock, [this] { return queue_.size() < capacity_; }); // 等不满
queue_.push(value);
cv.notify_all();
}
int Take() {
std::unique_lock<std::mutex> lock(mtx_);
cv.wait(lock, [this] { return !queue_.empty(); }); // 等非空
int v = queue_.front();
queue_.pop();
cv.notify_all();
return v;
}
用的是 wait(lock, 谓词) 形式:
- 被叫醒时会重新检查条件
- 如果条件不满足,会继续睡
- 所以即使“通知对象”有点粗糙(一个 cv 上所有人一起排队),逻辑上仍然是正确的
结论:一个 cv + 正确的谓词,完全可以实现模型。
6.2 两个 cv:语义更清晰,扩展性更好
两 cv 写法(我函数版用的是类似思想):
std::condition_variable not_full_cv;
std::condition_variable not_empty_cv;
void Put(int value) {
std::unique_lock<std::mutex> lock(mtx_);
not_full_cv.wait(lock, [] { return queue_.size() < capacity; });
queue_.push(value);
not_empty_cv.notify_one(); // 告诉“等不空”的人:现在不空了
}
void Take() {
std::unique_lock<std::mutex> lock(mtx_);
not_empty_cv.wait(lock, [] { return !queue_.empty(); });
queue_.pop();
not_full_cv.notify_one(); // 告诉“等不满”的人:现在有空位了
}
这样做的好处是:
-
语义更清楚:
- 谁在等“队列不空”?→
not_empty_cv - 谁在等“队列不满”?→
not_full_cv
- 谁在等“队列不空”?→
-
如果将来扩展到“多生产者 + 多消费者”:
- 用两 cv 可以减少一部分“被叫醒却发现条件跟自己无关”的情况
- 不会让一堆线程白白抢锁又睡回去
总结一句:
一个 cv:代码更少,逻辑没问题;
两个 cv:语义更清晰、扩展性更好,是更工程化的写法。
对我现在这个阶段来说,两种都写一遍,能更好地理解 condition_variable 的本质:
它只是一个“门铃/等待队列”,真正的条件是我们自己维护的状态。
七、第二版:不用类,只用两个函数的生产者–消费者
在写完类版 BlockingQueue 之后,我又写了一个**“全局变量 + 两个函数”**的版本,目的就是把模型拆得更透一点,让自己更清楚谁在等什么。
核心代码大致是这样:
#include <iostream>
#include <mutex>
#include <condition_variable>
#include <queue>
#include <thread>
std::mutex mtx;
std::condition_variable not_full_cv;
std::condition_variable not_empty_cv;
std::queue<int> queue_;
size_t capacity = 10;
void Product() {
for (int i = 0; i < 50; i++) {
std::unique_lock<std::mutex> lock(mtx);
not_full_cv.wait(lock, [] { return queue_.size() < capacity; });
queue_.push(i);
std::cout << "生产者生产了一道菜\n";
std::cout << "现在餐桌上还剩下 "
<< queue_.size() << " 道菜\n\n";
not_empty_cv.notify_one();
}
}
void Consumer() {
for (int i = 0; i < 50; i++) {
std::unique_lock<std::mutex> lock(mtx);
not_empty_cv.wait(lock, [] { return !queue_.empty(); });
queue_.pop();
std::cout << "消费者食用了一道菜\n";
std::cout << "现在餐桌上还剩下 "
<< queue_.size() << " 道菜\n\n";
not_full_cv.notify_one();
}
}
int main() {
std::thread t1(Product);
std::thread t2(Consumer);
t1.join();
t2.join();
std::cout << "生产/消费 结束\n";
return 0;
}
对比类版,你会发现其实本质差不多:
-
共享状态从“类的成员”变成了“全局变量”
-
Put/Take变成了Product/Consumer -
逻辑核心仍然是:
- 一个线程等“队列不满” → push → 通知“队列不空”
- 一个线程等“队列不空” → pop → 通知“队列不满”
这一版最大的好处是:
把所有变量都摊在外面,看得非常清楚谁在改谁。
对我现在这个阶段理解模型特别有帮助。
八、Day2 小结:从题目到模型,再到自己的坑
Day2 结束时,我觉得这道生产者–消费者题给我的收获主要有这些:
-
BlockingQueue 封装了什么?
- 一个队列
- 一把 mutex
- 一组“队列不空/不满”的条件
-
wait(lock, 谓词)心法再次加深- 被唤醒不代表条件满足
- “条件是否满足”永远要自己用状态变量判断
-
lambda 捕获和类成员访问
- lambda 里需要访问成员 →
[*this]or[this] - 只访问局部变量 →
[&]/[=]即可
- lambda 里需要访问成员 →
-
成员函数 vs 普通函数在线程中的用法区别
std::thread(&Class::Method, &obj, args...)- 或者像我这样,外面包一层
Product()/Consumer()调obj.Method(),简单直接
-
“要不要在外面再加一把锁”的设计问题
- 线程安全由谁负责?由队列自己
- 调用者只关心接口,不额外套锁
-
行为观察:先生产一坨再消费一坨是正常现象
- 由容量 + 调度策略决定,不是 bug
- 只要满了会等,空了会等,数据不丢,就是正确的生产者–消费者模型
-
一个
condition_variablevs 两个- 一个:更精简,用谓词就足够保证正确
- 两个:语义更清晰,尤其适合扩展到多生产者/多消费者的场景
后记:我的并发题“通关路线”
如果把 Day1 + Day2 合起来,我现在对“OS 手撕并发题”的学习路线,大概是这样:
-
从 “两个线程争抢一个变量” 入门(count++ + mutex)
-
写出 奇偶打印,第一次用
condition_variable控制“轮到谁” -
把同样的思想迁移到 生产者–消费者:
- 把“轮到谁”变成“队列现在是什么状态(空/满)”
-
先写类版,再写函数版,把“共享状态 + 条件 + wait/notify”这一套反复练熟
下一步,我打算在这个基础上继续往上叠:
- 多生产者、多消费者版本
- 简易线程池
- 再往后看能不能写一个简化版的读写锁