【Linux】第十三篇:线程池与单例模式

目录

  • 1.线程池
    • 介绍
    • 一个基于简单任务的线程池实现
  • 2.线程池与单例模式
    • 饿汉模式与懒汉模式
    • 懒汉模式的线程池
  • 3.读写锁
    • 读写锁接口
    • 设置读者写者优先级
    • 读写锁API
    • 读写锁实验
  • 4.自旋锁简介

【Linux】第十三篇:线程池与单例模式_第1张图片

1.线程池

介绍

线程池是一种线程使用模式。

如果频繁地创建线程,会带来调度上的大量开销,进而影响缓存局部性和整体性能。

通过线程池一次性向系统申请多个线程进行维护,随后等待管理者分配可并发执行的任务。

线程池可避免在执行短时间任务时创建和销毁线程的高代价,防止过分调度,保证内核的充分利用。

可用线程的数量取决于可用的并发处理器,内存,网络sockets等的数量。

  • 何处使用线程池?
  • 需要依赖大量线程来并发完成任务,且每条任务的执行时间不长。

    WEB服务器完成网页的请求的任务,使用线程池技术是很合适的,因为单个访问者即是一条线程,任务小的同时任务数量特别大。

  • 对性能要求苛刻的应用,例如游戏服务器需要迅速对玩家的操作做出响应。

  • 接受突发性的大量请求。

一个基于简单任务的线程池实现

我们使用类来封装线程池,使用队列存储任务对象。其中需要使用到互斥锁保证多线程间的互斥,需要条件变量保证生产者与消费者之间的同步。

所需工具

  • 互斥锁
  • 条件变量
  • 创建多个线程
  • 队列
  1. 先给出线程池类 ThreadPool的基本框架:
  • 初始化锁和条件变量
  • 销毁锁和变量
  • 封装lock,unlock,wait,signal(之后讨论为什么一定要做封装)
//threadpool.hpp
 #pragma once 
#include 
#include 
#include 
#include 
#include 

const int g_num=10;
template <class T>
class ThreadPool
{
public:
    ThreadPool(int num=g_num):_num(num)
    {
        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 WakeUp()
    {
        pthread_cond_signal(&_cond);
    }

    bool IsEmpty()
    {
        return _task_queue.empty();
    }
private:
    int _num;//线程数
    std::queue<T> _task_queue;
    pthread_mutex_t _mtx;
    pthread_cond_t _cond;
};
  1. 现在我们需要一个函数 ThreadInit()来创建多个线程,
public:
    void ThreadInit()
    {
       pthread_t tid; 
       for(int i=0;i<_num;++i)
       {
            pthread_create(&tid,nullptr,Routine,(void*)this);
       }
    }
  1. 线程函数 Routine()要处理队列中的任务,事先写好从任务队列中提取数据的操作:PopTask(),顺便写出存放数据的操作:PushTask()
public:
    void PushTask(const T& in)
    {
        Lock();//队列为临界资源
        _task_queue.push(in);
        WakeUp();//填入数据后,唤醒线程开始处理
        Unlock();
    }

    void PopTask(T* out)
    {
        *out=_task_queue.front();
        _task_queue.pop();
    }
  1. 显然还需建立一个线程函数 Routine()来提取队列中的对象,但需要注意到,线程函数的参数只有一个参数,类型为void*:

int pthread_create(pthread_t *thread, const pthread_attr_t *attr,void *(*start_routine) (void *), void *arg);

而如果作为成员函数,那么函数的第一个参数会默认为this指针,所以我们将 Routine设置为静态成员函数。static修饰的函数,因为没有this指针,所以函数中无法调用成员变量,那显然互斥锁,条件变量,任务队列的API都需要做封装。

public:
    static void *Routine(void *args)
    {
        pthread_detach(pthread_self());
        ThreadPool<T> *tp = (ThreadPool<T> *)args;
        while (true)
        {
            tp->Lock();
            while(tp->IsEmpty())
            {
                tp->Wait();//如果队列为空,则等待
            }
            //该任务队列中一定有了任务
            T t;
            tp->PopTask(&t);
            tp->Unlock();//静态变量没有this指针只能依赖传入的参数
            //线程处理任务的工作不在临界区内,
            //需先释放锁再执行相应的处理
            t.Run();
        }
    }

那这个简单的线程池类就完成了

在队列中我们将存放一个任务类 Task,届时生产者提供两个操作数和操作符,消费者执行 +-*/%计算:

//Task.hpp
#pragma once 
#include 
class Task
{
public:
    Task(int _a=0,int _b=0,char _op=0):a(_a),b(_b),op(_op)
    {}

