【C++面试】手写线程池:任务队列、工作线程与优雅退出

一、为什么需要线程池

如果没有线程池,每来一个任务就创建一个线程:

复制代码
std::thread t(task);
t.detach();

任务少的时候没有明显问题。

但如果短时间内来了大量任务:

复制代码
任务1
任务2
任务3
任务4
...
任务10000

程序就可能不断:

复制代码
创建线程
   ↓
执行任务
   ↓
销毁线程

线程的创建和销毁本身是有开销的。

而且线程数量如果不受控制,还可能出现:

复制代码
线程数量过多
    ↓
频繁上下文切换
    ↓
CPU调度开销增加
    ↓
程序性能下降

线程池的思路就是:

复制代码
程序启动
   ↓
提前创建固定数量线程
   ↓
线程一直等待任务
   ↓
任务来了以后放入队列
   ↓
空闲线程取任务执行
   ↓
执行完继续等待

例如提前创建:

复制代码
4个工作线程

后面来了:

复制代码
100个任务

也不会创建 100 个线程。

而是:

复制代码
               Task Queue

Task1 → Task2 → Task3 → Task4 → ...

          ↓        ↓        ↓        ↓

      Thread1  Thread2  Thread3  Thread4

四个线程不断从任务队列中取任务执行。

所以线程池主要解决的是:

复制代码
线程重复利用
+
控制线程数量
+
统一管理任务

从面试角度来看,一个简化线程池最少需要四个核心部分:

复制代码
工作线程 workers

任务队列 tasks

互斥锁 mutex

条件变量 condition_variable

再加一个:

复制代码
停止标志 stop

用来控制线程池退出。

二、先设计一个最简单的线程池

先定义线程池:

复制代码
#include <condition_variable>
#include <functional>
#include <mutex>
#include <queue>
#include <thread>
#include <vector>

class ThreadPool {
private:
    std::vector<std::thread> workers_;          // 工作线程
    std::queue<std::function<void()>> tasks_;  // 任务队列

    std::mutex mutex_;                          // 保护任务队列
    std::condition_variable cv_;                // 用于线程等待和唤醒

    bool stop_;                                 // 是否停止线程池

public:
    explicit ThreadPool(size_t threadCount);
    ~ThreadPool();

    void post(std::function<void()> task);
};

这里最关键的是:

复制代码
std::vector<std::thread> workers_;

负责保存所有工作线程。

例如:

复制代码
workers_[0]
workers_[1]
workers_[2]
workers_[3]

分别对应四个线程。

任务则使用:

复制代码
std::queue<std::function<void()>> tasks_;

保存。

为什么任务类型使用:

复制代码
std::function<void()>

因为这样可以把各种:

复制代码
普通函数
Lambda
成员函数包装结果

统一成:

复制代码
无参数、无返回值的可调用对象

例如:

复制代码
pool.post([]() {
    std::cout << "hello thread pool" << std::endl;
});

这个 Lambda 就可以直接放入任务队列。

所以整个线程池的数据关系可以先理解成:

复制代码
                 ThreadPool
                     │
          ┌──────────┴──────────┐
          ↓                     ↓
      Task Queue              Workers
          ↓                     ↓
      task1                   thread1
      task2                   thread2
      task3                   thread3
      task4                   thread4

工作线程只负责:

复制代码
不断从Task Queue里面取任务

三、构造函数:创建工作线程并等待任务

线程池构造时,需要创建指定数量的线程。

例如:

复制代码
ThreadPool pool(4);

表示创建:

复制代码
4个工作线程

构造函数可以这样实现:

复制代码
ThreadPool::ThreadPool(size_t threadCount) : stop_(false) {
    for (size_t i = 0; i < threadCount; ++i) {
        workers_.emplace_back([this]() {
            while (true) {
                std::function<void()> task;

                {
                    std::unique_lock<std::mutex> lock(mutex_);

                    cv_.wait(lock, [this]() {
                        return stop_ || !tasks_.empty();
                    });

                    if (stop_ && tasks_.empty()) return;

                    task = std::move(tasks_.front());
                    tasks_.pop();
                }

                task();
            }
        });
    }
}

这段代码就是线程池最核心的部分。

首先:

复制代码
workers_.emplace_back(...);

创建一个新线程并保存到:

复制代码
workers_

里面。

线程运行以后进入:

复制代码
while (true)

表示工作线程一直存在,不会执行一次任务就退出。

