Linux:详解多线程(线程池读写锁和CAS无锁编程)

Posted It‘s so simple

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Linux:详解多线程(线程池读写锁和CAS无锁编程)相关的知识,希望对你有一定的参考价值。


1. 线程池

1.1 相关概念

概念:

一种线程使用模式。线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。可用线程数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets等的数量。

线程池它本质上是包含了一个线程安全的队列和一大堆的线程
在这里插入图片描述
线程池当中的线程都是从线程池当中的线程安全队列中获取元素进行处理,在逻辑上属于消费者线程,线程当中的线程执行的是同样的入口函数,并且执行的是同样的代码。

应用场景:

  • 需要大量的线程来完成任务,且完成任务的时间比较短。 WEB服务器完成网页请求这样的任务,使用线程池技术是非常合适的。因为单个任务小,而任务数量巨大,你可以想象一个热门网站的点击次数。 但对于长时间的任务,比如一个Telnet连接请求,线程池的优点就不明显了。因为Telnet会话时间比线程的创建时间大多了。
  • 对性能要求苛刻的应用,比如要求服务器迅速响应客户请求。
  • 接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用。突发性大量客户请求,在没有线程池情况下,将产生大量线程,虽然理论上大部分操作系统线程数目最大值不是问题,短时间内产生大量线程可能使内存到达极限,出现错误。

1.2 线程池的实现

我们都知道一个服务端后台的代码执行的业务是非常之庞大的,如果一个线程池只能处理一个类型的数据,则需要对每一个业务数据都创建一个线程池,这无疑是消耗巨大的一件事情。那么问题来了:怎么让一个线程池,可以处理多种多样的问题?

首先,我们需要实现一个线程安全的队列。实现线程安全,我们可以采用互斥锁+条件变量来实现,也可以采用信号量来实现。

其次,为了让该线程安全队列中的线程在出队的时候可以处理多种多样的问题,我们必须规定该队列的元素类型① 待要处理的数据② 处理该数据的方法

并且由于线程池中的线程都是从线程安全队列中获取元素进行处理的,因此当我们往队列中插入我们所规定的元素类型时(即Push方法),就相当于一个生产者线程。

然后当线程池中的多个线程都被创建出来时,都会从队列中获取元素并处理(即Pop方法),就相当于一个消费者线程。因此,线程池的实现逻辑也和生产者和消费者的逻辑类似。

总结一下,要实现一个线程池,我们需要:

① 定义队列的元素类型,该类型包括数据的类型(本题中我们使用int,但实际业务中就可能是一些自定义类型的数据)和处理数据的方法(函数指针),该方法就是一个函数,因此我们只需要将其对应的函数指针传入即可。

② 用一个类来表示线程池,该类中的成员变量主要有:

  • 用一个队列来存储我们之前定义的元素类型。
  • 为了实现队列的线程安全,我们采用条件变量+互斥锁来实现,使用一个互斥锁来保证在操作队列时不同线程之间是互斥的,使用两个条件变量来保证生产者和消费者之间的同步。
  • 线程池的容量大小限制
  • 由于线程池在启动线程的时候,可能会有一部分线程启动失败,因此我们需要一个变 量来记录启动成功的线程数量
  • 当线程池中的线程处理完自己所需要处理的函数的时候,就要进行线程退出,但是> 由于线程池中有众多的线程,每个线程之间是并行执行的,因此当我们整个线程池要退出> 的时候,就需要将某些还阻塞在PCB等待队列中的线程全部唤醒,因此就需要一个标志变量> 来标志,让当前还在运行的线程进行退出。

成员方法主要有:

  • 首先是构造函数,在这个里面初始化各个成员变量
  • 析构函数,该函数中析构创建出的成员变量
  • 线程创建函数,该函数用来创建出线程池中的线程并将其初始化
  • 线程启动函数,该函数用来使线程池中的线程从线程安全队列中获取元素并处理(消费者线程)
  • Pop函数,弹出队列中的元素
  • Push函数,在主线程中用来向线程池中线程安全队列插入对应的要处理的数据(生产者线程)
  • 线程退出函数,该函数用来将目前所有处于PCB等待队列的线程全部唤醒(防止在析构线程池的时候,还有线程处于PCB等待队列中未进行退出,而浪费程序的资源)。

