面试和工作中的线程池

线程池是一种很经典的技术,在后端系统中很常见。线程池的常规做法是提前创建好一组工作线程,然后将任务分发给这些工作线程来处理,这样就避免了频繁的线程创建和销毁,同时也能很好的控制线程数量。线程池本质上是一种池化技术,利用空间来换取时间。线程池技术已经存在很多年,在面试的时候被问到的概率很高,在工作中也非常有用。

 

首先来看面试中的线程池,通常面试官会提问线程池的目的和原理,如果面试时间充足的话,恭喜你可能要进入紧张刺激的“白纸编程”(又叫“白板面试”,在一张A4纸上手写代码)阶段了。

线程池在设计和实现时主要考虑“任务”和“工作线程”之间的协作关系。通常我们把线程池创建的工作线程称之为worker线程,他们就像一群任劳任怨、勤劳无比的工人们(like you and me)一样,等着有人给安排活儿干或主动找任务做;任务通常被抽象成一个类,主要提供一种统一、通用的任务接口,以便线程池中的worker线程进行无差别的调用。

下面的代码是一个简单任务Task类的例子,简单起见,这个类只提供了一个没有返回值和参数的抽象方法Run。

 1 define _THREAD_POOL_H
 2  
 3 #include <pthread.h>
 4 #include <iostream>
 5 #include <string>
 6 #include <list>
 7  
 8 namespace thread {
 9  
10 const int MIN_THREAD_NUM = 4;
11 const int MAX_THREAD_NUM = 100;
12  
13 // 任务类接口,只提供一个Run方法
14 class Task {
15  public:
16   virtual void Run() = 0;
17   virtual ~Task() {}
18 };

 

线程池通常提供两个接口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线程数是否还能继续增加。

优雅退出功能:程序在接收信号准备停止运行时,线程池中积压的任务要处理完后才能退出程序,同时将资源有序释放。

 

最后补充一点,就是代码简洁,好的线程池代码一定是简洁易懂、接口容易被正确使用的。以上就是我总结的后端系统中线程池的基本知识和相关技巧,希望能够帮助朋友们掌握线程池的原理和使用,尤其在面试和长久的工作过程中有所帮助。


 

金句分享

年轻是一个中性词,它代表着很多缺点:缺乏经验、少不更事、容易冲动。但是也有很多优点,其中之一就是有大把的时间去遗忘那些不该记住的事情。

——出自《平凡的世界》,作者路遥,原名王卫国,中国著名作家。

解读:年轻的时候要勤奋、谦虚,勇敢尝试。

posted @ 2018-08-26 22:05  张巩武  阅读(1784)  评论(0编辑  收藏  举报