Day2:从奇偶打印到生产者-消费者——我第一次手写 BlockingQueue

7 阅读11分钟

这是我操作系统 / 多线程手撕题的 Day2。
Day1 我刚刚搞懂了 std::mutex + std::condition_variable,写出了两个线程交替打印奇偶数。
Day2 我想往真实一点的场景靠近:实现一个 生产者–消费者模型

这篇文章记录的是我从零开始实现 “阻塞队列 + 生产者–消费者” 的整个过程:

  • 先写了一个 类版 BlockingQueue

  • 中间踩了好几个新坑:

    • lambda 里为什么要 [this]
    • std::thread t(q.Put()) 为什么不对?
    • 外面还要不要再 lock 一次?
    • 输出看起来像“先生产一坨,再消费一坨”,到底对不对?
    • 到底要一个条件变量,还是两个?
  • 最后又写了一版 不用类、只用两个函数 + 全局状态 的简化模型


一、题目:一个简单但非常经典的模型

题目版本(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 有几个问题:

  1. 它和 BlockingQueue 内部的 mtx_ 完全不是一把锁

    • 它锁不住队列内部状态
  2. 如果未来队列内部逻辑复杂起来,很容易制造“嵌套加锁” → 死锁风险

  3. 从设计角度讲,它破坏了封装:

    • “谁负责线程安全”变得不清不楚

所以,我最后给自己的设计原则是:

队列自己有自己的锁;调用者不再对队列加锁。

这一点,我会在后面写线程池的时候继续沿用。


五、问题四:为什么看起来总是“先生产一堆,再消费一堆”?这算不算错?

当我把类版 BlockingQueue 跑起来之后,日志大概是这样的:

生产者生产了一道菜
现在餐桌上还剩下 1 道菜

生产者生产了一道菜
现在餐桌上还剩下 2 道菜

...
(连着生产到 10)
...

消费者食用了一道菜
现在餐桌上还剩下 9 道菜

消费者食用了一道菜
现在餐桌上还剩下 8 道菜

...

一开始我心里是有点慌的:

“这和我想象中的‘你一下我一下’不一样啊,会不会写错了?”

后来我想明白了,这个现象其实很合理,也完全符合生产者–消费者模型。

5.1 为什么“先生产一坨再消费一坨”是正常的?

原因有三:

  1. 队列有容量上限(例如 10)

    • 队列没满之前,生产者不会被阻塞,能高频 push
    • 消费者如果一开始跑得慢,很容易一上来就被 wait 在“队列为空”
  2. 线程调度不保证平均轮换

    • OS 可能先让生产者跑一段时间
    • 生产者一路 push 到队列满了,才第一次在 wait 卡住
    • 这时才轮到消费者连续跑一阵
  3. 模型的正确定义里,并没有要求“完美交错输出”
    生产者–消费者模型的要求是:

    • 满则等、空则等
    • 不丢数据,不重复消费
    • 保证线程安全和顺序正确

并没有说:“必须严格一生产就立刻一消费”。

所以我现在的理解是:

只要 满的时候生产者会阻塞、空的时候消费者会阻塞,数据量对得上
输出是“批量生产再批量消费”,也是完全正确的行为。

如果想看到更“交错”的输出,其实可以:

  • 把队列容量调小一点(比如 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 结束时,我觉得这道生产者–消费者题给我的收获主要有这些:

  1. BlockingQueue 封装了什么?

    • 一个队列
    • 一把 mutex
    • 一组“队列不空/不满”的条件
  2. wait(lock, 谓词) 心法再次加深

    • 被唤醒不代表条件满足
    • “条件是否满足”永远要自己用状态变量判断
  3. lambda 捕获和类成员访问

    • lambda 里需要访问成员 → [*this] or [this]
    • 只访问局部变量 → [&] / [=] 即可
  4. 成员函数 vs 普通函数在线程中的用法区别

    • std::thread(&Class::Method, &obj, args...)
    • 或者像我这样,外面包一层 Product()/Consumer()obj.Method(),简单直接
  5. “要不要在外面再加一把锁”的设计问题

    • 线程安全由谁负责?由队列自己
    • 调用者只关心接口,不额外套锁
  6. 行为观察:先生产一坨再消费一坨是正常现象

    • 由容量 + 调度策略决定,不是 bug
    • 只要满了会等,空了会等,数据不丢,就是正确的生产者–消费者模型
  7. 一个 condition_variable vs 两个

    • 一个:更精简,用谓词就足够保证正确
    • 两个:语义更清晰,尤其适合扩展到多生产者/多消费者的场景

后记:我的并发题“通关路线”

如果把 Day1 + Day2 合起来,我现在对“OS 手撕并发题”的学习路线,大概是这样:

  1. “两个线程争抢一个变量” 入门(count++ + mutex)

  2. 写出 奇偶打印,第一次用 condition_variable 控制“轮到谁”

  3. 把同样的思想迁移到 生产者–消费者

    • 把“轮到谁”变成“队列现在是什么状态(空/满)”
  4. 先写类版,再写函数版,把“共享状态 + 条件 + wait/notify”这一套反复练熟

下一步,我打算在这个基础上继续往上叠:

  • 多生产者、多消费者版本
  • 简易线程池
  • 再往后看能不能写一个简化版的读写锁