Day6:并发基础回顾与整合 —— 复写经典题,补齐“正确性细节”

3 阅读5分钟

Day1–Day5 我已经把并发的基本组件跑通了:线程、互斥锁、条件变量、future 等。但真正让我感觉“学会了”的,不是继续加新知识点,而是把几个经典题目重新写一遍:写的时候会暴露出很多之前“似懂非懂”的细节——尤其是生命周期、等待条件(predicate)、退出语义、以及 stop 场景下的行为

因此 Day6 我做了三件事:

  • 回顾 Day1–Day5 的知识主线,提炼出一个通用的入手模板
  • 复写三个经典并发题:SPSC 阻塞队列 / 写者优先 RWLock / 带返回值线程池
  • 记录复写过程中遇到的坑,以及修正后的“可解释版本”

一、Day1–Day5 回顾:我到底在学什么

这 5 天看似做了很多题,但本质都在训练同一件事:

在多线程环境下,把“共享状态”变得可控:谁能改、何时能改、何时等待、何时唤醒、何时退出。

我现在用一句话概括每一天的核心:

  • Day1:线程与执行流std::thread 的基本使用、join/生命周期意识)
  • Day2:条件变量的“等待条件”cv.wait(lock, predicate) 抵抗虚假唤醒)
  • Day3:生产者-消费者(共享队列 + 两类等待条件 + 正确唤醒对象)
  • Day4:读写锁与公平性(写者优先的策略:只要有写者等待,读者就不再进入)
  • Day5:future/超时/健壮性wait_for、可控等待、结果收集)

这一套知识最后都会落到一个通用模板上:

我的并发“通用入手模板”(Day6 复写时一直在用)

  1. 先列出共享状态(队列/计数器/stop 标志)
  2. 再写出 2–3 个 predicate(什么时候能继续、什么时候该退出)
  3. 所有 wait 必须带 predicate
  4. 所有共享状态读写都必须在锁内
  5. 退出语义必须明确(例如:stop && queue empty 才退出)

二、复写 1:单生产者-单消费者阻塞队列(SPSC BlockingQueue)

