线程池是一种很经典的技术,在后端系统中很常见。线程池的常规做法是提前创建好一组工作线程,然后将任务分发给这些工作线程来处理,这样就避免了频繁的线程创建和销毁,同时也能很好的控制线程数量。线程池本质上是一种池化技术,利用空间来换取时间。线程池技术已经存在很多年,在面试的时候被问到的概率很高,在工作中也非常有用。
首先来看面试中的线程池,通常面试官会提问线程池的目的和原理,如果面试时间充足的话,恭喜你可能要进入紧张刺激的“白纸编程”(又叫“白板面试”,在一张A4纸上手写代码)阶段了。
线程池在设计和实现时主要考虑“任务”和“工作线程”之间的协作关系。通常我们把线程池创建的工作线程称之为worker线程,他们就像一群任劳任怨、勤劳无比的工人们(like you and me)一样,等着有人给安排活儿干或主动找任务做;任务通常被抽象成一个类,主要提供一种统一、通用的任务接口,以便线程池中的worker线程进行无差别的调用。
下面的代码是一个简单任务Task类的例子,简单起见,这个类只提供了一个没有返回值和参数的抽象方法Run。
define _THREAD_POOL_H
#include <pthread.h>
#include <iostream>
#include <string>
#include <list>
namespace thread {
const int MIN_THREAD_NUM = 4;
const int MAX_THREAD_NUM = 100;
// 任务类接口,只提供一个Run方法
class Task {
public:
virtual void Run() = 0;
virtual ~Task() {}
};
线程池通常提供两个接口Init和AddTask,其中Init接口用来初始化线程池资源,比如创建指定数目的worker线程,以及初始化任务队列,任务队列用来保存用户添加的各种任务,由于任务队列主要涉及添加和删除操作,因此STL中的list容器比较适合;AddTask接口是调用最多的接口,用于向线程池中添加任务。用户添加任务和worker线程获取任务这两个操作需要加互斥锁来保护任务队列,下面的代码是线程池类ThreadPool。
// 线程池
class ThreadPool {
public:
ThreadPool() : max_thread_num_(0) {}
// 初始化线程池,同时设置最大worker线程个数
bool Init(int max_thread_num);
// 添加任务到线程池
void AddTask(Task *task);
private:
static void *StartWorker(void *argv);
void Do();
private:
int max_thread_num_;
pthread_mutex_t lock_;
pthread_cond_t cond_;
std::list<Task*> task_list_; // 任务队列
};
} // namespace thread
#endif
这里解释下线程池中的StartWorker函数为什么被设计成static静态函数,这是由pthread_create的线程入口参数必须是静态函数这个限制条件决定的。同时由于我们只需要给用户提供Init和AddTask接口,所以StartWorker函数被设计成私有的。
下面的代码是线程池类ThreadPool的实现,作为一个示例程序,这里的函数调用都没有判断返回值。
#include "thread_pool.h"
#include <cstdio>
namespace thread {
bool ThreadPool::Init(int max_thread_num) {
// 参数合法性检查
if (max_thread_num < MIN_THREAD_NUM ||
max_thread_num > MAX_THREAD_NUM) {
printf("Error: Invalid parameter thread number:%d\n",
max_thread_num);
return false;
}
//初始化锁、条件变量
pthread_mutex_init(&lock_, NULL);
pthread_cond_init(&cond_, NULL);
pthread_t thd;
for (int i = 0; i < max_thread_num; ++i) {
// 创建线程
// 注意StartWorker的参数是this指针,即ThreadPool*类型指针
pthread_create(&thd, NULL, ThreadPool::StartWorker, this);
}
max_thread_num_ = max_thread_num;
return true;
}
void ThreadPool::AddTask(Task *task) {
if (task == NULL) {
return;
}
pthread_mutex_lock(&lock_);
task_list_.push_back(task);
pthread_mutex_unlock(&lock_);
pthread_cond_signal(&cond_);
}
void *ThreadPool::StartWorker(void *argv) {
ThreadPool *pool = reinterpret_cast<ThreadPool*>(argv);
pool->Do();
return NULL;
}
void ThreadPool::Do() {
// worker线程处理循环
while (true) {
// 等待任务
pthread_mutex_lock(&lock_);
while (task_list_.size() == 0) {
pthread_cond_wait(&cond_, &lock_);
}
// 获取并执行任务,释放任务资源
Task *task = task_list_.front();
task_list_.pop_front();
pthread_mutex_unlock(&lock_);
task->Run();
delete task;
}
}
} // namespace thread
我们知道,C++类的静态函数没有this指针,在静态函数中只能调用静态函数。StartWorker是一个静态函数,而Do函数是一个非静态函数,这里的技巧就是通过将参数argv传递一个this指针进来,然后通过C++的reinterpret_cast转换成ThreadPool类型的指针,再通过这个指针调用Do函数。
最后,我们通过一个例子演示如何生成一个具体的任务Task,如何将Task添加到线程池,执行效果又是怎么样的。
#include "thread_pool.h"
#include <cstdio>
#include <cstdlib>
#include <string.h>
#include <unistd.h>
// 简单任务类,Run函数仅仅是打印字符串
class MyTask: public thread::Task {
public:
void Run();
void SetData(const std::string &data) {
data_ = data;
}
private:
std::string data_;
};
void MyTask::Run() {
printf("%s run over.\n", data_.c_str());
}
int main(int argc, char **argv) {
// 初始化线程池
thread::ThreadPool thread_pool;
thread_pool.Init(4);
char str[10] = "";
for (int i = 0; i < 10; ++i) {
// 初始化任务,仅仅是设置任务名称
MyTask *task = new MyTask();
sprintf(str, "Task %d", i);
task->SetData(str);
// 添加任务到线程池
thread_pool.AddTask(task);
}
// 休眠100ms等待线程池任务执行完
usleep(100);
return 0;
}
编译:g++ main.cpp thread_pool.cpp -lpthread
运行:./a.out
Task 0 run over.
Task 4 run over.
Task 5 run over.
Task 6 run over.
Task 7 run over.
Task 8 run over.
Task 9 run over.
Task 2 run over.
Task 3 run over.
Task 1 run over.
至此,一个白纸编程的线程池就完成了,它是一个很好的线程池原型,足以让面试官产生“此人还是写过几行代码的,我再出个5星级难度的算法题考考他的思考问题能力”的美妙想法……言归正传,工作中的线程池比这个简单模型要功能强大很多,会对这个模型进行大量优化,以满足工程需求。
那么,工业级别的线程池通常具备什么特点呢?可靠、稳定、高性能,这些高大上的词都显得太“虚”了,更为实际一点的答案是支持监控、灵活配置、自我调节能力和具有优雅退出功能。下面来浅谈一下工作中线程池应具备的上述优良特点,以及这些特点实现的思路。
可监控:线程池最主要的监控指标只有一个,那就是任务堆积个数,通过这个指标我们可以直观感受到线程池运行状况。如果观察到线程池任务堆积严重,这时候就要仔细分析原因,考虑是否需要调整线程池参数或者优化任务的处理逻辑了。在上述线程池原型中,线程池的任务堆积个数即task_queue_.size()。
支持灵活配置:是指线程池应提供足够多的参数让用户去定制,例如最小线程个数、最大线程个数、任务超时时间等等。
自我调节能力:这个是线程池的高级功能,是指线程池中的worker线程个数可以由线程池自身动态调整,例如在任务很少的时候,主动减少worker线程数,例如可以将示例中ThreadPool::Do函数中的pthread_cond_wait改成pthread_cond_timewait,设置一个超时时间,如果达到超时时间则主动销毁worker线程。很显然还需要在任务数变多的时候主动增加worker线程个数,当然前提是不能超过线程池中的最大线程个数限制。这个特性可以通过修改AddTask接口来实现,每次添加任务时都判断下当前任务队列的任务数是否达到某个阈值,同时判断worker线程数是否还能继续增加。
优雅退出功能:程序在接收信号准备停止运行时,线程池中积压的任务要处理完后才能退出程序,同时将资源有序释放。
最后补充一点,就是代码简洁,好的线程池代码一定是简洁易懂、接口容易被正确使用的。以上就是我总结的后端系统中线程池的基本知识和相关技巧,希望能够帮助朋友们掌握线程池的原理和使用,尤其在面试和长久的工作过程中有所帮助。
金句分享
年轻是一个中性词,它代表着很多缺点:缺乏经验、少不更事、容易冲动。但是也有很多优点,其中之一就是有大把的时间去遗忘那些不该记住的事情。
——出自《平凡的世界》,作者路遥,原名王卫国,中国著名作家。
解读:年轻的时候要勤奋、谦虚,勇敢尝试。