使用C++实现一个简易的线程池

在现代软件开发中,多线程编程已经成为提升程序性能的常见手段。无论是处理大量 I/O 请求的服务器,还是进行 CPU 密集型计算的应用,多线程都能显著提高吞吐量和响应速度。然而,直接频繁创建和销毁线程开销巨大,也容易导致资源浪费和管理复杂度的增加。

为了解决这个问题,线程池应运而生。它的核心思想是:提前创建固定数量的工作线程,并将任务提交到一个任务队列中,由线程池中的线程循环取任务执行。这样,不仅减少了线程创建和销毁的开销,也能方便地控制系统的并发度。

这篇博客记录了逐步实现一个简易版线程池的过程。

首先线程池的思想是创建预先创建一定数量的工作线程,它们循环等待任务队列中的任务并执行,从而避免频繁创建和销毁线程,实现资源复用,提高资源利用率和程序性能。所以我们首先需要一个vector<thread>的数组,还有一个任务队列。同时,由于线程池可能会被多个线程同时访问:工作线程需要取出任务队列中的任务,其他使用线程池的线程可能同时提交任务,所以还需要加一把互斥锁。上述还提到了其他线程会往线程池中提交任务,使得线程池中的工作线程可以取出任务队列中的任务并且执行,所以还需要一个公开的提交任务函数。基本架构如下:

C++ 复制代码
class ThreadPool{
public:
  ThreadPool(size_t);

  template<class F, class ...Args>
  auto enqueue(F&& f, Args&&... args) -> void;
  
  ~ThreadPool();

private:
  std::vector<std::thread> workers_;
  std::queue<std::function<void()>> tasks_;
  std::mutex mutex_;
};

这里解释一下任务队列为什么使用std::queue<std::function<void()>> tasks_,首先我们肯定需要一个统一的形式封装任务队列,这样就可以统一封装到stl容器中。而且我们期望工作线程执行时是可以直接拿来调用的,工作线程是线程池内部实现,不应该依赖于用户传入任务的参数这层外部抽象,所以参数列表为空。参数列表为空之后用户传入带有参数的函数还是可以执行的,注意我们的enqueue函数中接受了用户提供的任务函数和参数,在这个函数中会将二者进行std::bind,这样就把参数填入可调用对象了,所以工作线程取出任务时可以直接调用。至于用户需要返回值怎么办,这里先埋一个伏笔,先统一使用void表示无返回值,便于封装。

接下来我们实现构造函数:

C++ 复制代码
ThreadPool::ThreadPool(size_t nums){
    for(size_t i = 0; i < nums; i++){
        workers_.emplace_back([this]{
            while(true){
                std::function<void()> task;
                {
                    std::unique_lock<std::mutex> lock(this->mutex_);
                    this->condition.wait(lock, [this]{!this->tasks_.empty(); });
                    task = std::move(this->tasks_.front());
                    this->tasks_.pop();
                }
                task();
            }
        });
    }
}

构造函数的目的就是向std::vector<std::thread> workers_中填充工作线程,工作线程的逻辑如下:首先尝试获取锁,获取锁之后查看任务队列是否为空,如果不为空的话就取出元素执行,如果为空的话就释放锁进行下一轮循环。

下面我们从细节上具体说明,获取锁之后使用条件变量进行等待,wait(lock, [this]{!this->tasks_.empty(); }),这样当任务队列不为空时就可以不阻塞往下执行,如果为空的话,就会释放锁,并且将该线程添加到对应条件变量的阻塞队列中,之后如果enqueue时填充了任务就可以发signal唤醒阻塞于这个条件变量的一个线程。如果获取锁之后发现任务队列中有任务可以执行,那么就取出任务队列中的第一个任务执行,因为要保证调用顺序。

注意在emplace_back函数中直接调用了线程的构造函数,先在vector 尾部直接原地构造一个线程对象。构造线程对象时会立即创建一个新线程,在这个线程里执行传入的 lambda。所以在执行条件变量的wait时就已经有实际创建的线程对象了。

我们在构造函数中新使用了condition这个条件变量,所以应该在ThreadPool类的私有成员中添加对应变量。上述构造函数构造的线程函数是一个死循环,因此会一直等待,获取任务队列中的任务然后执行。我们还要给线程函数加上停止逻辑,不然主线程调用join的时候就会一直阻塞,因为此时线程函数中还没有返回逻辑,所以需要给线程函数添加返回的分支。

