基于 modern C++ 实现的线程池

98 阅读4分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第12天,点击查看活动详情

为什么需要线程池?

线程池,是一个多线程程序组成部分之一

对于一个程序,我们想尽可能多的利用多核的性能

这个时候我们就需要多线程或者多进程来压榨CPU的性能了

但是多进程通信代价属实更大,所以,多线程有时候是更好的选择!

我们设想这样一个场景 一主多从

我们有一个主线程负责事件分发处理,把事件通过队列拿给工作队列

工作队列负责处理逻辑,主线程则只关心事件分发

stateDiagram-v2
request --> master
master --> request

master --> worker1
worker1 --> master

master --> worker2
worker2 --> master

master --> worker3
worker3 --> master

worker --> task
task --> worker

而任务队列在其中起到的作用就是任务的调度

graph TD
event --> task_queue
task_queue --> worker1
task_queue --> worker2
task_queue --> worker3

这就是线程池的基本逻辑

若是我们对每一个task开一个线程,这样固然是可以达到目的

可是这样的开销我们经受不起,因为创建和消耗线程是需要时间和内存的。

并且若是线程数过多以后,则还可能导致OOM kill 掉你的进程

线程池的实现

为了减少心智负担,我直接采用了许多C++ 标准库中的许多东西,有如下内容:

  • std::vector
  • std::queue
  • std::function
  • std::condition_variable
  • std::mutex
  • std::thread
  • std::future

线程池需要一个容器来存放我们的线程对象,用于回收线程池资源

std::vector<std::thread> worker_;

线程池需要一个队列,用来存放我们的执行任务

using FUNC = std::function<void()>;
std::queue<FUNC> que_;

线程池需要锁,同时,我们希望锁占用时,线程睡眠等待,同时,执行完成后我们需要回收线程资源

bool stop_;
std::mutex mtx_;
std::condition_variable cond_;

首先,在构造函数中将线程先创建好,这是一种池式组件的思想

在这里,采用了RAII的手法,来控制锁

explicit thread_pool(size_t thread_count) : stop_(false) {
  for (int i = 0; i < thread_count; ++i) {
    worker_.emplace_back([this]() {
      while (true) {
        FUNC task;
        {
          std::unique_lock<std::mutex> lock(this->mtx_);
          this->cond_.wait(
              lock, [this]() { return this->stop_ || !this->que_.empty(); });
          if (this->stop_ && this->que_.empty())
            return;
          task = std::move(que_.front());
          que_.pop();
        }
        task();
      }
    });
  }
}

其次,我们需要将其析构函数释放资源的部分写好

回收资源比较简单,只需要把stop开关开启,同时等待任务队列消费完毕,就可以了

~thread_pool() {
  {
    std::lock_guard<std::mutex> lock(mtx_);
    stop_ = true;
  }
  cond_.notify_all();
  for (auto &v : worker_)
    v.join();
}

最后,构造析构都写了,还剩下一个添加任务的接口

void add_task(FUNC &&func) {
  std::lock_guard<std::mutex> lock(mtx_);
  que_.push(func);
  cond_.notify_one();
}

至此,我们的线程池就简单写完了,不过,这个线程池是一个极为简单的,接口简单的线程池

因为我们只能传给它一个 void()的函数,不能够传参数,就很不友好

虽然我们调用时,可以使用lambda来构造好我们传入的参数。

但是,我们应该寻求一个更加自然的方式传入参数

接下来,我们来看一下,如何改进我们的线程池

线程池改进

上文提到,改进点显然在我们的add_task

如何支持多参数呢?

少不了函数重载,那么一个一个写显得我很蠢,我们当然要用到可变长传参了!

需要用到的技术

  • 我们需要接受函数对象和参数 可变长传参 实现
  • 我们需要推导出函数的返回值 decltype 实现
  • 我们需要拿到返回值 异步拿返回值 future实现

只需要用一点点完美转发和模板编程,就可以轻松实现我们的匹配任意函数的需求

template <typename F, typename... Arg>
auto submit(F &&func, Arg &&...arg) -> std::future<decltype(func(arg...))> {
  using ret = decltype(func(arg...));
  auto fun = [fn = std::forward<F>(func),
              ... pack = std::forward<Arg>(arg)]() {
    return fn(pack...);
  }; // c++ 14 can capture move
  auto task = std::make_shared<std::packaged_task<ret()>>(fun);
  add_task([task]() { (*task)(); });
  return task->get_future();
}

完整代码

class thread_pool {
public:
  using FUNC = std::function<void()>;
  explicit thread_pool(size_t thread_count) : stop_(false) {
    for (int i = 0; i < thread_count; ++i) {
      worker_.emplace_back([this]() {
        while (true) {
          FUNC task;
          {
            std::unique_lock<std::mutex> lock(this->mtx_);
            this->cond_.wait(
                lock, [this]() { return this->stop_ || !this->que_.empty(); });
            if (this->stop_ && this->que_.empty())
              return;
            task = std::move(que_.front());
            que_.pop();
          }
          task();
        }
      });
    }
  }
  template <typename F, typename... Arg>
  auto submit(F &&func, Arg &&...arg) -> std::future<decltype(func(arg...))> {
    using ret = decltype(func(arg...));
    auto fun = [fn = std::forward<F>(func),
                ... pack = std::forward<Arg>(arg)]() {
      return fn(pack...);
    }; // c++ 14 can capture move
    auto task = std::make_shared<std::packaged_task<ret()>>(fun);
    add_task([task]() { (*task)(); });
    return task->get_future();
  }

  ~thread_pool() {
    {
      std::lock_guard<std::mutex> lock(mtx_);
      stop_ = true;
    }
    cond_.notify_all();
    for (auto &v : worker_)
      v.join();
  }

private:
  void add_task(FUNC &&func) {
    std::lock_guard<std::mutex> lock(mtx_);
    que_.push(func);
    cond_.notify_one();
  }
  
  bool stop_;
  std::mutex mtx_;
  std::condition_variable cond_;
  std::queue<FUNC> que_;
  std::vector<std::thread> worker_;
};

总结

其实本质上来说,线程池做的事情就是一个生产者、消费者模型

没错,就是你在操作系统书上所看见的那个,最基本的,最实在的模型

我们这个东西究其本质不过是一个 多生产者、多消费者模式

而其用途呢?

其实还是比较多的,特别是高吞吐的情况下,处理逻辑尽可能的让cpu吃满,这个时候,线程池这个组件就有用武之地了

小技巧:分配线程池的大小和 cpu的核数有关,具体分配多少根据实际情况调优