现在的位置: 首页 > 自动控制 > 工业·编程 > 正文

面试和工作中的线程池

2018-09-22 08:26 工业·编程 ⁄ 共 4523字 ⁄ 字号 暂无评论

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

首先来看面试中的线程池,通常面试官会提问线程池的目的和原理,如果面试时间充足的话,恭喜你可能要进入紧张刺激的“白纸编程”(又叫“白板面试”,在一张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线程数是否还能继续增加。

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

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

给我留言

留言无头像?