互斥锁和条件变量实战:万字拆解基于c++11实现支持Fixed&&Cached模式线程池

 本文给出一个通用线程池的设计方案,并编码实现;最后列举两个在编码过程中遇到的问题,并给出解决方案;线程池源码可移步https://github.com/fridaiy/threadpool

一.线程池设计

既然是线程池,创建线程池的目的就是为了完成用户提交的任务,因此线程池中需要维护一组线程,用于完成用户提交的任务;还要维护一个任务队列,用于存储用户提交的任务。

线程同步:每个线程都去同一个任务队列中获取任务,因此任务队列的访问应该是互斥的,同时如果任务队列为空,工作线程应当休眠;当任务队列中有任务时,应该唤醒工作线程执行任务,这里涉及到了线程同步

任务提交:线程池应该为用户提供一个提交任务的接口,能接收用户传入的任意类型的任务函数和任意个数的参数,并且获取返回值;这里同样涉及到了线程同步,即当用户获取返回值时,必须保证任务已经被处理完毕,如果任务还没完成,用户获取返回值的操作应该阻塞等待。

线程创建和回收:线程池可以支持fixed和cached模式,对于fixed模式,池中线程个数在线程池创建之初就已经确定;但是对于cached模式,当线程池中待处理任务个数大于现有空闲线程个数时,应当动态的创建线程;同时线程池创建的线程个数应该是有上限的,并且当线程池中任务数量小于空闲线程个数,导致线程长时间获取不到任务,应该将线程回收,只保持初始的线程个数(要有一个下限),这里有涉及到了线程回收问题。

线程池析构处理:当线程池对象析构时,应该等待未完成的任务处理完毕,并且只有线程全部回收时,才能将线程池对象析构,这里同样涉及到了线程同步。

二.技术要点

针对上述分析的问题,对使用到的c++11的一些特性进行列举:

线程同步:使用c++11的智能锁unique_lock条件变量conditional_variable

提交任务接口:为了能够接收任意个数的参数可使用可变参函数模板;为了异步获取任务的返回值使用c++11提供的packaged_task函数模板配合bind绑定器封装任务;配合decltype类型推导和函数返回值类型后置,在函数声明时推迟指定返回类型,能够返回任意类型的返回值。

线程回收:线程回收的关键是将线程由阻塞态唤醒,然后检查状态;对于阻塞在条件变量上的线程有两种方式:1.其他线程显式执行cv.notify2.cv.wait_for函数设置一个超时时间,如果时间内没有被唤醒的话,自己将自己唤醒。这里采用第二种方式。

三、编码实现

3.1准备工作

3.1.1创建枚举类

标记线程池模式。

enum class Mode{
    MODE_FIXED,
    MODE_CACHED
};

3.1.2封装Thread类

c++提供的thread类,在创建对象时直接启动一个线程,不太灵活;可以将创建对象操作包装在Thread类的start方法中,当手动调用start方法时,创建thread对象进而启动线程;并且可以为Thread类绑定一个Id便于线程回收操作。

class Thread{
public:
    using ThreadFunc=std::function<void(int)>;
    Thread(ThreadFunc func)
            :threadId_(baseId_++),
             func_(func){}
    ~Thread()=default;
    void start(){
        std::thread t(func_,threadId_);
        t.detach();
    }
    int getId() const{
        return threadId_;
    }
private:
    static uint32_t baseId_;
    uint32_t threadId_;
    ThreadFunc func_;
};

3.1.3ThreadPool类核心成员变量

3.1.3.1线程池模式

结合前面的枚举类用于设置线程池的工作模式

Mode mode_;
3.1.3.2线程池任务队列和线程容器

线程池要维护一个任务队列和一组线程;任务队列可直接使用queue;值得注意的是用户的各个任务肯定不可能是同一个类型;返回值类型不确定,参数的个数和类型也不能确定,而queue队列肯定只能存储一个类型的函数对象,这里采用的解决方案是,queue队列中只存储返回值为void参数列表为空的函数对象,将原始任务函数对象使用绑定器绑定为无参的函数,然后再用一个返回值为void参数列表为空函数对象对其进行包装,这样一来任何类型的函数我们都能包装成返回值类型为void ,参数列表为空的函数,最后将这个包装完的任务函数传入到队列即可。

