基于条件变量的线程池实现

什么是线程池?线程池有什么用?

正常情况下,我们想要创建一个新线程都必须通过系统调用,让操作系统来给我们创建。这就需要进行用户态与内核态之间的切换,开销是不小的。为了减少开销,我们提出了线程池的方法,就是我一次向操作系统申请创建多个线程,让操作系统把这些进程都创建出来,然后我都拿到用户区,由线程池将这些线程管理起来。此后我用户区的进程有创建新线程的需要,就不用向操作系统要了,而是直接向线程池里要。这样就不需要进行用户态和内核态之间的切换,大大减少了开销,提高了运行效率。

线程池的创建

线程值的创建过程从本质上讲也是一个生产者消费者模型,其主要架构和基于条件变量的阻塞队列是基本一致的。只是多了一些优化机制。下面我们就先来介绍一下线程池的运行逻辑,然后再来分析这些新加入的优化机制

线程池的使用

线程池对外界暴露的接口很简单,我们只需要创建并初始化一个线程池,然后创建任务,把这个任务发送给线程池,后面的事情就不用管了。下面就是一个简单的线程池使用示例,首先我们先调用getInstance创建并获取一个线程池的单例,然后再调用InitThreadPool创建5个线程,下面就是循环往复地创建任务、将任务交给池中的线程去执行

#include "thread_pool.hpp"
#include "task.hpp"
#include <time.h>
#include <unistd.h>

#define NUM 5

int main()
{
    srand((unsigned)time(nullptr));
    ThreadPool<Task> *tp = ThreadPool<Task>::get_instance();
    tp->InitThreadPool(5);
    sleep(3);

    const std::string ops = "+-*/%";
    while(true){
        int x = rand() % 50 + 1;
        int y = rand() % 50 + 1;
        char op = ops[rand()%5];

        Task t(x, y, op);
        tp->PushTask(t);
        sleep(1);
    }

    return 0;
}

线程池的运行机制

线程池的成员变量有四个

  1. 第一个成员变量就是任务队列,里面用来存储需要线程去执行的任务,相当于我们生产者消费者模型中的缓冲区。
  2. 第二个成员变量就是一把互斥锁,在线程具体执行的过程中,他需要先从任务队列中拿任务,拿完之后再去执行,这把互斥锁的主要任务就是用来实现多线程对任务队列的互斥访问
  3. 第三个成员变量是一个条件变量,主要用来实现生产者消费者模型中的同步机制(当任务队列为空时,我们需要调用pthread_cond_wait来阻塞想要从任务队列中拿任务的线程,当有新任务加入任务队列的时候,我们需要调用pthread_cond_signal来唤醒一个想要从任务队列中拿任务的线程)
    线程池在初始化的时候会一次性的创建多个进程,每个线程的执行方法都是相同的
  4. 第4个成员变量是单例模式下的单例指针,属于静态成员变量,需要在内外进行初始化,在调用getInstance时实例化

然后我们就要根据这个线程池的成员变量,去写出这个线程池的构造函数和析构函数。任务队列可以用它默认的构造函数。但是互斥锁和条件变量不行,因此我们在构造函数中的任务就是要用专门儿的POSIX 库函数对互斥锁和条件变量进行初始化。同时我们也要在析构函数中对互斥锁和条件变量进行销毁

 ThreadPool()
 {
     pthread_mutex_init(&lock, nullptr);
     pthread_cond_init(&cond, nullptr);
 }
 ~ThreadPool()
 {
     pthread_mutex_destroy(&lock);
     pthread_cond_destroy(&cond);
 }

单例模式的实现

前面儿三个成员变量都已经初始化了。现在还剩最后一个——单例模式的指针并没有初始化。类中与单例模式相关的代码如下:可以看到instance这个静态成员变量比较特殊,它是在类外进行的初始化,在get_Instance函数中进行的实例化,不仅如此当这个instance实例化之后,Instance还能作为获取这个单例指针的调用接口,大家看这个get_instance()可能会有些疑问,怎么在进入这个函数之后又初始化了一把静态的锁?为什么不用我们线程池成员变量中的那把锁?为什么在加锁操作之前和之后都要进行一次指针是否为空的判断?