设计要点

  • 共享资源:food 队列

  • 两类等待条件:

    • 生产者:桌面没满(food.size() < max_food
    • 消费者:桌面非空(!food.empty()
  • wait 必须带 predicate,否则会被虚假唤醒坑到

  • 生产后唤醒消费者,消费后唤醒生产者(唤醒对象要精准)

✅ 去注释代码(按你版本整理)

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

class BlockingQueue {
 public:
  explicit BlockingQueue(int max_food_) : max_food(max_food_) {}

  void Put(int i) {
    std::unique_lock<std::mutex> lock(mtx_);
    cv_product.wait(lock, [this] { return food.size() < max_food; });
    food.push(1);
    lock.unlock();
    cv_consumer.notify_one();
  }

  void Take(int i) {
    std::unique_lock<std::mutex> lock(mtx_);
    cv_consumer.wait(lock, [this] { return !food.empty(); });
    food.pop();
    lock.unlock();
    cv_product.notify_one();
  }

 private:
  int32_t max_food = 10;
  std::queue<int> food;
  std::mutex mtx_;
  std::condition_variable cv_product;
  std::condition_variable cv_consumer;
};

BlockingQueue blockingqueue(10);

void Consumers() {
  for (int i = 0; i < 100; i++) {
    blockingqueue.Take(i);
  }
}

void Products() {
  for (int i = 0; i < 100; i++) {
    blockingqueue.Put(i);
  }
}

int main() {
  std::thread t1(Consumers);
  std::thread t2(Products);

  t1.join();
  t2.join();
  return 0;
}

三、复写 2:写者优先读写锁(Writer-first RWLock)

设计要点(写者优先怎么实现)

我的理解是:写者优先的核心不是“写者抢锁更快”,而是:

只要有写者在等待,就禁止新的读者进入
这样写者不会被源源不断的新读者饿死。

因此读者的进入条件是:

  • writer_wait == 0 && writer_count == 0

写者的进入条件是:

  • read_count == 0 && writer_count == 0

释放时的唤醒策略:

  • 若还有写者在等:优先唤醒写者
  • 否则:放行所有读者

✅ 去注释代码(按你版本整理)

#include <mutex>
#include <condition_variable>
#include <thread>
#include <chrono>

class RWLock {
 public:
  void WriteUnlock(int i);
  void WriteLock(int i);

  void ReadUnlock(int i);
  void ReadLock(int i);

 private:
  std::mutex mtx_;
  std::condition_variable cv_read;
  std::condition_variable cv_writer;
  int writer_wait = 0;
  int read_count = 0;
  int writer_count = 0;
};

int shared_data = 0;

void RWLock::ReadLock(int i) {
  std::unique_lock<std::mutex> lock(mtx_);
  cv_read.wait(lock, [this] { return writer_wait == 0 && writer_count == 0; });
  read_count++;
}

void RWLock::ReadUnlock(int i) {
  std::unique_lock<std::mutex> lock(mtx_);
  read_count--;
  if (writer_wait && read_count == 0) {
    lock.unlock();
    cv_writer.notify_one();
  }
}

void RWLock::WriteLock(int i) {
  std::unique_lock<std::mutex> lock(mtx_);
  writer_wait++;
  cv_writer.wait(lock, [this] { return read_count == 0 && writer_count == 0; });
  writer_wait--;
  writer_count++;
}

void RWLock::WriteUnlock(int i) {
  std::unique_lock<std::mutex> lock(mtx_);
  writer_count--;
  if (writer_wait) {
    lock.unlock();
    cv_writer.notify_one();
  } else {
    lock.unlock();
    cv_read.notify_all();
  }
}

RWLock rwlock;

void ReadFunc(int i) {
  using namespace std::chrono_literals;
  rwlock.ReadLock(i);
  std::this_thread::sleep_for(500ms);
  rwlock.ReadUnlock(i);
}

void WriteFunc(int i) {
  using namespace std::chrono_literals;
  rwlock.WriteLock(i);
  std::this_thread::sleep_for(500ms);
  shared_data++;
  rwlock.WriteUnlock(i);
}

int main() {
  using namespace std::chrono_literals;
  std::thread read1(ReadFunc, 1);
  std::thread read2(ReadFunc, 2);

  std::this_thread::sleep_for(100ms);

  std::thread write1(WriteFunc, 1);
  std::thread read3(ReadFunc, 3);

  read1.join();
  read2.join();
  write1.join();
  read3.join();
  return 0;
}

四、复写 3:带返回值线程池(ThreadPool with future)

设计要点

线程池这题最“卡人”的地方有两个:

  1. worker 的等待条件与退出条件
  • 唤醒:!task_.empty() || stop_
  • 退出:stop_ && task_.empty()
  1. 任务生命周期(packaged_task 的坑)
  • packaged_task 是 move-only
  • 不能引用捕获局部变量(否则任务延迟执行时会悬空引用)
  • 正确做法是把 packaged_task 移动到堆上,用 shared_ptr 管理生命周期

另外补上一个非常关键的健壮性点:

  • stop 后禁止提交(否则 future 可能永远不会 ready)

✅ 去注释代码(按你最终能跑通版本整理)

#include <mutex>
#include <condition_variable>
#include <queue>
#include <vector>
#include <thread>
#include <future>
#include <functional>
#include <memory>
#include <stdexcept>
#include <iostream>

class ThreadPool {
 public:
  explicit ThreadPool(int i);
  ~ThreadPool();

  void Worker();
  std::future<int> Task(std::function<int()> fn);

 private:
  bool stop_ = false;
  std::queue<std::function<void()>> task_;
  std::vector<std::thread> workers_;
  std::mutex mtx_;
  std::condition_variable cv_;
};

ThreadPool::ThreadPool(int i) {
  for (int j = 0; j < i; j++) {
    workers_.emplace_back(&ThreadPool::Worker, this);
  }
}

ThreadPool::~ThreadPool() {
  std::unique_lock<std::mutex> lock(mtx_);
  stop_ = true;
  lock.unlock();

  cv_.notify_all();

  for (auto& t : workers_) {
    t.join();
  }
}

void ThreadPool::Worker() {
  while (true) {
    std::unique_lock<std::mutex> lock(mtx_);
    cv_.wait(lock, [this] { return !task_.empty() || stop_; });

    if (task_.empty() && stop_) {
      break;
    }

    std::function<void()> fn = std::move(task_.front());
    task_.pop();
    lock.unlock();

    fn();
  }
}

std::future<int> ThreadPool::Task(std::function<int()> fn) {
  std::packaged_task<int()> pt(fn);
  std::future<int> fut = pt.get_future();

  auto int_ptr =
      std::make_shared<std::packaged_task<int()>>(std::move(pt));

  std::unique_lock<std::mutex> lock(mtx_);
  if (stop_) {
    throw std::runtime_error("submit on stopped ThreadPool");
  }
  task_.emplace([int_ptr]() mutable { (*int_ptr)(); });
  lock.unlock();

  cv_.notify_one();
  return fut;
}

int main() {
  ThreadPool thread_pool(3);
  int f1 = thread_pool.Task([] { return 1; }).get();
  int f2 = thread_pool.Task([] { return 2; }).get();
  int f3 = thread_pool.Task([] { return 3; }).get();
  std::cout << f1 << f2 << f3 << std::endl;
  return 0;
}

五、Day6 我踩到的坑与修正

  1. 引用捕获 packaged_task(悬空引用 / UB)
    线程池任务是延迟执行的,必须保证任务对象在执行时仍然存活。
    因此必须用 move + shared_ptr 管理生命周期,而不能用 [&] 捕获局部变量。
  2. stop 后仍允许提交的风险
    如果 stop 后仍把任务放入队列,有可能造成:worker 全退出、future 永远不 ready、.get() 卡死。
    所以 Task() 里必须加 stop_ 检查并拒绝提交。

六、小结:Day6 的收获

Day6 没有引入新 API,但我把“并发题怎么入手”变成了固定套路:

  • 先写 predicate
  • 明确退出语义
  • 保证生命周期正确
  • 锁内访问共享状态
  • 唤醒对象要精准

到这里,我对互斥锁/条件变量/future 的理解从“能写出来”变成了“能解释为什么这样写”。