using TaskFunc=std::function<void()>;
std::queue<TaskFunc> tasksQue_;

管理线程可使用map;key为前面说过的线程id,value为指向线程的指针;在回收线程时可直接根据key进行删除;更进一步,我们不需要维护线程的有序性,可直接采用unordered_map无序map,底层采用的是哈希表,查找速度会更快。

std::unordered_map<int,std::unique_ptr<Thread>> threads_;
3.1.3.3线程同步相关

结合前面的分析,不难得出,当线程池启动时,会有多个线程并行访问任务队列taskQue;并且queue不是线程安全的;因此要创建一个互斥锁,在访问任务队列之前首先进行加锁操作,保证各个线程对任务队列的访问是互斥的;同时应该设置两个条件变量,notEmpty_:当任务队列中没任务时,工作线程需要将自己阻塞,直到任务队列中有任务时再将工作线程唤醒;notFull_:当任务队列已满时,提交任务的线程应该阻塞等待,当任务队列不满时唤醒阻塞提交任务的线程

std::mutex mtxQue_;
//任务队列中有任务时 唤醒线程执行任务
std::condition_variable notEmpty_;
//任务队列不满时用于唤醒线程提交任务
std::condition_variable notFull_;
3.1.3.4线程池和任务队列相关

围绕着threads_和taskQue_两大属性,不难得出,线程池需要设置一个初始线程个数(对于fixed模式就是线程池中线程个数,对于cached模式就是线程池中线程个数下限),线程个数上限(cached模式才有用),线程池中实际线程个数(cached模式才有用),以及空闲线程数量,即没有在执行任务的线程。

同时任务队列需要有最大的任务数量和和待处理的任务数量,结合空闲线程数量可用于计算cached模式下应该创建的线程数量。

//线程池初始线程数量 fixed模式:池中线程数量  cached模式:池中线程数量下限
uint32_t initThreadSize_;
//线程池中实时实际线程数量
std::atomic_uint32_t threadSize_;
//专用于cached模式下 创建线程数量的上限
uint32_t threadSizeThreshold_;
//池中空闲线程数量
std::atomic_uint32_t idleThreadSize_;

//待处理的任务数量
std::atomic_uint32_t taskCount_;
//任务数量最大值
uint32_t taskSizeThreshold_;
3.1.3.5 线程池回收资源相关

需要标记线程池的运行状态,和一个条件变量,当线程全部退出时通知主线程。

//线程池状态
std::atomic_bool started_;
//保证线程池析构时所有线程均退出(且任务都执行完毕)的条件变量
std::condition_variable threadsEmpty_;
3.1.3.6线程池代码框架 

经过前面的分析,我们已经知道线程池要有哪些成员变量;除了构造和析构以外,还需要提供一个提交任务的接口,开启线程池的接口,以及为线程提供一个线程函数,最终代码框架如下。

class ThreadPool{
public:
    /*  构造 负责初始化线程池状态
     *  ThreadPool();
     *  析构 负责回收线程 确保线程池中所有线程都结束
     * ~ThreadPool();
     *  提交任务
     *  submit();
     *  开启线程池
     *  start();
     *  线程函数 
     *  threadFunc();
     *  获取当前时间 单位秒
     *  getCurrentSecond();
     */
private:
    //线程池模式
    Mode mode_;
    //线程池状态
    std::atomic_bool started_;
    //管理线程池创建的线程
    std::unordered_map<int,std::unique_ptr<Thread>> threads_;
    //保证线程池析构时所有线程均退出(且任务都执行完毕)的条件变量
    std::condition_variable threadsEmpty_;
    //线程池初始线程数量 fixed模式:池中线程数量  cached模式:池中线程数量下限
    uint32_t initThreadSize_;
    //线程池中实时实际线程数量
    std::atomic_uint32_t threadSize_;
    //专用于cached模式下 创建线程数量的上限
    uint32_t threadSizeThreshold_;
    //池中空闲线程数量
    std::atomic_uint32_t idleThreadSize_;

    using TaskFunc=std::function<void()>;
    //任务队列
    std::queue<TaskFunc> tasksQue_;
    //待处理的任务数量
    std::atomic_uint32_t taskCount_;
    //任务数量最大值
    uint32_t taskSizeThreshold_;
    //任务队列的互斥锁
    std::mutex mtxQue_;
    //任务队列中有任务时 唤醒线程执行任务
    std::condition_variable notEmpty_;
    //任务队列不满时用于唤醒线程提交任务
    std::condition_variable notFull_;

};