class ThreadPool{
		.....
        ThreadPool(const ThreadPool<T>&) = delete;
        ThreadPool<T>& operator = (const ThreadPool<T>&) = delete;
        static ThreadPoIol<T> *instance;
    public:
        // 获取一个单例
        static ThreadPool<T> *get_instance()
        {
            static pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
            if(nullptr == instance){
                pthread_mutex_lock(&mtx);
                if(nullptr == instance){
                    instance = new ThreadPool<T>();
                    //instance->Init...
                }
                pthread_mutex_unlock(&mtx);
            }
            return instance;
        }
        .....
  }
  
template<class T>
ThreadPool<T>* ThreadPool<T>::instance = nullptr;

首先先来回答第一个问题:怎么在进入这个函数之后又初始化了一把静态的锁?为什么不用我们线程池成员变量中的那把锁?

  1. 线程池成员变量中的锁(如代码中的lock)是属于线程池实例的成员,而在instance被创建之前,这些成员变量根本不存在(还未分配内存)因此,必须在get_instance函数内部单独定义一把静态锁 —— 这把锁的生命周期与程序一致,在instance创建前就已存在,能有效同步多个线程对instance的竞争创建过程。
  2. 我们在使用锁的时候尽量让锁分工明确,get_instance中单独初始化的静态锁,是为了解决 “单例实例创建前的线程同步问题”,而线程池成员变量中的锁用于 “实例创建后的数据访问同步”。两者的生命周期和职责完全不同,不能相互替代。分开设计既保证了单例初始化的线程安全,又明确了不同锁的职责边界,是单例模式与线程池结合时的典型实现方式。

现在我大概知道了。就是确实需要在get_instance的内部重新申请一把锁,但是我现在又有两个新的问题,为什么要申请一把静态的锁?直接申请一把普通锁不行吗?你这把锁现在申请了,但是我并没有在你的代码中看到释放这把锁的步骤呀?
如果直接申请一把普通锁,那他就只是调用get_instance函数的那个线程的局部变量,其他的线程根本就看不到这把锁,那你怎么能够实现线程对临界资源的互斥访问呢?因此想要做到这一点,这把锁必须是一个全局变量,所以要申请一把静态的锁,我们通过 static pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;的方式申请这把锁,他的生命周期和线程的生命周期就是一致的,当线程结束时,这把锁不用线程主动释放,会被操作系统自动回收。

然后我们就来解决最后一个问题:为什么在加锁操作之前和之后都要进行一次指针是否为空的判断?

学过c++语法的同学对单例模式一定是不陌生的。这个模式保证一个类在程序运行期间只有一个实例,实现访问接口的统一管理,解决资源冲突、优化性能、保证数据一致。在本例中,我这么多线程。确实只需要一个线程池来对他们进行统一的管理,你线程池的增加,并不会提高我的管理效率,甚至还有可能出现访问数据不一致的问题。因此我们想在线程池中引入单例模式
那现在的问题最关键的就在于如何实现单例模式呢?

  1. 首先就是要禁掉你这个线程池类中的拷贝函数和等号操作符
  2. 然后要有一个静态成员变量作为单例指针
  3. 要有一个静态成员GetInstance函数用来获取单例指针

前面两条都是直接根据C++语法去写就行(注意静态成员变量需要在类外进行初始化),最后一条GetInstance函数在实现的时候,就需要注意,他在上锁之前和上锁之后都要进行一次指针是否为空的判断,两次判断的目的是不一样的:

  • 上锁之后进行的那次判断是为了防止多个线程同时进入临界区,从而创建多个线程池的实例。
  • 而上锁之前的判断是为了避免不必要的加锁操作(线程池已经被实例化之后有线程再次调用GetInstance函数,如果不加这个判断,我们还要尝试去加锁,成功上锁之后才能进去判空,加入这个判断之后,我们不用加锁,直接就能进行判断)

线程池初始化

有同学可能就会问,线程池刚才不是已经在构造函数中初始化过了吗?怎么还要初始化?

你线程池是干什么的?是用来管理线程的,那线程在哪里?你刚刚初始化的时候,向操作系统申请线程了吗?没有,所以本次线程池初始化的工作就是要向操作系统要线程

void InitThreadPool(int num)
{
	for(auto i = 0; i < num; i++){
	    pthread_t tid;
	    pthread_create(&tid, nullptr, Routinue, this);
	}
}

上面这段代码的运行逻辑非常简单,就是通过pthread_create向操作系统申请指定数量的线程。每个县城创建好之后,都去执行Routinue函数对应的执行方法。