    std::string Show()
    {
        std::string message=std::to_string(a);
        message+=op;
        message+=std::to_string(b);
        message+=" = ";
        return message;
    }
    void Run()
    {
        int result=0;
        switch(op)
        {
        case '+':
            result=a+b;
            break;
        case '-':
            result=a-b;
            break;
        case '*':
            result=a*b;
            break;
        case '/':
            if(b==0)
            {
                std::cout<<"div zero"<<std::endl;
                result=-1;
            }
            else
            {
                result=a/b;
            }
            break;
        case '%':
            if (b == 0)
            {
                std::cout << "mod zero" << std::endl;
                result = -1;
            }
            else
            {
                result = a % b;
            }
            break;
        default:
            break;
        }
        std::cout<<"thread("<<pthread_self()<<") 正在执行:"\
        <<a<<op<<b<<" = "<<result<<std::endl;
    }

    ~Task(){}
private:
    int a;
    int b;
    char op;
};

最后我们做下测试,由主线程不断发出任务,主函数如下:

//main.cpp
#include 
#include 
#include
#include "threadpool.hpp"
#include "Task.hpp"
int main()
{
    ThreadPool<Task>* tp=new ThreadPool<Task>();
    tp->ThreadInit();
    srand((unsigned int)time(nullptr));
    while(1)
    {
        int x=rand()%50+1;
        int y=rand()%50+1;
        Task t(x,y,"+-*/%"[rand()%5]);
        tp->PushTask(t);
        sleep(1);
    }
    return 0;
}

【Linux】第十三篇:线程池与单例模式_第2张图片

【Linux】第十三篇:线程池与单例模式_第3张图片

2.线程池与单例模式

上面所设计的线程池类,在工程中我们实例化一个对象就够了,这称为单例模式。

在很多服务器开发场景中,经常需要让服务器加载很多数据到内存中,此时往往需要一个单例的类来管理这些数据。如果允许这个类存在多个对象便会导致代码冗余。

饿汉模式与懒汉模式

吃完饭, 立刻洗碗, 这种就是饿汉方式. 因为下一顿吃的时候可以立刻拿着碗就能吃饭.

吃完饭, 先把碗放下, 然后下一顿饭用到这个碗了再洗碗, 就是懒汉方式.

  • 饿汉方式实现单例模式
template <class T>
class Sigleton
{
private:
    static T data;
public:
    static T* GetInstance()
    {
        return &data;
    }
};

饿汉模式会在进程创建伊始就实例化对象,缺点在于:起初如果这个对象用不到,但是会占据一部分内存,而且 创建进程的时间会更久。

  • 懒汉方式实现单例模式
typename<class T>
class Singleton
{
private:
    static T* data;
public:
    static T* GetInstance()
    {
        if(data==nullptr)
        {
            data=new T();
        }
        return data;
    }
};
T* Singleton::data=nullptr;

懒汉模式最核心的思想是延时加载,如写时拷贝,从而能够优化服务器的启动速度。

懒汉模式的线程池

现在我们用懒汉的单例模式尝试改写第一节中的线程池类:threadpool

首先需要注意的是,将构造函数改为private,以及禁用拷贝构造和赋值重载,防止用户实例化多个对象。

template <class T>
class ThreadPool
{
private:
    //构造函数必须要实现,但是必须要私有化
    ThreadPool(int num=g_num):_num(num)
    {
        pthread_mutex_init(&_mtx,nullptr);
        pthread_cond_init(&_cond,nullptr);
    }
    ThreadPool(const ThreadPool<T> &tp)=delete;
    ThreadPool<T>& operator=(ThreadPool<T> &tp)=delete;

public:

    static ThreadPool<T>* GetInstance()
    {
        static pthread_mutex_t ins_mtx=PTHREAD_MUTEX_INITIALIZER;
        if(instance==nullptr)//双判定
        {
            pthread_mutex_lock(&ins_mtx);
            if(instance==nullptr)
            {
                instance=new ThreadPool();
                instance->ThreadInit();
                std::cout<<"首次加载"<<std::endl;
            }
            pthread_mutex_unlock(&ins_mtx);
        }      
        return instance;
    }
    //其余的函数不变
    //...
private:
    int _num;
    std::queue<T> _task_queue;
    pthread_mutex_t _mtx;
    pthread_cond_t _cond;
    volatile static ThreadPool<T>* instance;//懒汉单例模式的指针,用时new
};
//静态成员变量须在类外初始化
template<class T>
ThreadPool<T>* ThreadPool<T>::instance=nullptr;

说明

  1. GetInstance函数是静态成员函数,是跟随类编译时就创建的,只要我们需要实现单例时,调用这个静态成员函数即可。

  2. GetInstance函数内我们写了两个if来判定instance指针是否为空,目的是为了保证线程安全并保证效率——双判定

    首先需要明确的是,instance是一个需要保护的共享资源,如果线程很多,那么在就会有多个线程能通过 if(instance==nullptr)的判断条件,从而创建多个对象。于是我们需要对此判断进行加锁,但是,如果单例对象已经创建,instance不为nullptr,那每个线程还要先去竞争锁再判断,是会造成性能损失的,所以我们在外边再套一层判断,从而在已创建一个对象后,让后面的线程可直接跳到return instance,而不用再去竞争锁。

所以一个线程安全的懒汉单例模式的模型应该是:

template <typename T>
class Singleton {
private:
    volatile static T* instance; // 需要设置 volatile 关键字, 否则可能被编译器优化.
    static std::mutex lock;
public:
    static T* GetInstance()
    {
        if (instance == NULL) 
        {   // 双重判定空指针, 降低锁冲突的概率, 提高性能.
            lock.lock(); // 使用互斥锁, 保证多线程情况下也只调用一次 new.
            if (instance == NULL) 
            {
                instance = new T();
            } 
            lock.unlock();
        } 
        return instance;
    }
};

主函数稍作修改

//main.cpp
int main()
{

    srand((unsigned int)time(nullptr));
    while(1)
    {
        int x=rand()%50+1;
        int y=rand()%50+1;
        Task t(x,y,"+-*/%"[rand()%5]);
      
        ThreadPool<Task>::GetInstance()->PushTask(t);
        std::cout<<"单例对象:"<<ThreadPool<Task>::GetInstance()<<std::endl;
        sleep(1);
    }
    return 0;
}

【Linux】第十三篇:线程池与单例模式_第4张图片

3.读写锁

在编写多线程的时候,有一种情况十分常见:有些公共数据修改的机会比较少,相比较改写数据,他们被读取的机会反而高得多。

通常而言,在读的过程中,常伴随查找操作,中间耗时很长,但是读者不会取走数据资源,给数据加锁会极大地降低程序的效率,所以我们需要重新划分各个线程的关系:

  • 写者和写者:互斥关系
  • 读者和写者:互斥+同步
  • 读者和读者:没有关系(可以共享临界数据)

所以读者写者与生产者消费者的区别在于:读者不拿走数据,消费者会拿走数据。

有一种专用锁可以处理这种多读少写的情况,就是 读写锁

读写锁的行为

当前锁状态 读锁请求 写锁请求
无锁 可以 阻塞
读锁 可以 阻塞
写锁 阻塞 阻塞

加写锁的时候判断是否有写锁,没有则说明已有写者持有写锁,要阻塞等其释放写锁;加写锁的时候判断reader是否大于0;如果大于0代表有人加读锁,将阻塞;

加读锁的时候判断是否有写者持有写锁;如果有人已加写锁,说明临界资源正在修改,读者将阻塞,否则reader计数器+1(所以可以存在多个reader)。

总结:写独占,读共享