3.2成员函数实现 

3.2.1构造函数

按照上面分析过的,结合成员变量的含义初始化即可;最好不要出现0和1以外的数字,若有,可以定义成常量。构造函数示例如下。

ThreadPool(uint32_t size=DEFAULT_SIZE,Mode mode=Mode::MODE_FIXED,
               uint32_t threadSizeThreshold=THREAD_SIZE_THRESHOLD,uint32_t taskSizeThreashold=TASK_SIZE_THRESHOLD):
            threadSizeThreshold_(threadSizeThreshold),
            taskSizeThreshold_(taskSizeThreashold),
            taskCount_(0),
            mode_(mode),
            started_(false),
            idleThreadSize_(0),
            threadSize_(0){
  
        if(size>0){
            if(size<threadSizeThreshold_){
                initThreadSize_=size;
            }else{
                initThreadSize_=DEFAULT_SIZE;
            }
        }else{
            throw "threadpool create exception";
        }
    }

3.2.2getCurrentSecond

获取当前时间,可使用c++11提供的chrono,单位秒;可用于cached模式下空闲线程的回收,代码如下。

int getCurrentSecond(){
   
        auto now=std::chrono::system_clock::now();
        auto duration = std::chrono::duration_cast<std::chrono::seconds>(now.time_since_epoch());
        return static_cast<int>(duration.count());
    }

3.2.3threadFunc

首先只实现fixed模式下的线程函数,再实现cached模式,最后再加入线程池结束时回收线程的逻辑得到最终版。

3.2.3.1只支持fixed模式的线程函数
  • 线程函数需要写在一个循环里,不能只执行完一个任务,线程就结束,还应该继续获取下个任务;
  • 任务队列中任务个数为0时,就应该阻塞等待,并释放锁,等待一段时间后苏醒,获取锁之后再次检查while的条件直到任务队列中有任务(wait_for的行为在上篇文章中已做了具体的讲解在此不在赘述);
  • 获取任务之后修改相应的变量的值:空闲线程数量减1、任务数量减1、从任务队列中弹出任务,此时若任务队列中还有任务(不空),使用条件变量notEmpty唤醒其他(由于任务队列为空)而阻塞在notEmpty(获取任务)的线程,如果此时任务列队不满的话(不满),使用条件变量notFull唤醒其他(由于任务队列已满)而阻塞在notFull(提交任务)的线程
  • 值得注意的是,获取完任务(多线程访问完共享资源)应该尽快将锁释放,让其他线程尽快的获取锁,进而尽快的处理任务,这里使用的是智能锁unique_lock出作用域会自动释放,可以采用显式加作用域的方式控制临界区大小;
  • 执行任务函数的操作不要在加锁范围内执行,若获取任务并执行任务再释放锁,各个工作线程直接变成了串行执行任务,违背了线程池初衷。代码如下。
void threadFunc(){
        while(true){
            TaskFunc task;
            {
                std::unique_lock<std::mutex> lck(mtxQue_);
                while (taskCount_==0){
                     notEmpty_.wait_for(lck,std::chrono::seconds(1));   
                }
                idleThreadSize_--;
                taskCount_--;
                task=tasksQue_.front();
                tasksQue_.pop();
                if(taskCount_>0){
                    notEmpty_.notify_all();
                }
                notFull_.notify_all();
            }
            if(task){
                task();
                idleThreadSize_++;
            }
        }
    }