实现代码如下:

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


using namespace std;

//我们将int作为要处理的数据类型
typedef int DataType;
//定义函数指针作为处理该数据的方法,返回值是void,参数是DataType
typedef void (*Handler)(DataType);

//首先我们需要定义一个队列的元素类型
class QueueType
{
    public:
        QueueType(DataType d,Handler h):data_(d),handler_(h)
    {}

        ~QueueType()
        {}

        //调用此接口,即可对数据进行处理,本质上就是调用函数指针处理该数据
        //当线程池中的线程从线程安全队列中拿到元素后,就调用该方法来处理数据
        void dealData()
        {
            handler_(data_);
        }

    private:
        DataType data_;
        Handler handler_;
};

//然后接下来我们就需要定义出线程池的类了
/*
 *首先,我们需要知道该类需要哪些成员变量
 * 1. 要有一个线程安全的队列,线程安全的实现采用互斥锁+条件变量
 * 2. 线程池的容量大小限制
 * 3. 由于线程池在启动线程的时候,可能会有一部分线程启动失败,
 * 因此我们需要一个变量来记录启动成功的线程数量
 * 4. 当线程池中的线程处理完自己所需要处理的函数的时候,就要进行线程退出,
 * 但是由于线程池中有众多的线程,每个线程之间是并行执行的,
 * 因此当我们整个线程池要退出的时候,就需要将某些还阻塞在PCB等待队列中的线程全部唤醒,
 * 因此就需要一个标志变量来标志,让当前还在运行的线程进行退出。
 */

/*
 * 其次,我们还要考虑当前这个线程池需要实现哪些功能
 * 1.构造函数:初始化成员变量
 * 2.析构函数:析构掉成员变量
 * 3.Push方法:将待要处理数据存入到线程安全队列中,相当于生产线程
 * 4.Pop方法:弹出当前队列的元素,注意它不是消费者,它只是单纯实现了元素的弹出而已
 * 5.线程创建函数:在线程池中创建线程
 * 6.线程池启动函数:启动线程池中的线程从队列中获取元素进行处理
 * 7.线程退出函数:当线程池要退出的时候,将所有处于PCB等待队列的线程唤醒,并使其退出。
 * */ 

#define PTHREADNUM 4
#define QueueSize 10

class MyPthreadPool
{
    public:
    MyPthreadPool()
    {
        capacity_ = QueueSize;
        pthread_mutex_init(&lock_,NULL);
        pthread_cond_init(&prod_,NULL);
        pthread_cond_init(&cons_,NULL);

        pthreadNum_ = PTHREADNUM;
        quitFlag_ = false;

    }
        ~MyPthreadPool()
        {
            pthread_mutex_destroy(&lock_);
            pthread_cond_destroy(&prod_);
            pthread_cond_destroy(&cons_);
        }

