首页 » 技术分享 » C++实现线程池

C++实现线程池

 

本文转载自:https://blog.csdn.net/caoshangpa/article/details/80374651

1、为什么需要线程池技术

    目前的大多数网络服务器,包括Web服务器、Email服务器以及数据库服务器等都具有一个共同点,就是单位时间内必须处理数目巨大的连接请求,但处理时间却相对较短。传统多线程方案中我们采用的服务器模型则是一旦接受到请求之后,即创建一个新的线程,由该线程执行任务。任务执行完毕后,线程退出,这就是是“即时创建,即时销毁”的策略。尽管与创建进程相比,创建线程的时间已经大大的缩短,但是如果提交给线程的任务是执行时间较短,而且执行次数极其频繁,那么服务器将处于不停的创建线程,销毁线程的状态。我们将传统方案中的线程执行过程分为三个过程:T1、T2、T3。
    T1:线程创建时间;
    T2:线程执行时间,包括线程的同步等时间;
    T3:线程销毁时间;
    那么我们可以看出,线程本身的开销所占的比例为(T1+T3) / (T1+T2+T3)。如果线程执行的时间很短的话,这比开销可能占到20%-50%左右。如果任务执行时间很长的话,这笔开销将是不可忽略的。
    除此之外,线程池能够减少创建的线程个数。通常线程池所允许的并发线程是有上界的,如果同时需要并发的线程数超过上界,那么一部分线程将会等待。而传统方案中,如果同时请求数目为2000,那么最坏情况下,系统可能需要产生2000个线程。尽管这不是一个很大的数目,但是也有部分机器可能达不到这种要求。
    因此线程池的出现正是着眼于减少线程本身带来的开销。线程池采用预创建的技术,在应用程序启动之后,将立即创建一定数量的线程(N1),放入空闲队列中。这些线程都是处于阻塞(Suspended)状态,不消耗CPU,但占用较小的内存空间。当任务到来后,缓冲池选择一个空闲线程,把任务传入此线程中运行。当N1个线程都在处理任务后,缓冲池自动创建一定数量的新线程,用于处理更多的任务。在任务执行完毕后线程也不退出,而是继续保持在池中等待下一次的任务。当系统比较空闲时,大部分线程都一直处于暂停状态,线程池自动销毁一部分线程,回收系统资源。基于这种预创建技术,线程池将线程创建和销毁本身所带来的开销分摊到了各个具体的任务上,执行次数越多,每个任务所分担到的线程本身开销则越小,不过我们另外可能需要考虑进去线程之间同步所带来的开销
     事实上,线程池并不是万能的。它有其特定的使用场合。线程池致力于减少线程本身的开销对应用所产生的影响,这是有前提的,前提就是线程本身开销与线程执行任务相比不可忽略。如果线程本身的开销相对于线程任务执行开销而言是可以忽略不计的,那么此时线程池所带来的好处是不明显的,比如对于FTP服务器以及Telnet服务器,通常传送文件的时间较长,开销较大,那么此时,我们采用线程池未必是理想的方法,我们可以选择“即时创建,即时销毁”的策略。
总之线程池通常适合下面的几个场合:

(1) 单位时间内处理任务频繁而且任务处理时间短;
(2) 对实时性要求较高。如果接受到任务后在创建线程,可能满足不了实时要求,因此必须采用线程池进行预创建。

