【Linux】线程池

文章目录

  • 线程池
    • 概念
    • 优点
    • 线程池的应用场景
    • 线程池的实现
      • 线程池的设计
      • thread_pool.hpp
      • 任务类型的设计Task.hpp
      • 主线程的逻辑
  • 线程安全的单例模式
    • 单例模式和设计模式的概念
    • 单例模式的特点
      • 饿汉实现方式和懒汉实现方式
    • 饿汉实现单例模式
    • 懒汉方式实现单例模式
    • 改写线程池代码
      • thread_pool.hpp
      • main.cc
  • STL,智能指针和线程安全

线程池

概念

线程池是一种线程使用模式,线程过多会带来调度开销,进而影响缓存局部和整体性能,而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务


优点

  • 线程池避免了在处理短时间任务时创建与销毁线程的代价
  • 线程池不仅能够保证内核充分利用,还能防止过分调度

注意: 线程池中可用线程的数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets等的数量


线程池的应用场景

  1. 需要大量的线程来完成任务,且完成任务的时间比较短.
  2. 对性能要求苛刻的应用,比如要求服务器迅速响应客户请求.
  3. 接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用.

例子

像Web服务器完成网页请求这样的任务,使用线程池技术是非常合适的

  • 因为单个任务小,而任务数量巨大,你可以想象一个热门网站的点击次数

对于长时间的任务,比如Telnet连接请求,线程池的优点就不明显了.因为Telnet会话时间比线程的创建时间大多了

突发性大量客户请求,在没有线程池的情况下,将产生大量线程,虽然理论上大部分操作系统线程数目最大值不是问题,但短时间内产生大量线程可能使内存到达极限,出现错误


线程池的实现

基本步骤

1.创建固定数量线程池,循环从任务队列中获取任务对象
2.获取到任务对象后,执行任务对象中的任务接口


【Linux】线程池_第1张图片

1)线程池中的多个线程负责从任务队列当中拿任务,并将拿到的任务进行处理

2)线程池对外提供一个Push接口,用于让外部线程能够将任务Push到任务队列当中

线程池的设计

1)由于现在是多线程同时访问的,线程池中的任务队列是会被多个执行流同时访问的临界资源,因此我们需要引入互斥锁对任务队列进行保护

2)线程池当中的线程要从任务队列里拿任务,前提条件是任务队列中必须要有任务

  • 线程池当中的线程在拿任务之前,需要先判断任务队列当中是否有任务,若此时任务队列为空,那么该线程应该进行等待,直到任务队列中有任务时再将其唤醒,因此我们需要引入条件变量
  • 当主线程向任务队列中Push一个任务后,此时可能有线程正处于等待状态,因此在新增任务后需要唤醒在条件变量下等待的线程

3)当某线程被唤醒时,其可能是被异常或是伪唤醒,此时应该让被唤醒的线程再次判断是否满足被唤醒条件,所以在判断任务队列是否为空时,应该使用while进行判断,而不是if

4)关于唤醒时候函数的选择

pthread_cond_broadcast函数的作用是唤醒条件变量下的所有线程,而外部可能只Push了一个任务,我们却把全部在等待的线程都唤醒了,此时这些线程就都会去任务队列获取任务,但最终只有一个线程能得到任务

一瞬间唤醒大量的线程可能会导致系统震荡,这叫做惊群效应

因此在唤醒线程时最好用pthread_cond_signal函数唤醒正在等待的一个线程即可

5)当线程从任务队列中拿到任务后,该任务就已经属于当前线程了,与其他线程已经没有关系了,因此应该在解锁之后再进行处理任务,而不是在解锁之前进行,因为处理任务的过程可能会耗费一定的时间,所以我们不要将其放到临界区当中

6)如果将处理任务的过程放到临界区当中,那么当某一线程从任务队列拿到任务后,其他线程还需要等待该线程将任务处理完后,才有机会进入临界区,此时虽然是线程池,但最终我们可能并没有让多线程并行的执行起来

7)关于线程池中的线程执行的例程函数

需要设置为静态方法