        //线程创建函数--传入需要创建的线程数,返回创建成功的线程数
        int threadCreate(int num = PTHREADNUM)
        {
            if(num != PTHREADNUM)
               pthreadNum_ = num;

            //记录创建失败的线程数量
            int failed = 0;
            for(int i = 0; i < pthreadNum_; ++i)
            {
                //我们在这里不需要线程的标识符,因此也就不需要对其进行保持
                pthread_t tid;
                /*
                 * 注意线程池中的线程都执行的是同样的入口函数。
                 * 并且我们要令线程池启动函数为静态成员函数
                 * 为什么呢?还记得线程入口函数的标准格式吗?
                 * 标准格式为:void* PthreadEntry(void* arg)
                 * 若是定义为普通的成员变量,那么他就是这样的:
                 * void* PthreadEntry(MyPthreadPool* const this,void* arg)
                 * 里面就会有一个隐藏的this指针,不符合格式,因此采用静态的成员函数
                 */
                int ret = pthread_create(&tid,NULL,PthreadPoolStart,this);
                if(ret < 0)
                {
                    ++failed;
                    printf("i create failed, i am %d\\n",i);
                    //创建线程失败了,不能返回,要继续进行创建
                    continue;
                }
            }
            //printf("failed:%d\\n",failed);

            pthreadNum_ -= failed;

            //若结果等于0,则说明创建失败
            if(pthreadNum_ == 0)
                return -1;

            return pthreadNum_;
        }
        //线程池启动函数,相当于是线程入口函数
        static void* PthreadPoolStart(void* arg)
        {
            //我们直接让线程分离掉,这样就不需要在外面进行线程等待了
            pthread_detach(pthread_self());
            MyPthreadPool* mtp = (MyPthreadPool*) arg;

            /*
             * 1. 在该函数中我们需要获取队列中的元素,并对其进行处理。
             * 2. 由于有多个线程,因此我们必须保证线程安全
             */ 
            while(1)
            {
                pthread_mutex_lock(&mtp->lock_);

                while(mtp->que_.empty())
                {

                    //注意这里一定要先判断是否该退出了
                    if(mtp->quitFlag_)
                    {
                        //走到这里说明已经拿到锁了,要对锁进行释放,
                        //要不然程序就可能卡死。
                        pthread_mutex_unlock(&mtp->lock_);
                        pthread_exit(NULL);
                    }
                    pthread_cond_wait(&mtp->cons_,&mtp->lock_);
                }

                QueueType* q;
                mtp->Pop(&q);

                pthread_mutex_unlock(&mtp->lock_);
                pthread_cond_signal(&mtp->prod_);

                //当我们已经拿到了队列的元素之后,我们就可以直接的释放锁
                //和唤醒对应的线程了,
                //不必等到处理完该元素后,再释放;
                //那么由于线程之间是并行处理的,多个线程都在等着抢这一把锁,
                //如果在处理完该元素再释放,那么程序的效率就会下降,得不偿失。

                q->dealData();


                //这里有一点很容易忽略,由于传入到队列中的元素都是在堆上动态开辟的空
                //间,所以,我们处理完之后,一定要进行释放,否则就会造成内存泄漏。
                delete q;

            }
            return NULL;
        }

        //在这里面只是单纯的弹出元素
        //在这里不加锁的原因只是为了让所有线程退出的时候,都只能从线程入口函数进行退出
        void Pop(QueueType** q)
        {
            *q = que_.front();
            que_.pop();
        }

        int Push(QueueType* q)
        {
            /*
             * 1. 要往线程池中的队列插入元素,要实现线程安全,使用互斥锁+条件变量
             * 2. 当队列已经满了的时候,就进入生产者的PCB等待队列中等待被唤醒
             * 3. 这里需要注意的是,我们是调用一个线程往队列中不停的插入数据,所以当线
             * 程池退出的时候,不能在当前线程进行退出,否则就会造成程序卡死,因为没有
             * 线程进行对其进行等待,回收不到资源,若该线程为主线程,则会变为僵尸线
             * 程,其他工作线程将永远不会退出。
             */ 

            pthread_mutex_lock(&lock_);

            while(que_.size() >= capacity_)
            {
                if(quitFlag_)
                {
                    pthread_mutex_unlock(&lock_);
                    return -1;
                }
                pthread_cond_wait(&prod_,&lock_);
            } 

            //当我们调用线程池退出函数的时候,所有正在写的行为都要进行停止,
            //所有读的行为也要停止,因此,我们还需在这里再进行一次判断。
            if(quitFlag_)
            {
                pthread_mutex_unlock(&lock_);
                return -1;
            }

            que_.push(q);

            pthread_mutex_unlock(&lock_);
            pthread_cond_signal(&cons_);
            return 0;
        }