Routinue函数的设计

那下面我们就来具体看看这个执行方法Routinue的设计
首先就是这个函数的参数设计,非常的有讲究。static void *Routinue(void *args/*,ThreadPool *this*/)
大家看到上面的声明一定有很多疑问,下面我们就来一一分析Hey

为什么这个函数要定义成一个静态成员函数?
这主要是因为我们在InitThreadPool中使用的pthread_create函数,其定义如下:

#include <pthread.h>
int pthread_create(
    pthread_t *thread,           // 输出参数:用于存储新线程的ID
    const pthread_attr_t *attr,  // 线程属性:指定线程的栈大小、调度策略等(NULL表示使用默认属性)
    void *(*start_routine)(void*),  // 线程入口函数:新线程启动后执行的函数
    void *arg                    // 传递给线程入口函数的参数
);

其中pthread_create第三个参数start_routine是线程入口函数的函数指针,类型必须是void* (*)(void*)类型,而如果我们将Routinue函数定义为一个非静态的成员函数,那么根据c++的语法规则,它的参数中会隐含着一个this指针,而这个this指针的类型是ThreadPool*,并不是void*,因此他就没有办法作为线程入口函数(如果你自己定义一个void*类型的参数,那么在传入的时候实际上Routinue就有两个参数,不符合要求,如果你自己不定义参数,那么在传入的时候,实际上Routinue就只有一个参数,但是很可惜这个参数的类型不是void*,而是ThreadPool*,这也不符合要求)
而如果我们将其定义为静态成员函数,那么静态成员函数的参数中就不再隐含this指针了,此时我们将Routinue函数定义为void *Routinue(void *args),这就完美符合pthread_create对第三个参数的要求了。

这时候有人又要说了,你Routinue函数的任务不就是让这个线程不断地从任务队列中拿任务,然后执行任务吗?你如果不传this指针,你怎么去访问线程池中的任务队列呢?你不访问任务队列,你怎么拿任务呢?

说的没错,对于Routinue函数来说,this指针是必要的,但是他又不能作为非静态成员函数的隐含参数传递进来,因此我们只能够让他作为静态成员函数的那个唯一参数传进来。也就是我们前面定义的void *Routinue(void *args)中的void *args,你注意看我们在InitThreadPool中pthread_created的调用格式:pthread_create(&tid, nullptr, Routinue, this);,pthread_create的第四个参数,也就是Routinue中那个唯一的参数,传递的实际上传的就是this指针,到这里你应该明白了,this指针。是一定要传的,只是我们不让他作为非静态成员函数的隐含参数传进来,而是让他作为静态成员函数的那个唯一显示参数传进来

分析完函数的类型与参数,下面我们就来看内部的代码。

  1. 进入执行方法内部的第一件事情就是实现线程分离。就是告诉主线程不用再等我这个子线程的运行结果了。我执行结束之后,自有操作系统帮我自动回收。
  2. 第二件事情就是通过传入的参数获取线程池单例的指针。
  3. I第三件事情就是不断的查询线程池的任务队列中有没有任务?如果没有任务,我们就阻塞等待,如果有任务,我们就从队列中取出一个任务,然后执行。
static void *Routinue(void *args/*,ThreadPool *this*/)
{
	pthread_detach(pthread_self()); //线程分离
	ThreadPool *tp = (ThreadPool*)args;
	
	while(true){
	    tp->LockQueue();
	    //1. 检测是否有任务
	    while(tp->IsEmpty()){
	        //thread 应该等待,等待有任务
	        tp->ThreadWait(); //我们线程当前是在临界区内等待的!我是持有锁的!!!
	    }
	    //2. 取任务的过程
	    T t;
	    tp->PopTask(&t);
	    tp->UnlockQueue();
	    //3. 处理任务, 拿到任务之后,处理任务的时候,需要在临界区内处理吗?不需要
	    //在你的线程处理任务期间,其他线程是不是可以继续获取任务,处理任务
	    t();
	}
}

为什么条件变量要和锁搭配使用?

条件变量和锁搭配使用的本质是 “分工合作”:

  • 锁负责 “保护共享状态的原子性”,只有通过锁,我们才能实现临界资源的互斥访问
  • 条件变量负责 “线程的高效等待与唤醒”,解决忙轮询浪费 CPU 的问题。