  • 读者优先:读者和写者同时到来,让读者先进入访问
  • 写者优先:读者和写者同时到来,比当前写者晚来的读者都不能进入临界区了,等到临界区的读者走完后,写者进入临界区。

读写锁接口

  • 基本类型 : pthread_rwlock_t(读写锁),pthread_rwlockattr_t(读写锁属性)

设置读者写者优先级

//初始化读写锁属性
pthread_rwlockattr_init(pthread_rwlockattr_t* attr);

//设置优先级
int pthread_rwlockattr_setkind_np(pthread_rwlockattr_t* attr,int pref);

//释放读写锁属性
pthread_rwlockattr_destroy(pthread_rwlockattr_t *attr);

pref有3种选项

pthread_RWLOCK_PREFER_READER_NP //(默认设置),读者优先,可能会导致写者饥饿问题。

PTHREAD_RWLOCK_PREFER_WRITER_NP //写者优先,有 BUG,导致表现行为和PTHREAD_RWLOCK_PREFER_READER_NP 一致

PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP //写者优先,注意写者不能递归加锁

读写锁API

  • 初始化
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,const pthread_rwlockattr_t *restrict attr);
  • 销毁
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
  • 加锁和解锁
//加读写
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
//加写锁
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
//解锁
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
  • 返回值:成功返回0,失败返回错误码。

读写锁实验

现在我们设置一个写者和4个读者线程:

#include 
#include 
#include 
#include
#include
#include
using namespace std;

pthread_rwlock_t rwlock;
string* blackboard;
void* reader(void* arg)
{
    cout<<"start read"<<endl;
    while(true)
    {
        pthread_rwlock_rdlock(&rwlock);
        cout<<pthread_self()<<"->read: "<<*blackboard<<endl;
        sleep(1);
        pthread_rwlock_unlock(&rwlock);
    }
    return nullptr;
}

void* writer(void* arg)
{
    while(true)
    {
        pthread_rwlock_wrlock(&rwlock);
        cout<<"start writing"<<endl;
        //blackboard->clear();
        *blackboard='A'+rand()%26;
        pthread_rwlock_unlock(&rwlock);  
        cout<<"write over"<<endl;
        sleep(3);
    }
    return nullptr;
}

int main()
{
    srand(unsigned(time(nullptr)));
    pthread_t rtid[4],wtid;
    int i;
    blackboard=new string("None");
    pthread_rwlock_init(&rwlock,nullptr);

    //创建4个读者线程
    for(i=0;i<4;++i)
    {
        pthread_create(rtid+i,nullptr,reader,nullptr);
    }
    //创建写者线程
    pthread_create(&wtid,nullptr,writer,nullptr);

    //回收线程资源
    pthread_join(wtid, NULL);
 
    for(i = 0; i < 4; ++i)
    {
        pthread_join(rtid[1], NULL);
    }

    pthread_rwlock_destroy(&rwlock);

    return 0;
}

结果发现由于默认读者优先加锁,导致写者一直无法得到读写锁也写不进内容,而读者也一直读的是空的黑板:

【Linux】第十三篇:线程池与单例模式_第5张图片

现在我们需要调整读写锁的属性,改为写者优先

#include 
#include 
#include 
#include
#include
#include
using namespace std;

pthread_rwlock_t rwlock;
string* blackboard;
void* reader(void* arg)
{
    cout<<"start read"<<endl;
    while(true)
    {
        pthread_rwlock_rdlock(&rwlock);//读者的锁是可以累加的
        cout<<pthread_self()<<"->read: "<<*blackboard<<endl;
        pthread_rwlock_unlock(&rwlock);
        sleep(1);
    }
    return nullptr;
}

