【面试】手撕线程池

这次我手写的是一个单队列版线程池

在真正开始写代码之前,我先把一个问题想清楚了:

线程池不只是一个"收任务的接口",它本质上是:任务队列 + worker 线程 + 同步机制 + 生命周期管理。

也就是说,线程池对外提供的是提交任务 的能力;

而对内负责的是存任务、等任务、取任务、执行任务、关闭线程、回收线程这一整套流程。

这次我先实现的是最稳的版本:
单任务队列 + 多个 worker 消费线程 + 外部多生产者提交任务。


一、一个线程池到底包含哪些部分

我最后把线程池拆成了下面几个核心部分。

1. 任务队列

任务队列负责存放外部提交进来的任务。

这里我没有直接用普通 std::queue 暴露给线程池,而是封装成了一个阻塞队列 BlockingQueue,因为线程池里 worker 线程在没有任务时不能一直空转轮询,而应该:

  • 队列为空时阻塞等待
  • 队列有任务时被唤醒
  • 队列关闭时退出等待并返回

所以这个队列需要考虑的功能有:

  • Push:安全地向队列中加入任务
  • Pop:安全地从队列中取出任务
  • Close:线程池关闭时唤醒所有等待线程,让 worker 能退出

2. worker 线程组

线程池的"池",本质上就是一组预先创建好的 worker 线程。

这些线程由 ThreadPool 自己创建和管理,而不是由测试代码来创建。

worker 线程做的事情很简单:

  • 循环从阻塞队列中取任务
  • 取到任务就执行
  • 取不到且队列已关闭,就退出循环

也就是说:

消费者线程属于线程池内部资源,由线程池负责创建、管理和回收。


3. 对外提交任务接口

线程池对外最重要的接口就是 Post

外部谁调用 Post,谁就是生产者。

这意味着即使线程池内部只有一个任务队列,它也依然可以支持:

  • 单生产者 + 多消费者
  • 多生产者 + 多消费者

所以"单队列"说的是存储结构设计,不代表"单生产者单消费者"。


4. 同步机制

线程池一定要考虑线程同步问题。

这里最核心的同步工具有三个:

  • std::mutex
  • std::condition_variable
  • std::lock_guard / std::unique_lock

需要解决的同步问题包括:

  • 多个生产者并发 Push
  • 多个 worker 并发 Pop
  • 队列为空时如何等待
  • 队列关闭时如何安全退出

5. 生命周期管理

线程池一定要考虑"怎么收尾"。

线程池不是只把任务跑起来就行了,它还必须考虑:

  • 构造时怎么创建 worker
  • 析构时怎么关闭队列
  • 怎么唤醒阻塞中的 worker
  • 怎么 join 回收线程

我这次实现的是最稳的做法:

析构时统一关闭队列,然后回收所有 worker 线程。


二、阻塞队列怎么设计

我最后的阻塞队列是这样的:

arduino 复制代码
#pragma once
#include <condition_variable>
#include <mutex>
#include <queue>
#include <utility>

template<typename T>
class BlockingQueue {
 private:
  std::queue<T> tasks_;
  std::mutex mtx_;
  std::condition_variable task_not_empty_;
  bool close_ = false;

 public:
  bool Push(T value);   // true: 成功添加任务 false: 队列已关闭
  bool Pop(T& value);   // true: 成功取到任务 false: 队列关闭且为空
  void Close();
};

template<typename T>
bool BlockingQueue<T>::Push(T value) {
  {
    std::lock_guard<std::mutex> lock(mtx_);
    if (close_) {
      return false;
    }
    tasks_.push(std::move(value));
  }
  task_not_empty_.notify_one();
  return true;
}

template<typename T>
bool BlockingQueue<T>::Pop(T& value) {
  std::unique_lock<std::mutex> lock(mtx_);
  task_not_empty_.wait(lock, [this]() {
    return !tasks_.empty() || close_;
  });

  if (!tasks_.empty()) {
    value = std::move(tasks_.front());
    tasks_.pop();
    return true;
  }

  return false;
}

template<typename T>
void BlockingQueue<T>::Close() {
  {
    std::lock_guard<std::mutex> lock(mtx_);
    close_ = true;
  }
  task_not_empty_.notify_all();
}

三、这个阻塞队列每个部分在解决什么问题

1. Push

Push 的作用是安全地加入任务。

它要考虑两个点:

  • 队列是否已经关闭
  • 多个线程是否会同时往队列里加任务

所以这里必须加锁,并且在关闭后拒绝继续添加任务。


2. Pop

Pop 是阻塞队列最关键的部分。

它要考虑:

  • 队列空时不能直接返回,要阻塞等待
  • 队列有任务时要安全取出
  • 队列关闭且空时,要返回 false 告诉外层线程可以退出

这里我最终把 Pop 设计成了返回 bool,这是一个很关键的点:

  • true:成功取到任务
  • false:队列关闭且没有任务了

这样线程池的 worker 就可以很优雅地退出。