线程函数返回的时任务队列应该为空,因为从逻辑上讲我要把用户给我的任务全部完成,所以任务队列为空是检测是否可以返回的条件。如果只检测这个条件也是不可以的,因为可能线程池中其他线程已经把任务队列中的所有任务取出来执行完,此时任务队列也为空,但是用户还可能enqueue任务进去,所以此时线程池不应该关闭。此时我们需要一个标志位,在ThreadPool的析构函数中将这个标志位设置为true,此时用户也不需要使用线程池了,因为调用析构函数时肯定已经退出用户代码作用域了,所以应该使用这个标志位和任务队列是否为空检测。下面给出完善后代码:

C++ 复制代码
class ThreadPool{
    public:
    ThreadPool(size_t){
        stoped_ = false;
        for(size_t i = 0; i < nums; i++){
            workers_.emplace_back([this]{
                while(true){
                    std::function<void()> task;
                    {
                        std::unique_lock<std::mutex> lock(this->mutex_);
                        this->condition.wait(lock, [this]{!this->tasks_.empty(); });
                        if(this->stopd_ && this->tasks_.empty()){
                            return;
                        }
                        task = std::move(this->tasks_.front());
                        this->tasks_.pop();
                    }
                    task();
                }
            });
        }
    }

    template<class F, class ...Args>
        auto enqueue(F&& f, Args&&... args) -> void;

    ~ThreadPool();

    private:
    std::vector<std::thread> workers_;
    std::queue<std::function<void()>> tasks_;
    std::condition_variable condition;
    std::mutex mutex_;
    bool stoped_;
};

接下来是析构函数,析构函数应该是否线程池相关资源,循环调用join回收所有线程。

C++ 复制代码
inline ThreadPool::~ThreadPool()
{
  {
    std::unique_lock<std::mutex> lock(mutex_);
    stoped_ = true;
  }
  condition.notify_all();
  for(std::thread &worker: workers_)
    worker.join();
}

这里我们将stoped_设置为true之后会唤醒所有在该条件变量上阻塞的工作线程,但是条件变量的唤醒只有一个线程可以重新获取锁,这个获取锁的线程就可以继续执行线程函数的逻辑,判断队列是不是为空,为空的话就可以直接return了,因为此时任务队列不会再有任务进来,但是也只有这个线程会return,其他的线程没有获取到锁,继续阻塞在条件变量的阻塞队列上。因此我们要修改条件变量的wait条件,目标是调用析构之后所有的线程都可以感知到并且被唤醒,尽管一次只有一个线程可以获取锁。

将条件变量的wait逻辑修改为如下就好了:

C++ 复制代码
this->condition.wait(lock, [this]{this->stoped_ || !this->tasks_.empty(); });

最后我们来实现enqueue函数,这个函数的作用是添加新任务到任务队列,下面给出初版实现:

C++ 复制代码
template<class F, class ...Args>
    auto ThreadPool::enqueue(F&& f, Args&&... args) -> void{
    auto task = std::bind(std::forward<F>(f), std::forward<Args>(args)...);

    {
        std::unique_lock<std::mutex> lock(mutex_);
        if(stoped_){
            throw std::runtime_error("enqueue on stopped ThreadPool");
        }

        tasks_.emplace([task](){task()});
    }
    condition.notify_one();
}

这段代码就是将用户传入的可调用对象和参数绑定,这样工作线程取出来之后就可以直接调用,不需要再传参。再将这个任务放入任务队列,通知一个工作线程可以取出任务执行。

上述代码其实很粗糙,我们来逐步优化。

C++ 复制代码
tasks_.emplace([task](){task()});

这段将任务放入任务队列的代码,调用了拷贝构造函数,如果对象很大的话会影响性能,比如说一个可调用对象内部封装了大量变量,和上下文信息。所以我们应该使用移动或者指针的方式,这里不可以使用移动,因为参数F是通过引用传入的,移动意味着放弃所有权,假如用户声明了一个F,传参之后在后续用户代码想调用这个F时就会发生报错,因为此时F已经被移动了,用户代码失去了所有权。这里只可以使用指针来做,而且应该使用共享指针,这样可以确保用户代码拥有所有权。