2、代码实现(参考GitHub

    线程池的实现思想:“管理一个任务队列,一个线程队列,然后每次取一个任务分配给一个线程去做,循环往复。”

(1)头文件ThreadPool.h的内容如下

#ifndef THREAD_POOL_H
#define THREAD_POOL_H
 
#include <vector>
#include <queue>
#include <memory>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <future>
#include <functional>
#include <stdexcept>
 
class ThreadPool {

public:
    ThreadPool(size_t);                          //构造函数
    template<class F, class... Args>             //类模板
    auto enqueue(F&& f, Args&&... args) -> std::future<typename std::result_of<F(Args...)>::type>;//任务入队
    ~ThreadPool();                              //析构函数

private:
    std::vector< std::thread > workers;            //线程队列,每个元素为一个Thread对象
    std::queue< std::function<void()> > tasks;     //任务队列,每个元素为一个函数对象    

    std::mutex queue_mutex;                        //互斥量
    std::condition_variable condition;             //条件变量
    bool stop;                                     //停止
};
 
// 构造函数,把线程插入线程队列,插入时调用embrace_back(),用匿名函数lambda初始化Thread对象
inline ThreadPool::ThreadPool(size_t threads) : stop(false){

    for(size_t i = 0; i<threads; ++i)
        workers.emplace_back(
            [this]
            {
                for(;;)
                {
                    // task是一个函数类型,从任务队列接收任务
                    std::function<void()> task;  
                    {
                        //给互斥量加锁,锁对象生命周期结束后自动解锁
                        std::unique_lock<std::mutex> lock(this->queue_mutex);
                        
                        //(1)当匿名函数返回false时才阻塞线程,阻塞时自动释放锁。
                        //(2)当匿名函数返回true且受到通知时解阻塞,然后加锁。
                        this->condition.wait(lock,[this]{ return this->stop || !this->tasks.empty(); });
                       
                         if(this->stop && this->tasks.empty())
                            return;
                        
                        //从任务队列取出一个任务
                        task = std::move(this->tasks.front());
                        this->tasks.pop();
                    }                            // 自动解锁
                    task();                      // 执行这个任务
                }
            }
        );
}
 
// 添加新的任务到任务队列
template<class F, class... Args>
auto ThreadPool::enqueue(F&& f, Args&&... args) 
    -> std::future<typename std::result_of<F(Args...)>::type>
{
    // 获取函数返回值类型        
    using return_type = typename std::result_of<F(Args...)>::type;
 
    // 创建一个指向任务的只能指针
    auto task = std::make_shared< std::packaged_task<return_type()> >(
            std::bind(std::forward<F>(f), std::forward<Args>(args)...)
        );
        
    std::future<return_type> res = task->get_future();
    {
        std::unique_lock<std::mutex> lock(queue_mutex);  //加锁
        if(stop)
            throw std::runtime_error("enqueue on stopped ThreadPool");
 
        tasks.emplace([task](){ (*task)(); });          //把任务加入队列
    }                                                   //自动解锁
    condition.notify_one();                             //通知条件变量,唤醒一个线程
    return res;
}
 
// 析构函数,删除所有线程
inline ThreadPool::~ThreadPool()
{
    {
        std::unique_lock<std::mutex> lock(queue_mutex);
        stop = true;
    }
    condition.notify_all();
    for(std::thread &worker: workers)
        worker.join();
}
 
#endif

(2)基本使用方法

#include <iostream>
#include "ThreadPool.h"
 
int main()
{
    // create thread pool with 4 worker threads
    ThreadPool pool(4);
 
    // enqueue and store future
    auto result = pool.enqueue([](int answer) { return answer; }, 42);
 
    // get result from future, print 42
    std::cout << result.get() << std::endl; 
}

(3)另一个例子

#include <iostream>
#include "ThreadPool.h"
 
void func()
{
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    std::cout<<"worker thread ID:"<<std::this_thread::get_id()<<std::endl;
}
 
int main()
{
    ThreadPool pool(4);
    while(1)
    {
       pool.enqueue(fun);
    }
}

3、代码详细解释:

    首先我们看析构函数:inline ThreadPool::ThreadPool(size_t threads) : stop(false){},这个函数向线程队列worker插入threads个线程,每个线程对象用一个匿名函数lambda来初始化,每个匿名函数会一直循环着从任务队列中取出任务来执行。它首先对互斥量加锁,获得互斥锁后,调用条件变量的wait()函数等待条件发生,传入wait()函数的第二个参数为一个匿名函数lambda,当函数返回值为false时,wait会使得该线程阻塞(任务队列为空时会阻塞),并且对互斥量进行解锁。当函数返回值为true(任务队列不为空)并且收到条件变量的通知后,wait函数使线程解阻塞,并且对互斥量加锁。接着从任务队列里面弹出一个任务,对互斥量自动解锁,并执行这个任务。

    接着我们看添加任务的函数:auto ThreadPool::enqueue(F&& f, Args&&... args)-> std::future<typename std::result_of<F(Args...)>::type>{},这个函数向任务队列中加入一个任务。它首先创建一个名为task的智能指针,接着对互斥量加锁,然后把该任务加入到任务队列中,对互斥量自动解锁。调用条件变量的notify_one()函数,使得阻塞到这个条件变量的线程解阻塞。

    总结:初始化时,线程中的每个函数都会阻塞到条件变量那里,当任务队列中新加入一个任务时,通知阻塞到条件变量的某一个线程,接着这个线程执行:互斥量加锁——>任务队列出队——>互斥量解锁——>执行任务。当线程执行完任务之后,如果任务队列不为空,则继续从任务队列那里取出任务执行,如果任务队列为空则阻塞到条件变量那里。

  (1) std::result_of<func()>::type:获取func()函数返回值的类型;

  (2) typename:作用是告诉c++编译器,typename后面的字符串为一个类型名称,而不是成员函数或者成员变量;

  (3) 匿名函数lambda: 允许incline函数的定义被当做一个参数,用法为:[]()->type{},中括号内指定了传递方式(值传递或引用传递),小括号内指明函数的形参,->type指定了返回值类型,花括号内指定了函数体。

  (4) std::funtion<void()> :声明一个函数类型,接收任意原型是void()的函数、或函数对象、或是匿名函数。void() 意思是不带参数,没有返回值。

  (5) std::unique_lock<> : C++多线程编程中通常会对共享的数据进行写保护,以防止多线程在对共享数据成员进行读写时造成资源争抢导致程序出现未定义的行为。通常的做法是在修改共享数据成员的时候进行加锁--mutex。在使用锁的时候通常是在对共享数据进行修改之前进行lock操作,在写完之后再进行unlock操作,进场会出现由于疏忽导致由于lock之后在离开共享成员操作区域时忘记unlock,导致死锁。针对以上的问题,C++11中引入了std::unique_lock与std::lock_guard两种数据结构。通过对lock和unlock进行一次薄的封装,实现对象生命周期结束后自动解锁的功能,参考。用法如下:
    1)新建一个unique_lock对象 :std::mutex mymutex; 
    2)给对象传入一个std::mutex 对象作为参数:unique_lock lock(mymutex);

  (6) std::condition_variable:当 std::condition_variable 对象的某个 wait 函数被调用的时候,它使用 std::unique_lock(通过 std::mutex) 来锁住当前线程。当前线程会一直被阻塞,直到另外一个线程在相同的 std::condition_variable 对象上调用了 notification 函数来唤醒当前线程。
    std::condition_variable::wait() :std::condition_variable提供了两种 wait() 函数:
    1) 第一种情况:void wait (unique_lock<mutex>& lock);当前线程调用 wait() 后将被阻塞(此时当前线程应该获得了锁(mutex),不妨设获得锁 lock),直到另外某个线程调用 notify_* 唤醒了当前线程。在线程被阻塞时,该函数会自动调用 lock.unlock() 释放锁,使得其他被阻塞在锁竞争上的线程得以继续执行。另外,一旦当前线程获得通知(notified,通常是另外某个线程调用 notify_* 唤醒了当前线程),wait() 函数也是自动调用 lock.lock(),使得 lock 的状态和 wait 函数被调用时相同。
   2) 第二种情况:void wait (unique_lock<mutex>& lock, Predicate pred)(即设置了 Predicate),只有当 pred 条件为 false 时调用 wait() 才会阻塞当前线程,并且在收到其他线程的通知后只有当 pred 为 true 时才会被解除阻塞。
   std::condition_variable::notify_one() :唤醒某个等待(wait)线程。如果当前没有等待线程,则该函数什么也不做,如果同时存在多个等待线程,则唤醒某个线程是不确定的;

  (7) using return_type = typename xxx :指定别名,和typedef的作用类似;

  (8) std::make_shared<type> : 创建智能指针,需要传入类型;

  (9) std::future<> : 可以用来获取异步任务的结果,因此可以把它当成一种简单的线程间同步的手段;

  (10) std::packaged_task<> : std::packaged_task 包装一个可调用的对象,并且允许异步获取该可调用对象产生的结果,从包装可调用对象意义上来讲,std::packaged_task 与 std::function 类似,只不过 std::packaged_task 将其包装的可调用对象的执行结果传递给一个 std::future 对象(该对象通常在另外一个线程中获取 std::packaged_task 任务的执行结果);

  (11) std::bind() : bind的思想实际上是一种延迟计算的思想,将可调用对象保存起来,然后在需要的时候再调用。而且这种绑定是非常灵活的,不论是普通函数、函数对象、还是成员函数都可以绑定,而且其参数可以支持占位符,比如你可以这样绑定一个二元函数auto f = bind(&func, _1, _2);,调用的时候通过f(1,2)实现调用;

  (12) std::forward<F> : 左值与右值的概念其实在C++0x中就有了。概括的讲,凡是能够取地址的可以称之为左值,反之称之为右值,C++中并没有对左值和右值给出明确的定义,从其解决手段来看类似上面的定义,当然我们还可以定义为:有名字的对象为左值,没有名字的对象为右值。std :: forward有一个用例:将模板函数参数(函数内部)转换为调用者用来传递它的值类别(lvalue或rvalue)。这允许右值参数作为右值传递,左值作为左值传递,一种称为“完全转发”的方案;

  (13) std::packged_task::get_future: 获取与共享状态相关联的 std::future 对象。在调用该函数之后,两个对象共享相同的共享状态。

参考:https://www.cnblogs.com/haippy/p/3284540.html

转载自原文链接, 如需删除请联系管理员。

原文链接:C++实现线程池,转载请注明来源!

0