3. Close

Close 不是普通队列里会有的接口,但在线程池里很重要。

它的作用是:

  • 通知队列不再接收新任务
  • 唤醒所有卡在 Pop 里的 worker 线程
  • 让 worker 在线程池析构时有机会退出

如果没有 Close(),worker 很容易永远卡在 wait 里,线程池析构就会卡死。


四、线程池主体实现

线程池主体代码如下:

arduino 复制代码
#include <functional>
#include <thread>
#include <vector>
#include "BlockingQueue.h"

class ThreadPool {
 private:
  void Work();
  void Stop();

  BlockingQueue<std::function<void()>> q_;
  std::vector<std::thread> workers_;

 public:
  explicit ThreadPool(int workers);
  ~ThreadPool();

  bool Post(std::function<void()> task);
};

void ThreadPool::Work() {
  while (true) {
    std::function<void()> task;
    if (!q_.Pop(task)) {
      break;
    }
    task();
  }
}

void ThreadPool::Stop() {
  q_.Close();
  for (auto& t : workers_) {
    if (t.joinable()) {
      t.join();
    }
  }
}

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

ThreadPool::~ThreadPool() {
  Stop();
}

bool ThreadPool::Post(std::function<void()> task) {
  return q_.Push(std::move(task));
}

五、线程池主体每个部分在做什么

1. Work

Work 是 worker 线程真正执行的线程函数。

逻辑很简单:

  • 从队列里 Pop
  • 成功取到任务就执行
  • 队列关闭且没任务了,就退出

这一段实际上就把"阻塞等待任务"和"执行任务"的职责连接起来了。


2. 构造函数

构造函数负责创建 worker 线程。

kotlin 复制代码
workers_.emplace_back(&ThreadPool::Work, this);

这里的含义是:创建一个线程,让它去执行当前对象的 Work() 成员函数。

所以线程池一创建出来,内部其实就已经有了一组工作线程在等任务。


3. Post

Post 是线程池对外的提交接口。

外部线程把任务包装成 std::function<void()> 后传进来,线程池再把它丢进阻塞队列。

这就形成了标准的生产者-消费者模型:

  • 外部调用 Post 的线程 = 生产者
  • 内部 worker 线程 = 消费者

4. Stop

这次我没有把关闭接口暴露给外界,而是做成了私有函数,只在析构里调用。

因为初版线程池最稳的生命周期就是:

  • 构造时创建线程
  • 析构时关闭队列并回收线程

这样足够完整,也不需要再额外讨论"外部何时停止线程池"的接口设计问题。


5. 析构函数

析构函数负责统一收尾:

  • 关闭队列
  • 唤醒 worker
  • join 回收线程

这是线程池"优雅关闭"的关键步骤。


六、测试代码:验证多生产者、多消费者

测试代码如下:

c 复制代码
#include <iostream>
#include <functional>
#include <thread>
#include <vector>

#include "BlockingQueue.h"
#include "ThreadPool.h"

void Product(ThreadPool& thread_pool, int id) {
  for (int i = 0; i < 10; i++) {
    thread_pool.Post([id, i]() {
      std::cout << "Producer " << id
                << ", worker thread id: " << std::this_thread::get_id()
                << ", task No." << i
                << " done." << std::endl;
    });
  }
}

int main() {
  ThreadPool thread_pool(3);
  std::vector<std::thread> products;

  for (int i = 0; i < 3; i++) {
    products.emplace_back(Product, std::ref(thread_pool), i);
  }

  for (auto& t : products) {
    if (t.joinable()) {
      t.join();
    }
  }

  return 0;
}

这段测试代码里:

  • 主线程创建了一个线程池,里面有 3 个 worker
  • 又创建了 3 个生产者线程
  • 每个生产者线程提交 10 个任务

这样就构成了:

  • 多生产者
  • 多消费者
  • 单任务队列

这也说明了一个我一开始容易误解的问题:

单队列不代表单生产者单消费者。

只要 PushPop 是线程安全的,单队列也完全可以支持多生产者多消费者。


七、这次实现过程中遇到的一些问题和疏忽

这次写代码时,虽然整体思路比较快理顺了,但中间还是踩了不少小坑。

1. 一开始误以为"单队列"就偏向单生产者单消费者

后来理清楚了:

  • 单队列说的是任务存储结构
  • 多生产者、多消费者说的是线程模型

两者不是一回事。


2. 一开始纠结生产者线程和消费者线程该由谁创建

后来明确:

  • worker 线程由 ThreadPool 内部创建和管理
  • 生产者线程由外部测试代码按需创建

也就是说,线程池本身负责"养工人",外部线程负责"投任务"。


3. Pop 必须返回 bool

如果 Pop 只是单纯地取值,不告诉外层"队列关闭且无任务"的状态,那么线程池在析构时就很难优雅退出。

所以 bool Pop(T& value) 是这次设计里比较关键的一点。


4. condition_variable::wait 不能配 lock_guard