3.2.3.2支持fixed模式和cached模式的线程函数 
  • cached模式下,待处理任务数量大于空余线程数量时,需要动态创建工作线程,创建线程交给主线程中submit()完成;
  • 线程函数本身负责线程回收,当一个工作线程发现自己空闲时间已经超过了预设时间,就需要回收线程,回收线程涉及到了两个操作:1.结束线程,2.在线程池threads_中移除对应的Thread对象
  • 结束线程:只需要将线程函数结束线程也随之结束。
  • threads_中移除对应的Thread对象:在无序map中key存储的是我们生成的线程id,value存储的是指向Thread对象的unique_ptr,线程执行线程函数时,同时将线程id传入,可直接根据key进行删除,由于存储的是unique_ptr,指针被移除时相应的Thread对象也会析构。
  • 怎样获知当前线程空闲时间是否超过了预设时间?线程如果采用获取不到任务就一直阻塞的策略(比如直接使用cv.wait(lck))那么该线程只能等待外部其他线程显式执行cv.notify操作被动唤醒。那么现在似乎陷入了一个悖论,假设我的最大空闲时间设置为5s,而现在某个线程一直阻塞,假设已经阻塞等待了10s(也就是空闲了10s,10s没有获取到任务了),显然应该被回收(假设现在线程池中线程个数大于线程数量下限),但是由于该线程处于阻塞态,无法检查自己是否空闲时间超时,进而不能回收自己;如果现在被其他线程使用notEmpty唤醒,说明已经有任务了,该线程被唤醒,发现刚才的空闲时间已经超过了预设的空闲时间,可是该线程被唤醒正是因为有任务将要执行,即使刚才的空闲时间超过了预设时间,也不应该回收该线程,这样一来永远也不能释放超时的空闲线程。
  • 故采用cv.wait_for(lck,timeout)不让线程一直阻塞,而是设置一个超时时间,根据返回值判断是超时返回还是有任务被唤醒,如果是超时返回,检查距离上次执行任务的时间,若空闲时间超过预设的空闲时间,且当前池中线程数大于线程池中线程下限时(需要回收多余线程),在threads_移除Thread,修改线程数量和空闲线程数量,最后将线程函数返回即完成了回收线程操作。
  • 最后注意cv.wait_for()设置的超时时间一定要小于预设的最大空闲时间,这样可以保证超过预设空闲时间的线程可以尽快回收。
  • 代码如下。
void threadFunc(int threadIdInPool){
        size_t timestamp;
        if(mode_==Mode::MODE_CACHED){
            timestamp=getCurrentSecond();
        }
        while(true){
            TaskFunc task;
            {
                std::unique_lock<std::mutex> lck(mtxQue_);
                while (taskCount_==0){
                    if(mode_==Mode::MODE_CACHED){
                        if(std::cv_status::timeout==notEmpty_.wait_for(lck,std::chrono::seconds(1))){
                            size_t currentTime=getCurrentSecond();
                            if(currentTime-timestamp>=MAX_IDLE_THREAAD_WAIT_TIME&&threadSize_>initThreadSize_){
                                threads_.erase(threadIdInPool);
                                threadSize_--;
                                idleThreadSize_--;
                                return;
                            }
                        }
                    }else{
                        notEmpty_.wait_for(lck,std::chrono::seconds(1));
                    }
                }
                idleThreadSize_--;
                taskCount_--;
                task=tasksQue_.front();
                tasksQue_.pop();
                if(taskCount_>0){
                    notEmpty_.notify_all();
                }
                notFull_.notify_all();
            }
            if(task){
                task();
                idleThreadSize_++;
            }
            if(mode_==Mode::MODE_CACHED){
                timestamp=getCurrentSecond();
            }
        }
    }
3.2.3.3线程池析构(关闭)时所有线程能自动回收的线程函数 
  • 线程池开始析构时,首先将started_设置为false,表示线程池已经关闭,然后执行threadsEmpty_.wait(),必须等待所有线程回收以后,析构函数才能向下执行
  • 如果线程池析构时还有任务没有结束,执行完任务以后再回收线程;每次执行完任务以后,以及等待条件变量超时返回时,都要检查started_的状态,若为false,进行线程回收,最后一个线程释放之前要threadsEmpty_.notify唤醒析构线程池的线程,表示所有线程已经释放完毕。代码如下。