使用pthread_create函数创建线程时, 需要为创建的线程传入一个Routine(执行例程),该函数只有一个参数类型为void*的参数,以及返回类型为void*的返回值

而此时Routine作为类的成员函数,该函数的第一个参数是隐藏的this指针,因此这里的Routine函数,虽然看起来只有一个参数,而实际上它有两个参数,此时直接将该Routine函数作为创建线程时的执行例程是不行的,无法通过编译

静态成员函数属于类,而不属于某个对象,也就是说**静态成员函数是没有隐藏的this指针的,**因此我们需要将Routine设置为静态方法,此时Routine函数才真正只有一个参数类型为void*的参数

但是在静态成员函数内部无法调用非静态成员函数,而我们需要在Routine函数当中调用该类的某些非静态成员函数,比如Pop

因此我们需要在创建线程时,向Routine函数传入的当前对象的this指针,此时我们就能够通过该this指针在Routine函数内部调用非静态成员函数了


综合上述的描述,我们的线程池代码为:

thread_pool.hpp

#pragma once

#include 
#include 
#include 
#include 
#include 

namespace  Mango
{
    const int g_num = 5;
    template<class T>
    class ThreadPool
    {
        public:
            ThreadPool(int num = g_num)
                :_num(num)
            {
                //初始化互斥锁和条件变量,属性设置为nullptr
                pthread_mutex_init(&_mtx,nullptr);
                pthread_cond_init(&_cond,nullptr);
            }

            ~ThreadPool()
            {
                //释放锁和条件变量
                pthread_mutex_destroy(&_mtx);
                pthread_cond_destroy(&_cond);
            }
            void Lock() //加锁
            {
                pthread_mutex_lock(&_mtx);
            }
            void UnLock()   //解锁
            {
                pthread_mutex_unlock(&_mtx);
            }
            void Wait() //在条件变量下等待
            {
                pthread_cond_wait(&_cond,&_mtx);
            }
            void WaitUp()//唤醒在条件变量下等待的进程
            {
                pthread_cond_signal(&_cond);
            }
            bool IsEmpty()//判断任务队列中是否有任务(是否为空)
            {
                return task_queue.empty();
            }

            void PushTask(const T& in) 
            {
                //访问临界资源前先加锁
                Lock();
                task_queue.push(in);
                UnLock();
                //唤醒在条件变量下等待的线程执行任务
                WaitUp();
            }

            //拿任务的时候,因为是在加锁的上下文中拿任务,所以可以直接拿
            void PopTask(T* out)
            {
                *out = task_queue.front();
                task_queue.pop();
            }

            //在类中要让线程执行类内成员方法,是不可行的!因为类内的成员函数第一个参数是默认的this指针!!!
            //因为现在是静态方法,不能访问类内的属性
            //解决办法:pthread_create的第四个参数传递this指针   
            static void *Rountine(void *args)
            {
                pthread_detach(pthread_self());//线程分离,后序就不需要等待该进程了
                ThreadPool<T>* tp = (ThreadPool<T>*)args;

                //竞争任务
                while(1)
                {
                    tp->Lock();//任务队列是临界资源,要访问临界资源,先加锁保护(先把任务队列锁住)

                    //检测任务队列当中有没有任务,因为可能存在伪唤醒,所以用while不能用if
                    while(tp->IsEmpty())
                    {
                        //任务队列为空,线程该做什么呢??-> 将线程挂起,等待有任务的时候被唤醒
                        tp->Wait();
                    }

                    //来到这里,说明有任务了
                    T t;
                    tp->PopTask(&t);//输出型参数

                    //先解锁在运行任务
                    //把任务从任务队列当中取出了,这个任务也就从任务队列当中移除
                    //这个任务属于当前线程,不再属于临界资源
                    //所以处理任务应该在解锁之后处理,当我们把锁释放掉之后,当前线程在处理这个任务,
                    //其它线程可能征征用锁,然后判断+处理,所以可能存在有多个线程同时跑任务
                    tp->UnLock();
                    t();//t.operator() -> 相当于t.Run()
                }
            }