接下来:

复制代码
std::unique_lock<std::mutex> lock(mutex_);

给任务队列加锁。

因为后面多个线程都可能访问:

复制代码
tasks_

如果不加锁,就可能出现多个线程同时:

复制代码
读取tasks_.front()
删除tasks_.front()
添加任务

从而造成数据竞争。

然后进入:

复制代码
cv_.wait(lock, [this]() {
    return stop_ || !tasks_.empty();
});

这一句非常重要。

它表示:

复制代码
如果现在没有任务
   ↓
线程进入等待
   ↓
不一直占用CPU

直到出现:

复制代码
有新任务
或者
线程池准备退出

才会被唤醒。

所以工作线程不是这样:

复制代码
while (true) {
    if (!tasks_.empty()) {
        ...
    }
}

因为这种写法会一直循环检查:

复制代码
有没有任务?
有没有任务?
有没有任务?

造成:

复制代码
CPU空转

而条件变量可以让线程:

复制代码
没有任务 → 睡眠
有任务 → 唤醒

当线程被唤醒以后:

复制代码
if (stop_ && tasks_.empty()) return;

表示:

复制代码
线程池已经停止
+
任务也全部执行完了

那么这个工作线程就可以退出。

如果还有任务:

复制代码
task = std::move(tasks_.front());
tasks_.pop();

从队头取出一个任务。

然后离开:

复制代码
锁作用域

最后:

复制代码
task();

执行任务。

为什么 task() 要放在锁外面?

因为如果写成:

复制代码
锁住mutex
   ↓
取任务
   ↓
执行task
   ↓
任务执行完
   ↓
解锁

假设一个任务执行:

复制代码
5秒

那么这 5 秒里面其他线程都拿不到这个锁。

结果就是:

复制代码
虽然有多个工作线程
实际上一次只能执行一个任务

所以正确流程应该是:

复制代码
加锁
 ↓
从队列取任务
 ↓
删除任务
 ↓
解锁
 ↓
真正执行任务

锁只负责保护:

复制代码
任务队列

而不是保护任务本身的执行。

四、Post任务和线程池如何优雅退出

有了工作线程以后,还需要向线程池提交任务。

可以写一个:

复制代码
void ThreadPool::post(std::function<void()> task) {
    {
        std::lock_guard<std::mutex> lock(mutex_);

        if (stop_) return;

        tasks_.push(std::move(task));
    }

    cv_.notify_one();
}

提交一个任务:

复制代码
pool.post([]() {
    std::cout << "task running" << std::endl;
});

整个过程就是:

复制代码
调用post
   ↓
加锁
   ↓
任务push进queue
   ↓
解锁
   ↓
notify_one
   ↓
唤醒一个工作线程
   ↓
线程取出任务
   ↓
执行task()

这里:

复制代码
cv_.notify_one();

为什么只唤醒一个线程?

因为:

复制代码
只加入了一个新任务

所以通常只需要唤醒:

复制代码
一个等待线程

即可。

如果一次加入了很多任务,也可以根据设计选择:

复制代码
notify_all();

但是简单线程池一般:

复制代码
notify_one();

就够了。

接下来是线程池中非常重要的一个问题:

析构的时候怎么安全退出?

不能直接让对象析构。

因为此时:

复制代码
workers_里面的线程

可能还在运行。

正确流程应该是:

复制代码
设置stop = true
      ↓
唤醒所有等待线程
      ↓
线程把剩余任务执行完
      ↓
所有线程退出
      ↓
主线程join
      ↓
线程池正式销毁

析构函数可以这样写:

复制代码
ThreadPool::~ThreadPool() {
    {
        std::lock_guard<std::mutex> lock(mutex_);
        stop_ = true;
    }

    cv_.notify_all();

    for (std::thread &worker : workers_) {
        if (worker.joinable()) worker.join();
    }
}

首先:

复制代码
stop_ = true;

告诉所有工作线程:

复制代码
线程池准备关闭

然后:

复制代码
cv_.notify_all();

为什么这里是:

复制代码
notify_all

而不是:

复制代码
notify_one

因为可能有:

复制代码
多个工作线程正在wait

例如:

复制代码
Thread1 → wait

Thread2 → wait

Thread3 → wait

Thread4 → wait

线程池要关闭时,需要把:

复制代码
所有线程

都唤醒。

线程醒来以后判断:

复制代码
if (stop_ && tasks_.empty()) return;

如果队列里还有任务:

复制代码
继续执行

如果任务已经全部执行完成:

复制代码
退出线程函数

最后析构函数:

复制代码
worker.join();

等待工作线程真正退出。

这就是所谓的:

复制代码
优雅关闭

而不是任务执行到一半就强行结束线程。

完整代码如下:

复制代码
#include <condition_variable>
#include <functional>
#include <iostream>
#include <mutex>
#include <queue>
#include <thread>
#include <vector>

class ThreadPool {
private:
    std::vector<std::thread> workers_;
    std::queue<std::function<void()>> tasks_;

    std::mutex mutex_;
    std::condition_variable cv_;

    bool stop_;

public:
    explicit ThreadPool(size_t threadCount) : stop_(false) {
        for (size_t i = 0; i < threadCount; ++i) {
            workers_.emplace_back([this]() {
                while (true) {
                    std::function<void()> task;

                    {
                        std::unique_lock<std::mutex> lock(mutex_);

                        cv_.wait(lock, [this]() {
                            return stop_ || !tasks_.empty();
                        });

                        // 停止并且已经没有任务,线程退出
                        if (stop_ && tasks_.empty()) return;

                        // 取出一个任务
                        task = std::move(tasks_.front());
                        tasks_.pop();
                    }

                    // 在锁外执行任务
                    task();
                }
            });
        }
    }

    ~ThreadPool() {
        {
            std::lock_guard<std::mutex> lock(mutex_);
            stop_ = true;
        }

        // 唤醒所有工作线程
        cv_.notify_all();

        // 等待所有线程退出
        for (std::thread &worker : workers_) {
            if (worker.joinable()) worker.join();
        }
    }

    void post(std::function<void()> task) {
        {
            std::lock_guard<std::mutex> lock(mutex_);

            if (stop_) return;

            tasks_.push(std::move(task));
        }

        // 新增一个任务,唤醒一个线程即可
        cv_.notify_one();
    }
};

int main() {
    ThreadPool pool(4);

    for (int i = 0; i < 10; ++i) {
        pool.post([i]() {
            std::cout << "task " << i << " thread = "
                      << std::this_thread::get_id() << std::endl;
        });
    }

    return 0;
}

整个线程池运行过程可以整理成:

复制代码
创建ThreadPool
      ↓
创建N个Worker
      ↓
Worker进入wait
      ↓
       post(task)
          ↓
     task进入queue
          ↓
      notify_one
          ↓
    Worker被唤醒
          ↓
      从queue取任务
          ↓
        解锁
          ↓
       执行task
          ↓
      再次进入wait

析构时:

复制代码
ThreadPool析构
      ↓
stop = true
      ↓
notify_all
      ↓
所有Worker被唤醒
      ↓
继续执行剩余任务
      ↓
queue为空
      ↓
Worker return
      ↓
join所有线程
      ↓
线程池销毁

五、面试最容易继续追问什么

把上面的线程池写出来以后,面试官通常不会马上结束,而是会继续围绕几个细节追问。

1. 为什么需要condition_variable?

因为如果没有条件变量,很容易写成:

复制代码
while (true) {
    if (!tasks_.empty()) {
        // 执行任务
    }
}

没有任务的时候线程仍然一直运行。

这叫:

复制代码
忙等待

会不断消耗 CPU。

使用:

复制代码
cv_.wait();

以后:

复制代码
没有任务
   ↓
线程睡眠

有任务
   ↓
notify
   ↓
线程唤醒

这样更加合理。


2. 为什么wait必须配合unique_lock?

常见写法是:

复制代码
std::unique_lock<std::mutex> lock(mutex_);
cv_.wait(lock, predicate);

因为 wait() 在等待时需要:

复制代码
自动释放mutex

让其他线程能够:

复制代码
post任务

被唤醒以后又需要:

复制代码
重新获得mutex

再继续访问任务队列。

也就是:

复制代码
wait
 ↓
自动unlock
 ↓
线程睡眠
 ↓
notify
 ↓
重新lock
 ↓
继续执行

unique_lock 支持这种:

复制代码
动态加锁和解锁

所以条件变量通常和:

复制代码
std::unique_lock

一起使用。


3. 为什么wait还需要判断条件?

也就是:

复制代码
cv_.wait(lock, [this]() {
    return stop_ || !tasks_.empty();
});

而不是简单:

复制代码
cv_.wait(lock);

一个重要原因是需要防止:

复制代码
虚假唤醒

线程可能在没有真正新任务的情况下从 wait() 返回。

所以醒来以后仍然必须重新确认:

复制代码
到底有没有任务?
是不是准备停止?

使用带谓词版本相当于帮我们做:

复制代码
while (!condition) {
    cv_.wait(lock);
}

4. 为什么task要在锁外执行?

因为锁真正保护的是:

复制代码
tasks_

如果:

复制代码
task();

也放在锁里面:

复制代码
线程A拿到锁
 ↓
执行一个10秒任务
 ↓
10秒后释放锁

其他工作线程这 10 秒都无法取任务。

线程池就失去了:

复制代码
并发执行

的意义。

所以:

复制代码
锁内:只取任务

锁外:执行任务

这是非常重要的一点。


5. 为什么析构一定要join?

如果:

复制代码
std::thread

对象析构时仍然:

复制代码
joinable

程序可能直接:

复制代码
std::terminate

所以线程池销毁之前必须处理这些线程。

这里选择:

复制代码
join()

表示:

复制代码
主线程等待工作线程结束

保证线程生命周期完整结束。


6. stop为什么也要在锁里面修改?

因为:

复制代码
stop_

不仅析构线程会访问。

工作线程也会读取:

复制代码
stop_ || !tasks_.empty()

如果多个线程同时访问并且没有同步,就可能产生:

复制代码
数据竞争

所以这里让:

复制代码
stop_
tasks_

都由同一个:

复制代码
mutex_

保护。

另一种方案也可以考虑:

复制代码
std::atomic<bool> stop_;

但对于这个简化实现来说,让状态和任务队列共用锁会更加直观。


7. 这个线程池还可以怎么优化?

当前只是:

复制代码
面试简化版

真正工程中的线程池还可能继续支持:

复制代码
动态线程数量

任务返回值

future / packaged_task

任务优先级

有界任务队列

拒绝策略

线程空闲回收

异常处理

任务取消

工作窃取

例如当前:

复制代码
void post(std::function<void()> task);

没有返回值。

如果希望拿到任务结果,可以进一步结合:

复制代码
std::future
std::packaged_task

实现:

复制代码
auto future = pool.submit([]() {
    return 10 + 20;
});

std::cout << future.get() << std::endl;

不过面试现场如果让手写一个简单线程池,一般先把:

复制代码
workers
+
task queue
+
mutex
+
condition_variable
+
stop
+
join

这套核心流程写正确,比一开始追求复杂功能更重要。

面试时可以把整个线程池总结成:

我实现的是一个固定线程数量的简化线程池。构造函数提前创建多个 Worker,每个 Worker 循环从线程安全的任务队列中获取 std::function<void()> 类型的任务。任务为空时通过 condition_variable 阻塞等待,post() 添加任务后使用 notify_one() 唤醒工作线程。任务取出后会先释放任务队列的锁,再真正执行任务,避免长时间占用互斥锁。析构时设置停止标志并 notify_all(),让 Worker 执行完剩余任务后退出,最后通过 join() 等待所有线程安全结束。

如果还能补一句:

核心本质就是"生产者消费者模型"。

基本就已经把这一题的关键点说清楚了。

0voice · GitHub

相关推荐
言乐61 小时前
Python概括后端原理
开发语言·python·django·virtualenv·pygame
无名猿1 小时前
拷贝构造与拷贝赋值:调用时机、深浅拷贝与复制消除
c++·内存管理·现代c++·踩坑记录
@大迁世界1 小时前
JEV RAG:更高效的 RAG 系统解决方案?
开发语言·rust
朝朝辞暮i2 小时前
C++ 第一阶段总结:从零基础到 ROS2 前置语法
开发语言·c++·算法
朝朝辞暮i2 小时前
C++ 第 25 课:getter / setter + this
java·javascript·c++
2601_962885722 小时前
如何用 Python 回测定投策略(定期定额 DCA)?(和一次性买入对比)
开发语言·人工智能·python
周杰偷奶茶2 小时前
【JavaSE】类和对象基础
java·开发语言
All for pursuit.2 小时前
【回溯-4】46.全排列
数据结构·c++·算法·leetcode
大圣编蚕2 小时前
C语言核心知识复习:预处理、头文件、强制类型转换、错误处理、递归与内存管理
c语言·开发语言