void threadFunc(int threadIdInPool){
        size_t timestamp;
        if(mode_==Mode::MODE_CACHED){
            timestamp=getCurrentSecond();
        }
        while(true){//线程得一直执行任务
            TaskFunc task;
            {
                std::unique_lock<std::mutex> lck(mtxQue_);
                while (taskCount_==0){
                    if(mode_==Mode::MODE_CACHED){
                        if(std::cv_status::timeout==notEmpty_.wait_for(lck,std::chrono::seconds(1))){
                            if(!started_){
                                if(taskCount_==0){
                                    threadSize_--;
                                    idleThreadSize_--;
                                    threadsEmpty_.notify_all();
                                    return;
                                }
                            }
                            size_t currentTime=getCurrentSecond();
                            if(currentTime-timestamp>=MAX_IDLE_THREAAD_WAIT_TIME&&threadSize_>initThreadSize_){
                                threads_.erase(threadIdInPool);
                                threadSize_--;
                                idleThreadSize_--;
                                return;
                            }
                        }
                    }else{
                        if(std::cv_status::timeout==notEmpty_.wait_for(lck,std::chrono::seconds(1))){
                            if(!started_){
                                threadSize_--;
                                idleThreadSize_--;
                                if(threadSize_==0){
                                    threadsEmpty_.notify_all();
                                }
                                return;
                            }
                        }
                    }
                }
                idleThreadSize_--;
                taskCount_--;
                task=tasksQue_.front();
                tasksQue_.pop();
                if(taskCount_>0){
                    notEmpty_.notify_all();
                }
                notFull_.notify_all();
            }
            if(task){
                task();
                idleThreadSize_++;
            }
            if(!started_){
                if(taskCount_==0){
                    threadSize_--;
                    idleThreadSize_--;
                    threadsEmpty_.notify_all();
                    return;
                }
            }
            if(mode_==Mode::MODE_CACHED){
                timestamp=getCurrentSecond();
            }
        }
    }

3.2.4start 

根据初始设置的线程个数,创建Thread对象,启动线程。使用绑定器将ThreadPool中的线程函数传给Thread,预留一个参数,用于后续传线程id,注意unique_ptr没有左值引用的拷贝和赋值,这里需要将ptr转换成右值,进而匹配emlpace方法的右值版本

void start(){
        started_=true;
        for(int i=0;i<initThreadSize_;++i){
            auto ptr=std::make_unique<Thread>(std::bind(&ThreadPool::threadFunc,this,std::placeholders::_1));
            threads_.emplace(ptr->getId(),std::move(ptr));
            threadSize_++;
        }
        for (int i = 0; i < initThreadSize_; i++){
            threads_[i]->start();
            idleThreadSize_++;
        }
}

3.2.5submit

  • 用户使用线程池提交任务时,我们期待像使用c++11提供的thread一样,只需要传递一个任务函数,然后传入该任务函数所需要的参数,传参的数量由任务函数决定,可由可变参函数模板实现;
  • 如何异步获取任务返回值,我们可以直接使用c++11提供的packaged_task函数模板,packaged_task本质也是一个函数对象,但是它可以获取一个std::future<T>类型的返回值,并且已经做了同步操作,当用户获取返回值时,若任务还没有执行完,会阻塞用户获取返回值的操作直到任务执行完毕
  • submit需要返回一个std::future<T>类型,由于T不确定可以使用类型后置声明,配合decltype延迟推导出函数的返回值类型
  • 提交任务时,要考虑任务队列满的情况,如果任务队列已满,就要阻塞提交任务的操作,等待一个超时时间,如果任务队列还满,则告知用户任务提交失败;
  • 提交任务时,任务队列不满时,根据用户传入的任务函数和参数,创建一个packaged_task对象,同时使用绑定器将参数绑定,此时packaged_task对象是一个Rtype()类型的函数对象,而任务队列里要提交的是void()类型的对象,可以加一个中间层,提交任务时提交一个void()类型的函数,在这个函数中调用封装好的package_task类型的任务函数
  • 如果于cached模式下,如果此时待处理任务数量大于空闲线程数量,要创建线程,但是创建线程的个数不能超过线程池设置的线程个数的上限
  • 最后notify唤醒,阻塞在获取任务的线程。