            void InitThreadPool()//初始化线程池创建线程
            {
                pthread_t tid;
                for(int i = 0;i<_num;i++)
                {
                    //第四个参数传this指针,方便我们静态成员函数Rountine访问类内的成员
                    pthread_create(&tid,nullptr,Rountine,(void*)this);
                }
            }

        private:
            int _num;//表示线程池中有多少个线程

             // 外部存在一个或者多个线程向任务队列当中塞入任务
            // 线程池内部有多个线程竞争式的从任务队列当中拿任务
            // ->典型的生产者消费者模型 ,这个任务队列就是临界资源
            std::queue<T> task_queue;//任务队列->是临界资源

            pthread_mutex_t _mtx;//互斥锁,保护临界资源

            pthread_cond_t  _cond;//条件变量,当没有任务的时候,线程要挂起等待
    };
}

任务类型的设计Task.hpp

我们将线程池进行了模板化,因此线程池当中存储的任务类型可以是任意的,但无论该任务是什么类型的,在该任务类当中都必须包含一个Run方法,当我们处理该类型的任务时只需调用该Run方法即可

下面我们实现一个计算任务类

#pragma once

#include 
#include 

namespace Mango
{
    class Task
    {
    private:
        int x_;
        int y_;
        char op_; //+/*/%
    public:
        Task() {}
        Task(int x, int y, char op) : x_(x), y_(y), op_(op)
        {
        }
        std::string Show()
        {
            std::string message = std::to_string(x_);
            message += op_;
            message += std::to_string(y_);
            message += "=?";
            return message;
        }
        int Run()
        {
            int res = 0;
            switch (op_)
            {
            case '+':
                res = x_ + y_;
                break;
            case '-':
                res = x_ - y_;
                break;
            case '*':
                res = x_ * y_;
                break;
            case '/':
                res = x_ / y_;
                break;
            case '%':
                res = x_ % y_;
                break;
            default:
                std::cout << "bug??" << std::endl;
                break;
            }
            std::cout << "当前任务正在被线程: " << pthread_self() << " 处理: " \
            << x_ << op_ << y_ << "=" << res << std::endl;
            return res;
        }
        int operator()()
        {
            return Run();
        }
        ~Task() {}
    };
}

此时线程池内的线程不断从任务队列拿出任务进行处理,而它们并不需要关心这些任务是哪来的,它们只需要拿到任务后执行对应的Run方法即可


主线程的逻辑

主线程就负责不断向任务队列当中Push任务,此后线程池当中的线程会从任务队列当中获取到这些任务并进行处理

#include "thread_pool.hpp"
#include "Task.hpp"

#include 
#include 

using namespace Mango;

int main()
{
    //创建一个线程池
    ThreadPool<Task>* tp = new ThreadPool<Task>();
    tp->InitThreadPool();//初始化线程池
    srand((long long)time(nullptr));
    const char* str = "+-*/%";
    while(1)
    {
        sleep(1);
        //主线程放任务
        Task t(rand()%20+1, rand()%10+1, str[rand()%5]);
        tp->PushTask(t);
    }
    return 0;
}

【Linux】线程池_第2张图片

我们可以发现:

1)启动可执行程序后一瞬间就有六个线程,其中一个是主线程,另外五个是线程池内处理任务的线程

2)这五个线程在处理时会呈现出一定的顺序性(观察打印的时候的线程PID),因为主线程是每秒Push一个任务,这五个线程只会有一个线程获取到该任务,其他线程都会在等待队列中进行等待,当该线程处理完任务后就会因为任务队列为空而排到等待队列的最后,当主线程再次Push一个任务后会唤醒等待队列首部的一个线程,这个线程处理完任务后又会排到等待队列的最后,因此这五个线程在处理任务时会呈现出一定的顺序性


此后我们如果想让线程池处理其他不同的任务请求时,我们只需要提供一个任务类,在该任务类当中提供对应的任务处理方法就行了


线程安全的单例模式

单例模式和设计模式的概念

什么是单例模式

单例模式是一种 “经典的, 常用的, 常考的” 设计模式.

什么是设计模式

针对一些经典的常见的场景, 给定了一些对应的解决方案, 这个就是 设计模式


单例模式的特点