void* writer(void* arg)
{
    while(true)
    {
        pthread_rwlock_wrlock(&rwlock);
        cout<<"------------------"<<endl;
        cout<<"start writing"<<endl;
        string tmp=*blackboard;
        //blackboard->clear();
        *blackboard='A'+rand()%26;
        cout<<"modify blackboard from \""<<tmp<<"\" to \""<<*blackboard<<"\""<<endl;
        pthread_rwlock_unlock(&rwlock);  
        cout<<"write over"<<endl;
        cout<<"------------------"<<endl;
        sleep(1);
    }
    return nullptr;
}

int main()
{
    srand(unsigned(time(nullptr)));
    
    //自己修改读写锁属性
    pthread_rwlockattr_t attr;
    //初始化读写锁属性
    pthread_rwlockattr_init(&attr);
    //属性修改为写者优先
    pthread_rwlockattr_setkind_np(&attr,PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP);

    pthread_t rtid[4],wtid;
    int i;
    blackboard=new string("None");
    pthread_rwlock_init(&rwlock,&attr);

    //创建4个读者线程
    for(i=0;i<4;++i)
    {
        pthread_create(rtid+i,nullptr,reader,nullptr);
    }
    //创建写者线程
    pthread_create(&wtid,nullptr,writer,nullptr);

    //回收线程资源
    pthread_join(wtid, NULL);
 
    for(i = 0; i < 4; ++i)
    {
        pthread_join(rtid[1], NULL);
    }
    pthread_rwlockattr_destroy(&attr);
    pthread_rwlock_destroy(&rwlock);

    return 0;
}

【Linux】第十三篇:线程池与单例模式_第6张图片

4.自旋锁简介

自旋锁比较适用于锁使用者保持锁时间比较短的情况

正是由于自旋锁使用者一般保持锁时间非常短,因此选择自旋而不是睡眠是非常必要的,自旋锁的效率远高于互斥锁。

信号量和读写信号量适合于保持时间较长的情况,它们会导致调用者睡眠,因此只能在进程上下文使用,而自旋锁适合于保持时间非常短的情况,它可以在任何上下文使用。

如果被保护的共享资源只在进程上下文访问,使用信号量保护该共享资源非常合适,如果对共享资源的访问时间非常短,自旋锁也可以。

但是如果被保护的共享资源需要在中断上下文访问(包括底半部即中断处理句柄和顶半部即软中断),就必须使用自旋锁。自旋锁保持期间是抢占失效的,而信号量和读写信号量保持期间是可以被抢占的。自旋锁只有在内核可抢占或SMP(多处理器)的情况下才真正需要,在单CPU且不可抢占的内核下,自旋锁的所有操作都是空操作。

总之,自旋锁是一种对多处理器相当有效的机制,而在单处理器非抢占式的系统中基本上没有作用。自旋锁在SMP系统中应用得相当普遍。

在许多SMP系统中,允许多个处理机同时执行目态程序,而一次只允许一个处理机执行操作系统代码,利用一个自旋锁可以很容易实现这种控制.一次只允许一个CPU执行核心代码并发性不够高,若期望核心程序在多CPU之间的并行执行,将核心分为若干相对独立的部分,不同的CPU可以同时进入和执行核心中的不同部分,实现时可以为每个相对独立的区域设置一个自旋锁.

  • 接口函数
#include 

int pthread_spin_destroy(pthread_spinlock_t *lock);

int pthread_spin_init(pthread_spinlock_t *lock, int pshared);

int pthread_spin_lock(pthread_spinlock_t *lock);

int pthread_spin_unlock(pthread_spinlock_t *lock);

pshared的取值及其含义:

  • PTHREAD_PROCESS_SHARED:该自旋锁可以在多个进程中的线程之间共享。
  • PTHREAD_PROCESS_PRIVATE: 仅初始化本自旋锁的线程所在的进程内的线程才能够使用该自旋锁。

自旋锁的基本形式如下:

spin_lock(&mr_lock);

//临界区
//访问时间较短

spin_unlock(&mr_lock);

— end —

青山不改 绿水长流

你可能感兴趣的:(Linux,单例模式,线程池,Linux)