        //线程池退出函数
        void ThreadPoolExit()
        {
            /*
             * 这里需要注意的是,我们要在这里改变quitFlag_的值,
             * 而这个变量,在其他的线程中也都正在被访问,
             * 因此,这里要对其进行加锁操作
             */ 
            pthread_mutex_lock(&lock_);

            if(quitFlag_ == false)
                quitFlag_ = true;

            pthread_mutex_unlock(&lock_);


            //这里我们需要使用broadcast来唤醒所有处于PCB等待队列中的线程,
            //让他们在各自执行的语句中进行线程退出。
            pthread_cond_broadcast(&cons_);
            //这里需要明白的是,我们不需要唤醒生产者的PCB等待队列,
            //因为自始至终往队列中插入元素的只有一个线程,本代码中是主线程

        }


    private:
            queue<QueueType*> que_;
            //线程池的容量
            size_t capacity_;

            pthread_mutex_t lock_;

            pthread_cond_t prod_;
            pthread_cond_t cons_;

            //线程池正常启动的线程数量
            int pthreadNum_;

            //线程池退出的标志位
            bool quitFlag_;
};

//定义处理数据的方法
void dealNum(DataType t)
{
    printf("i am deadlNum func ,i deal %d\\n",t);
}

int main(int argc,char* argv[])
{
    /*
     * 1.首先我们需要在堆上开辟一个线程池的空间
     * 2.其次我们还需定义一个处理用户传入数据的函数
     * 3.我们需要在堆上开辟出空间,用来保持我们待向线程池插入的队列的元素类型
     * 4.我们可以利用命令行变量特性来确定每次线程池中线程的数量
     *
     */
    //用来获取命令行变量中用户输入的线程池的数量
    int thread_num = 0;
    for(int i = 0; i < argc;++i)
    {
        //printf("argv[%d] : %s\\n",i,argv[i]);
        if(strcmp(argv[i],"-thread_num")== 0 && i + 1 < argc)
        {
            thread_num = atoi(argv[i+1]);
            //printf("thread_num is %d\\n",thread_num);
        }
    }

    //定义线程池
    MyPthreadPool* mtp = new MyPthreadPool();
    if(mtp == NULL)
    {
        printf("create PthreadPool failed\\n");
        return 0;
    }
    
    //初始化线程池
    int ret = mtp->threadCreate(thread_num);
    if(ret < 0)
    {
        printf("thread create failed\\n");
        return 0;
    }

    printf("create success,thread_num is %d\\n",ret);

    //向线程池的线程安全队列中插入数据
    for(int i = 0; i < 10000; ++i)
    {
        QueueType* q = new QueueType(i,dealNum);

        if(q==NULL)
            continue;

        //关于q的释放,会在线程池的线程处理完成之后,
        //对对其进行释放,所以不用考虑内存泄漏的问题。
        mtp->Push(q);
    }
    //注意这里一定要sleep一下,由于线程是并行的,
    //可能在主线程调用线程池退出函数的时候,
    //线程池中的线程还没有来得及从线程安全队列中获取获取元素进行处理,
    //就执行到了退出逻辑,就又可能造成程序的阻塞,比如最后几个数据处理不上的情况。

    sleep(2);

    //插入完之后,调用线程池退出函数
    mtp->ThreadPoolExit();


    delete mtp;

    while(1)
    {
        sleep(1);
    }
    return 0;
}

运行结果:

在这里插入图片描述
在这里插入图片描述
查看结果发现并没有出现什么问题,我们再来看一下它的调用堆栈信息
在这里插入图片描述
这个时候我们可以发现线程池中的4个线程全部终止掉了,说明程序是没有问题的!!!

2. 读写锁

2.1 读写锁的相关概念

首先我们要知道读写锁所适用的场景是:存在大量读,少量写的情况下,使用读写锁。

读写锁所实现的就是多个程序可以并行的对临界资源进行读操作,并且程序是不会产生二义性的结果。