template<typename Func,typename... Args>
    auto submit(Func&& func,Args&&... args)-> std::future<decltype(func(args...))>{
        /*
         * 向线程池中提交任务,并获取任务的返回值
         * 如果如果任务队列已满,等待一个超时时间
         * 若在这段时间内,任务队列仍然为满,任务提交失败,向用户返回一个期待类型的空值
         *
         * 当线程池处于cached模式下
         * 任务数量大于空闲线程数量时,一次性创建出所需要的线程
         */
        using RType= decltype(func(args...));
        std::shared_ptr<std::packaged_task<RType()>> task=std::make_shared<std::packaged_task<RType()>>(std::bind(std::forward<Func>(func),std::forward<Args>(args)...));
        std::future<RType> res=task->get_future();
        std::unique_lock<std::mutex> lck(mtxQue_);
        if(!notFull_.wait_for(lck,std::chrono::seconds(SUBMIT_TASK_TIME_OUT),[&]()->bool{
            return taskCount_<taskSizeThreshold_;})){
            std::cerr<<"task submit fail"<<std::endl;
            std::packaged_task<RType()> failTask([]()->RType{ return RType();});
            failTask();
            return failTask.get_future();
        }
        taskCount_++;
        tasksQue_.emplace([task]()->void{(*task)();});

        if(mode_==Mode::MODE_CACHED&&idleThreadSize_<taskCount_){
            for (int i = 0; i < std::min(taskCount_.load()-idleThreadSize_.load(),threadSizeThreshold_-threadSize_.load()); ++i) {
                auto ptr=std::make_unique<Thread>(std::bind(&ThreadPool::threadFunc,this,std::placeholders::_1));
                int threadId=ptr->getId();
                threads_.emplace(threadId,std::move(ptr));
                threads_[threadId]->start();
                idleThreadSize_++;
                threadSize_++;
            }
        }
        //出作用域释放锁 那就晚点notify 避免线程过早苏醒
        notEmpty_.notify_all();
        return res;
    }

3.2.6 ~ThreadPool

线程池对象析构时,需要保证所有任务已经执行完毕,且线程都退出。代码如下:

~ThreadPool(){
        started_= false;
        threads_.clear();
        std::unique_lock<std::mutex> lck(mtxQue_);
        threadsEmpty_.wait(lck,[&]()->bool{return threadSize_==0;});
    }

至此线程池核心代码已经完成,查看完整代码可移步https://github.com/fridaiy/threadpool

四、错误处理 

4.1构造function时传入不可拷贝的函数对象导致编译失败

4.1.1版本一 

首先看一下初始版本的submit方法 ,先不考虑提交失败以及cached模式创建线程的逻辑;使用bind结合用户传入的任务函数,在submit的函数栈上创建了一个task对象,使用lambda表达式(为什么需要加一次lambda,目的是为了将返回值不确定的任务函数,一律转换成返回值为void参数列表为空的函数,便于taskQue统一存储)引用捕获task,然后传入taskQue任务队列。

template<typename Func,typename... Args>
    auto submit(Func&& func,Args&&... args)-> std::future<decltype(func(args...))>{

        using RType= decltype(func(args...));

        std::packaged_task<RType()>task=std::packaged_task<RType()>(std::bind(std::forward<Func>(func),std::forward<Args>(args)...));
        std::future<RType> res=task.get_future();

        std::unique_lock<std::mutex> lck(mtxQue_);
        taskCount_++;
        auto packageTask=[&task]()->void{task();};
        tasksQue_.emplace(std::move(packageTask));


        notEmpty_.notify_all();
        return res;
    }

 测试类

int main(){
    ThreadPool threadPool;
    //使用默认参数 工作在fixed模式下
    threadPool.start();

    std::function<int(int,int)> func=[](int a,int b)->int{
        int sum=0;
        for (int i = a; i < b; ++i) {
            sum+=i;
        }
        return sum;
    };
    std::future<int> res1=threadPool.submit(func,1,100);
    std::future<int> res2=threadPool.submit(func,100,200);
    std::cout<<"Parallel results: "<<res1.get()+res2.get()<<std::endl;
    int sum=0;
    for(int i = 1; i < 200; ++i) {
        sum+=i;
    }
    std::cout<<"serial results: "<<sum<<std::endl;

}

编译是通过的,但是程序运行时发生了崩溃;经过调试,发现当submit函数执行结束时发生了段错误;

错误原因:不难看出我们在submit的函数栈上创建了一个task对象,即局部变量局部变量的生命周期只在这个函数的作用域内有效,函数结束出作用域,会将局部对象销毁;而在lambda表达式中使用引用的方式捕获一个将要销毁的对象,当我们使用task时,该对象已经被销毁,因此引发了错误。至于为什么不采用值捕获,packaged_task对象不能拷贝只能移动

4.1.2版本二

使用移动捕获,捕获task(lambda表达式要用mutable标记捕获的变量是可修改的)。只改动了这一行,使用同样的测试代码。

auto packageTask=[taskInLambda=std::move(task)]()mutable ->void{taskInLambda();};