二者缺一不可:

  • 没有锁,当多个线程同时修改条件变量的状态时,就会出现严重的数据不一致问题,导致唤醒丢失、数据错乱
  • 没有条件变量,锁只能通过忙轮询等待状态变化,浪费系统资源

在 POSIX 标准库中,接口pthread_cond_wait() 函数有一个参数就是互斥锁,强制要求条件变量和互斥锁搭配使用,否则调用行为未定义。比如看下面这句代码,它的功能如下:

pthread_cond_wait(&cond, &lock);
  1. 解锁互斥锁:当调用 pthread_cond_wait 时,它会自动释放传入的互斥锁(mutex)。这一操作至关重要,因为若不释放互斥锁,其他线程就无法进入临界区来修改共享资源和触发条件变量,从而可能导致死锁。
  2. 将当前线程放入条件变量 cond 的等待队列中,然后进入阻塞状态,等待其他线程通过 pthread_cond_signal 或 pthread_cond_broadcast 来唤醒它。
  3. 当其他线程发出信号唤醒该线程后,pthread_cond_wait 会尝试重新获取之前释放的互斥锁。一旦成功获取互斥锁,函数就会返回,线程可以继续执行后续的代码。

了解完了上面的内容,其余的内容就很简单了。线程池的完整代码如下

#pragma once 

#include <iostream>
#include <queue>
#include <pthread.h>

template <class T>
class ThreadPool{
    private:
        std::queue<T> q; //给线程池派发任务的地点, 临界资源
        pthread_mutex_t lock;
        pthread_cond_t cond;
    private:
        ThreadPool()
        {
            pthread_mutex_init(&lock, nullptr);
            pthread_cond_init(&cond, nullptr);
        }
        // 单例模式要禁掉=操作符和拷贝函数
        ThreadPool(const ThreadPool<T>&) = delete;
        ThreadPool<T>& operator = (const ThreadPool<T>&) = delete;
        // 静态成员变量在类外初始化
        static ThreadPool<T> *instance;
    public:
        // 获取一个单例
        static ThreadPool<T> *get_instance()
        {
            static pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
            if(nullptr == instance){
                pthread_mutex_lock(&mtx);
                if(nullptr == instance){
                    instance = new ThreadPool<T>();
                    //instance->Init...
                }
                pthread_mutex_unlock(&mtx);
            }
            return instance;
        }
        void LockQueue()
        {
            pthread_mutex_lock(&lock);
        }
        void UnlockQueue()
        {
            pthread_mutex_unlock(&lock);
        }
        bool IsEmpty()
        {
            return q.size() == 0;
        }
        void ThreadWait()
        {
            pthread_cond_wait(&cond, &lock);
        }
        void ThreadWakeup()
        {
            pthread_cond_signal(&cond);
        }
        void PopTask(T *out)
        {
            *out = q.front();
            q.pop();
        }
        //Routinue是类中的一个成员方法!包含了一个隐式参数this!ThreadPool*
        //实际上,这里是包含了两个参数的!
        static void *Routinue(void *args/*,ThreadPool *this*/)
        {
            pthread_detach(pthread_self()); //线程分离
            ThreadPool *tp = (ThreadPool*)args;

            while(true){
                tp->LockQueue();
                //1. 检测是否有任务
                //if -> while
                while(tp->IsEmpty()){
                    //thread 应该等待,等待有任务
                    tp->ThreadWait(); //我们线程当前是在临界区内等待的!我是持有锁的!!!
                }
                //2. 取任务的过程
                T t;
                tp->PopTask(&t);
                tp->UnlockQueue();
                //3. 处理任务, 拿到任务之后,处理任务的时候,需要在临界区内处理吗?不需要
                //在你的线程处理任务期间,其他线程是不是可以继续获取任务,处理任务
                t();
            }
        }
        void InitThreadPool(int num)
        {
            for(auto i = 0; i < num; i++){
                pthread_t tid;
                pthread_create(&tid, nullptr, Routinue, this);
            }
        }
        void PushTask(const T &in)
        {
            //放任务
            LockQueue();
            q.push(in);
            ThreadWakeup();
            UnlockQueue();
        }

        ~ThreadPool()
        {
            pthread_mutex_destroy(&lock);
            pthread_cond_destroy(&cond);
        }
};

template<class T>
ThreadPool<T>* ThreadPool<T>::instance = nullptr;



评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值