读写锁有三种模式:读模式、写模式、不加锁。

① 以读模式打开读写锁

以读模式打开的话,则多个线程可以并行的对临界资源进行操作,是不需要实现互斥的,因为读写锁的内部有一个引用计数,来统计当前以读模式打开读写锁的线程数量,通过引用计数来判断当前是否还有线程以读模式打开读写锁,本质上是想判断读写锁什么时候是空闲的,没有被占用的。

② 以写模式打开读写锁

以写模式打开的话,就相当于是互斥锁。

面试题:

现在有四个线程,线程A、B、C、D,其中线程A和B是以读模式打开的此锁,并且已经拥有了读写锁,现在线程C想要以写模式打开读写锁,由于读写锁已经被别的线程拿走了,所以线程C进入阻塞状态,那么此时又来了一个线程D,线程D想以读模式拿到这把互斥锁,问:线程D可以拿到吗?

解答:

这个问题从理论上来讲线程D是可以拿到读写锁的,,但是从实际上来说是不可以拿到的,试想一下,如果可以拿到,那么后面来的所有线程都是以读模式拿到读写锁,那么此时被阻塞的线程C什么时候才能运行,肯定要等其他以读模式打开的线程都运行完之后才能拿到,这在实际情况中是根本不允许的,因此,一旦有以写模式打开读写锁的线程出现,后面来的所有以读模式访问读写锁的线程均会被阻塞掉。

2.2 读写锁的接口

由于读写锁的接口中的类型和互斥锁一样,这里就不再做详细的解释。

读写锁的类型:pthread_rwlock_t

①创建读写锁

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_tryrdlock(pthread_rwlock_t *rwlock);

以写模式打开

int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);

④解锁接口

int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);

3. CAS无锁编程

无锁 CAS(Compare and swap,比较和交换)是一种乐观的并发控制策略,它假设对资源的访问是没有冲突的,遇到冲突进行重试操作直到没有冲突为止。这种设计思路和数据库的乐观锁很相像。在硬件层面,大部分的处理器都支持原子化的 CAS 指令。也就是说比较和交换这个操作是有处理器来保证是原子操作的。

悲观锁和乐观锁

实际上乐观锁和悲观锁是基于线程并发竞争的角度来说的,悲观锁就是假设每次操作都悲观的认为会发生线程竞争,不加锁就会导致程序结果错误乐观锁就假设每次操作都乐观的认为不会发生线程竞争,所以不需要上锁,因此CAS被称为无锁编程,实际上是一种乐观锁的体现。

简单来说,CAS 需要你额外给出一个期望值,也就是你认为这个变量现在是什么样子的。如果不是你想象的那样,则说明它已经被别人修改过了。你就重新读取,再次尝试修改就好了。

优势:无锁更优的性能,没有死锁风险。

缺点:它将使调用者处理竞争问题(重试、回退、放弃),而在锁中能自动处理竞争问题(线程在获得锁之前将一直阻塞)。

无锁编程的实现原理是

  • 线程A从主内存中读入变量count作为值V;
  • 线程A读取count的最新值,作为期望值E
  • 线程A把值(V)和期望(E)比较是否相等,相等就把新值(N)写回主内存,不相等就回到操作1
    在这里插入图片描述

本小节CAS无锁编程的相关概念转载至无锁编程CAS并发编程-无锁CAS之原子变量这两位大佬写的博客,更为详细的可以去看看这两篇文章。

以上是关于Linux:详解多线程(线程池读写锁和CAS无锁编程)的主要内容,如果未能解决你的问题,请参考以下文章

多线程(进阶篇)

无锁的同步策略——CAS操作详解

Linux-读写锁原理-CAS无锁编程原理和缺陷

Linux篇第十六篇——多线程(读写锁+线程池)

Linux篇第十六篇——多线程(读写锁+线程池)

Linux篇第十六篇——多线程(读写锁+线程池)