本次编译未通过,日志如下: 

declared here
 1573 |       packaged_task(const packaged_task&) = delete;

发现调用了packaged_task的拷贝构造函数,也是终于引出了问题;我全程都是移动资源,为什么还是发生了左值拷贝呢?到底是哪里发生了左值拷贝呢?

首先验证是不是捕获的操作有问题,同样是创建一个packaged_task对象,然后移动捕获,测试代码如下:

int main(){

    std::packaged_task<int()>task=std::packaged_task<int()>([]()->int{
        std::cout<<">>>packaged_task<<<"<<std::endl;
    });

    auto packageTask=[taskInLambda=std::move(task)]()mutable ->void{taskInLambda();};
    
    packageTask();

};

输出结果:

>>>packaged_task<<<

说明捕获操作并没有问题。

分析这行代码,这行代码的作用是在taskQue使用移动构造,构造一个packaged_task对象。

tasksQue_.emplace(std::move(packageTask));

测试代码如下: 

#include <iostream>
#include <future>
#include <functional>
int main(){

    std::packaged_task<int()>task=std::packaged_task<int()>([]()->int{
        std::cout<<">>>packaged_task<<<"<<std::endl;
    });

    auto packageTask=[taskInLambda=std::move(task)]()mutable ->void{taskInLambda();};

    std::function<void()> func(std::move(packageTask));

};

测试结果如下:

declared here
 1573 |       packaged_task(const packaged_task&) = delete;

可以发现这次的报错日志和版本二相同,所以说就是在移动构造的function的过程中调用了packaged_task的左值拷贝操作

我们知道packaged_task不支持左值拷贝操作,为了进一步缩小问题范围,还必须验证,究竟是移动捕获packaged_task独有这种问题还是说,移动捕获其他不可拷贝的对象也会有同样的问题,因此,再进行测试捕获unique_ptr会不会有同样的问题,代码如下:

#include <iostream>
#include <future>
#include <functional>
int main(){
    std::unique_ptr<int> ptr(new int(10));

    auto packageTask=[ptrInLambda=std::move(ptr)]()mutable ->void{
        std::cout<<"*ptr: "<<*ptrInLambda<<std::endl;
    };

    std::function<void()> func(std::move(packageTask));
};

运行结果:

note: declared here
  468 |       unique_ptr(const unique_ptr&) = delete;

由此可见,只要lambda使用移动捕获捕获了不可拷贝的对象,并用这个lambda表达式构造function对象时就会报错

为了更进一步缩小问题范围,还需要验证问题究竟是因为lambda表达式移动捕获了不可拷贝的对象,并用这个lambda构造function时出现问题还是,还是说但凡使用了不可拷贝的对象lambda都不能构造function,还需要进行以下测试,代码如下。

#include <iostream>
#include <future>
#include <functional>
int main(){

    auto packageTask=[]()mutable ->void{
        std::unique_ptr<int> ptr(new int(10));
        std::cout<<"*ptr: "<<*ptr<<std::endl;
    };

    std::function<void()> func(std::move(packageTask));

    func();
};

 运行结果:

*ptr: 10

 发现并没有问题,那么这两处lambda表达式都使用了不可拷贝的对象,由于使用了移动捕获却导致了编译失败;那么二者更深层次的区别是什么呢?

我们知道lambda表达式本质也还是一个重载了小括号运算符的类,即函数对象或称其为仿函数,lambda表达式的捕获操作,相当于,在我们使用仿函数时,使用了构造函数向类的成员属性进行赋值如果某个类的属性是不可拷贝的,那么这个类也成了不可拷贝的,对应的,这个lambda表达式也成了不可拷贝的而在lambda内部创建不可复制的对象,仅仅是重载operator()()方法的局部变量,并不会改变这个类即lambda表达式的不可复制性

这个类重载了小括号运算符,保证它是一个函数对象,并且显式仅用了左值拷贝和赋值,验证代码如下:

#include <iostream>
#include <functional>

class F{
public:
    F() {}
    F(const F&)= delete;
    F operator=(const F&)=delete;
    F(F&&)=default;
    void operator()(int a){
        std::cout<<"a: "<<a<<std::endl;
    }
};

int main() {
    F f;
    f(10);
    std::function<void(int)> ff(std::move(f));

}

运行结果:

declared here
   11 |     F(const F&)= delete;

至此我们终于可以得出结论,不能使用不可拷贝的函数对象构造function函数对象。 

到此为止,似乎还有一个重要的问题没有解决,我自始至终使用的是function的移动构造函数,我采用的是移动资源的方式构造对象,应该根本涉及不到函数拷贝。事实上,function确实支持移动构造,确实可以通过移动构造来转移资源,但是在这种情况下,function要求资源不仅是可移动的,而且必须是可复制的(支持左值拷贝和赋值的资源同样可以移动他的资源,支持左值拷贝赋值和右值拷贝和赋值,二者并不冲突);

问题的关键在于function函数对象本身是需要支持左值拷贝和赋值的,即你可以使用左值拷贝构造,使用一个function对象,构造另一个对象;创建function对象时,不管是使用lambda表达式构造还是其他方法;

本质是function类,维护了一个指针,指向了某个可调用的函数对象,比如当我们使用lambda表达式构造function对象时,本质就是这个指针指向这个lambda,如果这个指针指向了不可拷贝的函数对象,会导致function无法使用左值拷贝

因此当我们构造function时不管是使用左值构造还是移动构造,都要求资源必须是可复制的,即左值拷贝和赋值时可用的,因此为了让function的左值拷贝和赋值可用,在构造function对象时,通常会尝试复制这个函数对象,保证传入的函数对象是可复制的,反之构造失败,这就是为什么即使使用一个不可拷贝的函数对象移动构造function对象时,也会提示该函数对象不能复制进而构造失败。

总结:在构造function对象时,不论使用左值构造还是右值移动构造,都应该保证资源是可复制的。

最后我们的解决方案是在堆上new packaged_task对象,避免局部对象出作用域析构,使用shared_ptr管理该对象,在lambda中捕获shared_ptr即可,切记不要捕获unique_ptr否则会产生同样问题

4.2线程池析构时死锁问题

构建一个使用线程池并行计算的测试代码;为线程池的创建和使用,显式加了一个作用域,出作用域时线程池对象析构;任务函数中我们让任务函数工作五秒,并且不阻塞获取返回值保证在线程池析构时,线程还没有执行完毕;我们期待的行为是首先线程池开始析构,但是由于线程池内还有任务,于是阻塞等待任务完成,任务完成后,析构操作被唤醒,最后整个线程结束。

析构函数:

~ThreadPool(){
        started_= false;
        threads_.clear();
        std::unique_lock<std::mutex> lck(mtxQue_);
        std::cout<<"~ThreadPool before"<<std::endl;
        threadsEmpty_.wait(lck,[&]()->bool{return threadSize_==0;});
        std::cout<<"~ThreadPool after"<<std::endl;
    }

 测试函数:

int main(){
    {
        ThreadPool threadPool;
        //使用默认参数 工作在fixed模式下
        threadPool.start();

        std::function<int(int,int)> func=[](int a,int b)->int{
            int sum=0;
            for (int i = a; i < b; ++i) {
                sum+=i;
            }
            std::this_thread::sleep_for(std::chrono::seconds(5));
            return sum;
        };
        std::future<int> res1=threadPool.submit(func,1,100);
        std::future<int> res2=threadPool.submit(func,100,200);
       
    }
    std::this_thread::sleep_for(std::chrono::seconds(10));
    std::cout<<"end"<<std::endl;
}

执行结果:

~ThreadPool before
The task is completed
The task is completed

析构函数被一直阻塞,观察此时的回收线程的代码

if(!started_&&taskCount_==0){
    threadSize_--;
    idleThreadSize_--;
    if(threadSize_==0){   
        notEmpty_.notify_all();
    }
    return;
}

 不难发现,当线程个数为零时需要唤醒析构操作,但是析构函数阻塞在了threadEmpty_这个条件变量上,然而我们的唤醒操作使用的是notEmpty_,唤醒的是休眠在notEmpty_这个条件变量上的线程,因此析构操作必然得不到唤醒,休眠在哪个条件变量上就应该使用同一个条件变量进行唤醒。修改代码再次执行。

~ThreadPool before
The task is completed
The task is completed
~ThreadPool after
end

 死锁问题得以解决,至此整个线程池项目讲解完毕,完整代码可移步https://github.com/fridaiy/threadpool/tree/main

  • 21
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值