某些类, 只应该具有一个对象(实例), 就称之为单例.

例如:

在很多服务器开发场景中, 经常需要让服务器加载很多的数据 (上百G) 到内存中. 此时往往要用一个单例的类来管理这些数据.

饿汉实现方式和懒汉实现方式

以洗碗为例子:

  • 吃完饭, 立刻洗碗, 这种就是饿汉方式,因为下一顿吃的时候可以立刻拿着碗就能吃饭
  • 吃完饭, 先把碗放下, 然后下一顿饭用到这个碗了再洗碗, 就是懒汉方式

其中: 懒汉方式最核心的思想是 “延时加载”. 从而能够优化服务器的启动速度


饿汉实现单例模式

template<class T>
class Singleton
{
    static T data;
public:
    static T* GetInstance()
    {
        return &data;
    }
};
//静态成员在类外面初始化
template<class T>
T Singleton<T>::data = T();

只要通过 Singleton 这个包装类来使用 T 对象, 则一个进程中只有一个 T 对象的实例

懒汉方式实现单例模式

template <typename T>
class Singleton
{
    static T* inst;
public:
    static T* GetInstance()
    {
        if (inst == NULL) {
            inst = new T();
        }
        return inst;
    }
};
template<class T>
T* Singleton<T>::inst =nullptr;

上述代码存在线程安全问题:

单例本身会在任何场景,任何环境下被调用,如果被多线程重入,进而导致线程安全的问题

第一次调用 GetInstance 的时候, 如果两个线程同时调用, 可能会创建出两份 T 对象的实例,但是后续再次调用, 就没有问题了

如何修改?

  1. 加锁解锁的位置
  2. 双重 if 判定, 避免不必要的锁竞争
  3. volatile关键字防止过度优化
// 懒汉模式, 线程安全
template <typename T>
class Singleton
{
    volatile static T* inst; // 需要设置 volatile 关键字, 否则可能被编译器优化.
    static std::mutex lock;
    public:
    static T* GetInstance() 
    {
        if (inst == NULL)
        { // 双重判定空指针, 降低锁冲突的概率, 提高性能.
            lock.lock(); // 使用互斥锁, 保证多线程情况下也只调用一次 new.
            if (inst == NULL) 
            {
                inst = new T();
            }
            lock.unlock();
        }
        return inst;
    }
};
template<class T>
volatile T* Singleton<T>::inst =nullptr;

问:为什么要加volatile呢?

因为定义的这个指针inst到时候会初始化为NULL为了避免编译器过度优化,过度优化是指他直接把这个变量放到寄存器,然后每次都去寄存器里取,而不是到内存取,以后总是以NULL访问,所以加上volatile关键字表示保持内存可见性,总是从内存获取最新的数据进行使用


改写线程池代码

我们将上面的线程池改写为单例模式的懒汉模式

注意点:1)此时因为全局只有一个对象,所以我们不允许别人通过拷贝/赋值创建对象,所以把拷贝构造和赋值重载给禁用掉

注意:构造函数还是要实现:因为new对象仍然会调用构造,只是外部不允许构造对象

2)GetInstance为获取这个唯一的对象,由于其属于类内的成员方法,需要对象去调用,而我们是无法直接创建对象的,所以改为static方法,可以直接通过类域去调用

thread_pool.hpp

更改的位置:

0.将拷贝构造和赋值重载给禁用掉,然后构造函数私有化

1.增加了一个静态成员:static ThreadPool *ins;//指向这个类唯一的对象, 注意:该成员要在类外进行初始化

2.增加一个静态的方法用于获取这个全局唯一的对象

写法1:

【Linux】线程池_第3张图片


解决方法:双判断

static ThreadPool<T> *GetInstance()//获得这个唯一的对象
{
    //单例本身会在任何场景,任何环境下被调用
    //GetInstance():被多线程重入,进而导致线程安全的问题
    //我们只需要抱着检测当前创建对象和检测的过程是原子的就行
    //如何解决呢?->加一把锁
    static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;//因为是static,所以不用初始化也不用释放

    if(ins == nullptr)//当前单例对象还没有被创建
    {
        pthread_mutex_lock(&lock);
        //双判定,减少锁的争用,提高获取单例的效率!
        if(ins == nullptr)
        {
            ins = new ThreadPool<T>();
            ins->InitThreadPool();//初始化线程池
            std::cout << "首次加载对象" << std::endl;
        }
        pthread_mutex_unlock(&lock);
    }
    //单例对象已经被创建过了,直接返回
    return ins;
}