C++ 复制代码
template<class F, class ...Args>
auto ThreadPool::enqueue(F&& f, Args&&... args) -> void{
  using TaskType = decltype(std::bind(std::forward<F>(f), std::forward<Args>(args)...));
  auto taskPtr = std::make_shared<TaskType>(
    std::bind(std::forward<F>(f), std::forward<Args>(args)...)
  );

  {
    std::unique_lock<std::mutex> lock(mutex_);
    if(stoped_){
      throw std::runtime_error("enqueue on stopped ThreadPool");
    }

    tasks_.emplace([taskPtr]() {
      (*taskPtr)();
    });
  }
  condition.notify_one();
}

注意我们使用智能指针时需要知道对象具体的类型,但是std::bind是一个函数模板,它会返回一个可调用对象,这里返回的可调用对象是一个未命名类型的对象,你不能写出它的确切类型名字(它不是 std::function),但它可以像函数一样调用。也就是说返回一个具有operator()的匿名类型。

因为这个匿名类型我们没法直接写出来,所以需要让编译器帮我们推导。

C++ 复制代码
decltype(std::bind(std::forward<F>(f), std::forward<Args>(args)...))

上述enqueue函数还有一个问题,就是它没有返回值,用户无法获得任务结果,也无法知道任务是否执行完成。因此我们需要一种机制,当用户调用enqueue函数时可以拿到一个句柄,当任务被工作线程执行完成后,用户检测这个句柄就会发现任务已经执行完成,然后获取返回值。这个机制其实就是futurepromise,下面我们来继续完善:

C++ 复制代码
template<class F, class ...Args>
auto ThreadPool::enqueue(F&& f, Args&&... args) -> std::future<std::invoke_result_t<F, Args...>>{
  using return_type = typename std::invoke_result_t<F, Args...>;

  auto taskPtr = std::make_shared<std::packaged_task<return_type()>>(
    std::bind(std::forward<F>(f), std::forward<Args>(args)...)
  );

  std::future<return_type> res = taskPtr->get_future();
  {
    std::unique_lock<std::mutex> lock(mutex_);
    if(stoped_){
      throw std::runtime_error("enqueue on stopped ThreadPool");
    }

    tasks_.emplace([taskPtr]() {
      (*taskPtr)();
    });
  }
  condition.notify_one();
  return res;
}

其中using return_type = std::invoke_result_t<F, Args...>;是推导调用 f(args...) 的返回类型,std::packaged_task<return_type()>用于把一个可调用对象封装成一个可以产生 std::future 的任务。其中return_type()表示一个无参数,返回类型为 return_type的任务,正好对应std::bind后的类型。

之后我们就可以调用taskPtr->get_future():取得与该 packaged_task 关联的 std::future,将这个future返回出去之后,调用者可以通过res.get()获取返回值,类似下面的调用:

C++ 复制代码
auto future = pool.enqueue([](int x){ return x * 2; }, 21);
int result = future.get(); // result == 42

至此,我们的简易线程池就完成了,完整代码可参考这个仓库,下面给出用户使用线程池的示例代码:

C++ 复制代码
int main()
{
    
    ThreadPool pool(4);
    std::vector< std::future<int> > results;

    for(int i = 0; i < 8; ++i) {
        results.emplace_back(
            pool.enqueue([i] {
                std::cout << "hello " << i << std::endl;
                std::this_thread::sleep_for(std::chrono::seconds(1));
                std::cout << "world " << i << std::endl;
                return i*i;
            })
        );
    }

    for(auto && result: results)
        std::cout << result.get() << ' ';
    std::cout << std::endl;
    
    return 0;
}
相关推荐
shark_chili2 小时前
基于魔改Nightingale源码浅谈go语言包模块管理
后端
回家路上绕了弯2 小时前
用户中心微服务设计指南:从功能到非功能的全维度落地
后端·微服务
Main121382 小时前
Java Duration 完全指南:高精度时间间隔处理的利器
后端
用户3459474113612 小时前
Android系统中HAL层开发实例
后端
undefined在掘金390412 小时前
第二节 Node.js 项目实践 - 使用 nvm 安装 Node.js
后端
小码编匠2 小时前
.NET 10 性能突破:持续优化才是质变关键
后端·c#·.net
Python私教2 小时前
Python可以爬取哪些公开金融数据
后端
SimonKing3 小时前
还在为HTML转PDF发愁?再介绍两款工具,为你保驾护航!
java·后端·程序员
创码小奇客3 小时前
Spring Boot依赖排坑指南:冲突、循环依赖全解析+实操方案
后端·面试·架构