这个点很容易疏忽。

wait 需要的是 std::unique_lock<std::mutex>,因为它内部会自动解锁、休眠、再重新上锁。
lock_guard 不支持这套机制。


5. std::thread 传引用参数要用 std::ref

测试代码里我后面也遇到了这个问题。

因为:

java 复制代码
void Product(ThreadPool& thread_pool, int id)

要的是引用,但 std::thread 默认会按值拷贝参数,

所以要写成:

css 复制代码
products.emplace_back(Product, std::ref(thread_pool), i);

不然会编译不过。


6. 控制台输出很乱,不代表线程池有问题

最后跑出来的日志交叉得很厉害,一开始看起来像是输出坏掉了。

但其实这是因为多个 worker 线程同时在写 std::cout,输出不是原子的,所以行内容会互相穿插。

这个现象恰恰说明:

任务确实在被多个 worker 并发执行。

所以日志乱,不是线程池错了,而是多线程打印本来就会这样。


八、如果继续优化,我会怎么讲"双队列 swap"思路

除了这次实现的单队列版本,我还想到过一个优化方向:

双队列 + 交换(swap)

大致思路是这样的:

  • 准备两个队列

    • 一个给生产者写入
    • 一个给消费者读取
  • 消费者平时从自己的消费队列里取任务

  • 当消费队列为空时,检查生产队列

  • 如果生产队列不为空,就把两个队列 swap

  • 如果生产队列也为空,就阻塞等待


这个优化想解决什么问题

它主要是为了减少生产者和消费者在同一批任务数据上的锁竞争。

单队列版本里:

  • 生产者一直在往同一个队列里 push
  • 消费者一直在从同一个队列里 pop

这会让两边持续竞争一把锁。

而双队列的优化思想是:

尽量让生产者写自己的、消费者读自己的,只有在"切换批次"时才交换一次。

这样消费者一次 swap 后,可以批量消费当前队列里的任务,减少频繁争锁。


这个优化要额外考虑什么

这个思路虽然能讲,但真正实现时要考虑的细节明显更多:

  1. 不能多个消费者同时发现队列空、同时去 swap

  2. swap 必须在严格保护下完成

  3. 空队列时不能靠 sleep 轮询,还是应该用 condition_variable

  4. 关闭线程池时,要想清楚:

    • 当前消费队列还有没有剩余任务
    • 生产队列还有没有任务没换过来
    • 多个 worker 怎么安全退出

所以面试时我更推荐这样回答:

第一版先写单队列保证正确性;如果后续要进一步降低锁竞争,可以考虑双队列 swap 的批量切换方案。

这样既体现了你能写出稳定版本,也体现了你有进一步优化的意识。


九、如果继续优化,我会怎么讲完美转发

这次我在 Post 里先用了最简单的版本:

c 复制代码
bool Post(std::function<void()> task);

这是最稳的,因为调用方先把任务包装成 std::function<void()>,线程池只负责存和执行。

但后面如果进一步优化接口,可以考虑把 Post 改成模板 + 完美转发,比如支持:

ini 复制代码
pool.Post(f, x, y);
pool.Post([] { ... });

核心思想是:

  • Post 不强迫用户先手动构造 std::function<void()>
  • 直接接收可调用对象和参数
  • 在内部把它们绑定成一个最终任务

一个常见写法大概是:

c 复制代码
template <typename F, typename... Args>
bool Post(F&& f, Args&&... args) {
  auto task = std::bind(std::forward<F>(f), std::forward<Args>(args)...);
  return q_.Push(std::function<void()>(std::move(task)));
}

完美转发这个优化在讲的时候怎么说

我会这样概括:

当前版本 Post(std::function<void()>) 最简单、最稳;

如果后续要减少额外拷贝并提升接口通用性,可以把 Post 升级成模板接口,用完美转发保留左值/右值属性,再在内部统一封装成任务对象。


相关推荐
wangfpp2 小时前
性能优化,请先停手:为什么我劝你别上来就搞优化?
前端·javascript·面试
野犬寒鸦2 小时前
JVM垃圾回收机制面试常问问题及详解
java·服务器·开发语言·jvm·后端·算法·面试
奕成则成3 小时前
面试被问:MySQL 与 Doris/SelectDB 的架构区别。 大数据为什么禁止select *。
mysql·面试·架构
AlunYegeer3 小时前
面试问题controller和service能不能互相替换
面试·职场和发展
MSTcheng.3 小时前
【优选算法必修篇——位运算】『面试题 01.01. 判定字符是否唯一&面试题 17.19. 消失的两个数字』
java·算法·面试
Hilaku5 小时前
为什么很多工作 5 年的前端,身价反而卡住了?🤷‍♂️
前端·javascript·面试
前端炒粉5 小时前
React 面试高频题
前端·react.js·面试
阿里巴巴业务中台前端5 小时前
[淘天校招]2026年校园招聘开启
面试
七禾页丫5 小时前
面试记录19 软件设计师
面试·职场和发展