好处:第一次判断的时候虽然有线程安全问题,此时申请锁,然后创建,然后解锁,可以保证不会有问题, 后序如果发现ins已经不为空了,就直接返回ins,不需要竞争锁


#pragma once

#include 
#include 
#include 
#include 
#include 

namespace  Mango
{
    const int g_num = 5;
    template<class T>
    class ThreadPool
    {
        private:
            //构造函数+拷贝构造+赋值重载都禁用掉
            //构造函数必须得实现,因为没有构造函数就没办法初始化
            //但是必须私有化(单例模式) ->这个类不能用来定义对象
            ThreadPool(int num = g_num)
                :_num(num)
            {
                //初始化互斥锁和条件变量,属性设置为nullptr
                pthread_mutex_init(&_mtx,nullptr);
                pthread_cond_init(&_cond,nullptr);
            }
            ThreadPool(const ThreadPool<T>& tp) = delete;
            ThreadPool<T>& operator=(ThreadPool<T> &tp) = delete;
        public:
            ~ThreadPool()
            {
                //释放锁和条件变量
                pthread_mutex_destroy(&_mtx);
                pthread_cond_destroy(&_cond);
            }


            void Lock() //加锁
            {
                pthread_mutex_lock(&_mtx);
            }
            void UnLock()   //解锁
            {
                pthread_mutex_unlock(&_mtx);
            }
            void Wait() //在条件变量下等待
            {
                pthread_cond_wait(&_cond,&_mtx);
            }
            void WaitUp()//唤醒在条件变量下等待的进程
            {
                pthread_cond_signal(&_cond);
            }
            bool IsEmpty()//判断任务队列中是否有任务(是否为空)
            {
                return task_queue.empty();
            }

            static ThreadPool<T> *GetInstance()//获得这个唯一的对象
            {
                //单例本身会在任何场景,任何环境下被调用
                //GetInstance():被多线程重入,进而导致线程安全的问题
                //我们只需要抱着检测当前创建对象和检测的过程是原子的就行
                //如何解决呢?->加一把锁
                static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;//因为是static,所以不用初始化也不用释放

                if(ins == nullptr)//当前单例对象还没有被创建
                {
                    pthread_mutex_lock(&lock);
                    //双判定,减少锁的争用,提高获取单例的效率!
                    if(ins == nullptr)
                    {
                        ins = new ThreadPool<T>();
                        ins->InitThreadPool();//初始化线程池
                        std::cout << "首次加载对象" << std::endl;
                    }
                    pthread_mutex_unlock(&lock);
                }
                //单例对象已经被创建过了,直接返回
                return ins;
            }

            void PushTask(const T& in) 
            {
                //访问临界资源前先加锁
                Lock();
                task_queue.push(in);
                UnLock();
                //唤醒在条件变量下等待的线程执行任务
                WaitUp();
            }

            //拿任务的时候,因为是在加锁的上下文中拿任务,所以可以直接拿
            void PopTask(T* out)
            {
                *out = task_queue.front();
                task_queue.pop();
            }

