前言
ok,我们之前把固定式线程池写完了,今天开始学习缓冲式线程池,固定式线程池确实用起来简单,但用着用着就发现问题了:线程数从创建开始就定死了,完全不能灵活调整。如果突然来了一大波 IO 密集型任务,所有线程都在等网络、等数据库,任务队列会越堆越长;但如果任务很少,大部分线程都在空转,又白白浪费系统资源。
这时候就需要缓存式线程池了,它是生产环境里用得最多的线程池类型之一,核心特点就是线程数可以动态伸缩:任务多的时候自动新建线程,任务少的时候自动销毁空闲线程,既能应对突发高并发,又能在空闲时节省资源,特别适合 IO 密集型任务场景。
一、缓存式线程池的核心设计
和固定线程池相比,缓存式线程池多了几个核心逻辑,也是我们这次要重点实现的部分:
- 有最小线程数和最大线程数限制,线程数永远在这个区间内波动
- 空闲线程超过指定时间后自动销毁,不会一直占用资源
- 提交任务时,如果没有空闲线程,且当前线程数没到最大值,就自动新建线程
- 用原子变量统计当前线程数和空闲线程数,保证多线程下计数准确
cachedthreadpool.hpp
cpp
#ifndef CACHEDTHREADPOOL_H
#define CACHEDTHREADPOOL_H
#include <vector>
#include <queue>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <functional>
#include <future>
#include <stdexcept>
#include <atomic>
#include <chrono>
class cachedthreadpool
{
public:
// 最小两个线程,最大10个线程,超时时间是3秒
cachedthreadpool(int min_threads = 2, int max_threads = 10, int idle_timeout = 3);
~cachedthreadpool();
//入队函数
template<typename f, typename... args>
auto enqueue(f&& func, args&&... arg) -> std::future<decltype(func(arg...))>
{
using ret = decltype(func(arg...));
auto task = std::make_shared<std::packaged_task<ret()>>(
std::bind(std::forward<f>(func), std::forward<args>(arg)...)
);
std::future<ret> res = task->get_future();
{
std::lock_guard<std::mutex> lock(mtx);
if(!run_flag)
{
throw std::runtime_error("线程池已经关闭,无法提交任务");
}
task_queue.push([task](){(*task)();});
// 有空闲线程就唤醒,没有就新建线程
if(idle_threads > 0)
{
cv.notify_one();
}
else if(current_threads < max_threads)
{
add_thread();
}
}
return res;
}
void stop();
private:
void add_thread();
void worker();//线程执行
std::vector<std::thread> thread_group;
std::queue<std::function<void()>> task_queue;
std::mutex mtx;
std::condition_variable cv;
bool run_flag;
int min_threads;
int max_threads;
std::chrono::seconds idle_timeout;
// 这里必须用原子变量!多线程同时修改计数
std::atomic<int> current_threads;
std::atomic<int> idle_threads;
};
#endif
这里先记一个我踩过的大坑:current_threads和idle_threads绝对不能用普通 int!多个线程会同时修改这两个值,普通 int 不是线程安全的,会出现计数不准的问题,比如明明线程已经退出了,计数还没减,导致线程数一直涨,最后把系统资源吃光。必须用std::atomic<int>原子变量,保证每次修改都是原子操作。
cachedthreadpool.cpp
cpp
#include "cachedthreadpool.hpp"
#include <iostream>
cachedthreadpool::cachedthreadpool(int min, int max, int idle)
: min_threads(min), max_threads(max), idle_timeout(idle)
{
if(min_threads < 1) min_threads = 1;
if(max_threads < min_threads) max_threads = min_threads;
run_flag = true;
current_threads = 0;
idle_threads = 0;
//先设成最小的线程数量
for(int i = 0; i < min_threads; i++)
{
add_thread();
}
}
cachedthreadpool::~cachedthreadpool()
{
if(run_flag)
{
stop();
}
}
void cachedthreadpool::add_thread()
{
thread_group.emplace_back(&cachedthreadpool::worker, this);
current_threads++;
// std::cout << "新建线程,当前总线程数:" << current_threads << std::endl;
}
void cachedthreadpool::worker()
{
while(true)
{
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(mtx);
idle_threads++;
//wait_for等待,超时就自动退出
while(task_queue.empty() && run_flag)
{
// 等待指定时间,如果超时了,就检查是否可以退出
if(cv.wait_for(lock, idle_timeout) == std::cv_status::timeout)
{
// 只有当前线程数大于最小线程数时,才能退出
if(current_threads > min_threads)
{
idle_threads--;
current_threads--;
// 调试用,注释掉
// std::cout << "线程空闲超时退出,剩余线程数:" << current_threads << std::endl;
return;
}
}
}
if(!run_flag && task_queue.empty())
{
idle_threads--;
current_threads--;
return;
}
task = std::move(task_queue.front());
task_queue.pop();
idle_threads--;
}
// 执行任务
try
{
task();
}
catch(...)
{
std::cerr << "任务执行出现异常" << std::endl;
}
}
}
void cachedthreadpool::stop()
{
{
std::lock_guard<std::mutex> lock(mtx);
run_flag = false;
}
cv.notify_all();
for(auto& t : thread_group)
{
if(t.joinable())
{
t.join();
}
}
}
四、和固定线程池的核心区别
这两个线程池大部分逻辑都很像,核心区别只有 3 点,也是缓存式线程池的灵魂:
1. 提交任务时的逻辑不同
固定线程池:不管有没有空闲线程,都只把任务放进队列,然后唤醒一个线程
缓存式线程池:先看有没有空闲线程,有就唤醒;没有就新建线程,直到达到最大线程数
2. 线程等待逻辑不同
固定线程池:用cv.wait()无限等待,直到有任务或者线程池关闭缓存式线程池:用cv.wait_for()等待指定时间,超时后如果线程数大于最小值,就自动销毁
3. 多了动态增减线程的逻辑
固定线程池:线程数永远不变缓存式线程池:线程数在最小值和最大值之间动态波动,空闲超时自动销毁,任务多了自动新建
六、缓存式线程池优缺点总结
优点
- 线程数动态伸缩,能应对突发高并发
- 空闲线程自动销毁,节省系统资源
- 特别适合 IO 密集型任务,能充分利用 CPU
- 比固定线程池灵活很多
缺点
- 逻辑比固定线程池复杂一点,容易出 bug
- 如果任务提交太频繁,会导致线程频繁创建销毁,反而降低性能
- 最大线程数如果设置太大,还是可能把系统资源吃光
七、学习小结
写完缓存式线程池,我最大的感受就是:线程池的核心逻辑其实都是相通的,都是 "线程复用 + 任务队列 + 锁 + 条件变量",不同类型的线程池只是在这个基础上改了线程创建销毁的规则和任务调度的规则。
缓存式线程池解决了固定线程池不够灵活的问题,是生产环境中最常用的线程池类型,尤其是在处理大量 IO 密集型任务的时候,性能比固定线程池好很多。但要注意合理设置最小线程数、最大线程数和空闲超时时间,避免线程频繁创建销毁。