            //在类中要让线程执行类内成员方法,是不可行的!因为类内的成员函数第一个参数是默认的this指针!!!
            //因为现在是静态方法,不能访问类内的属性
            //解决办法:pthread_create的第四个参数传递this指针   
            static void *Rountine(void *args)
            {
                pthread_detach(pthread_self());//线程分离,后序就不需要等待该进程了
                ThreadPool<T>* tp = (ThreadPool<T>*)args;

                //竞争任务
                while(1)
                {
                    tp->Lock();//任务队列是临界资源,要访问临界资源,先加锁保护(先把任务队列锁住)

                    //检测任务队列当中有没有任务,因为可能存在伪唤醒,所以用while不能用if
                    while(tp->IsEmpty())
                    {
                        //任务队列为空,线程该做什么呢??-> 将线程挂起,等待有任务的时候被唤醒
                        tp->Wait();
                    }

                    //来到这里,说明有任务了
                    T t;
                    tp->PopTask(&t);//输出型参数

                    //先解锁在运行任务
                    //把任务从任务队列当中取出了,这个任务也就从任务队列当中移除
                    //这个任务属于当前线程,不再属于临界资源
                    //所以处理任务应该在解锁之后处理,当我们把锁释放掉之后,当前线程在处理这个任务,
                    //其它线程可能征征用锁,然后判断+处理,所以可能存在有多个线程同时跑任务
                    tp->UnLock();
                    t();//t.operator() -> 相当于t.Run()
                }
            }

            void InitThreadPool()//初始化线程池创建线程
            {
                pthread_t tid;
                for(int i = 0;i<_num;i++)
                {
                    //第四个参数传this指针,方便我们静态成员函数Rountine访问类内的成员
                    pthread_create(&tid,nullptr,Rountine,(void*)this);
                }
            }

        private:
            int _num;//表示线程池中有多少个线程

             // 外部存在一个或者多个线程向任务队列当中塞入任务
            // 线程池内部有多个线程竞争式的从任务队列当中拿任务
            // ->典型的生产者消费者模型 ,这个任务队列就是临界资源
            std::queue<T> task_queue;//任务队列->是临界资源

            pthread_mutex_t _mtx;//互斥锁,保护临界资源

            pthread_cond_t  _cond;//条件变量,当没有任务的时候,线程要挂起等待

            static ThreadPool<T> *ins;//指向这个类唯一的对象
    };
    //在类外初始化
    template<class T>
    //类型  作用域::名字
    ThreadPool<T>* ThreadPool<T>::ins = nullptr;
}

main.cc

此时我们不能new一个线程池出来,而是要通过GetInstance这个函数获取全局唯一的对象,然后进行操作

#include "thread_pool.hpp"
#include "Task.hpp"

#include 
#include 

using namespace Mango;

int main()
{
    std::cout << "当前正在运行我的进程其他代码..." << std::endl;
    std::cout << "当前正在运行我的进程其他代码..." << std::endl;
    std::cout << "当前正在运行我的进程其他代码..." << std::endl;
    std::cout << "当前正在运行我的进程其他代码..." << std::endl;
    std::cout << "当前正在运行我的进程其他代码..." << std::endl;
    std::cout << "当前正在运行我的进程其他代码..." << std::endl;
    std::cout << "当前正在运行我的进程其他代码..." << std::endl;

    sleep(3);
    srand((long long)time(nullptr));
    //主线程
    const char* str = "+-*/%";
    while(1)
    {
        sleep(1);
        
        Task t(rand()%20+1, rand()%10+1, str[rand()%5]);
        ThreadPool<Task>::GetInstance()->PushTask(t);//用单例的方式得到这个对象,然后Push
    }
    return 0;
}

我们可以发现,只有第一次才需要创建对象(首次加载对象),然后后序只需要直接返回使用即可

【Linux】线程池_第4张图片


STL,智能指针和线程安全

STL中的容器是否是线程安全的

不是!原因如下:

1)STL 的设计初衷是将性能挖掘到极致, 而一旦涉及到加锁保证线程安全, 会对性能造成巨大的影响

2)对于不同的容器, 加锁方式的不同, 性能可能也不同(例如hash表的锁表和锁桶)

因此 STL 默认不是线程安全. 如果需要在多线程环境下使用, 往往需要调用者自行保证线程安全


智能指针是否是线程安全的

对于 unique_ptr, 由于只是在当前代码块范围内生效, 因此不涉及线程安全问题

对于 shared_ptr, 多个对象需要共用一个引用计数变量, 所以会存在线程安全问题. 但是标准库实现的时候考虑到了这个问题, 基于原子操作(CAS)的方式保证 shared_ptr 能够高效, 原子的操作引用计数


你可能感兴趣的:(Linux,服务器,运维,linux,网络,开发语言)