Linux《线程同步和互斥(上)》

在之前的线程概念当中我们已经了解了Linux当中线程的基本概念和实现原理,并且还了解了pthread库当中的线程控制相关的接口;了解了线程创建、等待等操作,那么接下来在本篇当中将继续进行线程的学习,在此重点了解的是线程当中的同步和互斥,我们会了解到线程同步和互斥的概念以及如何实现线程同步和互斥 ,重点会了解到条件变量 两个方面,并且会试着封装系统当中提供的系统调用来封装出自己的库,最后会介绍一个实际生产当中的模型------生产消费模型 ,基于该模型实现阻塞队列环形队列两个模型。接下来就开始本篇的学习吧!!!



1. 线程互斥

1.1 线程互斥概念

首先我来了解线程互斥 的概念,当多个执行流同时访问对应的临界资源的时候就会出现数据同时访问的情况,那么这时就可能回引发数据不一致的问题,这就和之前我们在进程间通信当中学习信号量时了解到的是一样的,只不过当前数据不一致的从进程变为了线程

在之前的进程当中是通过System V中的信号量来进行线程之间的同步互斥的,那么在线程当中又是如何实现互斥的呢?

实际上线程使用的方式和之前进程也是类似的,之前我们就提到过了在多进程当中当多进程同时访问临界支援的时候,那么这时候就可以给对应的临界区进行加锁,实际上在线程当中实现互斥的方式也是加锁,并且在pthread库当中实现对应的函数来实现对临界区的加锁

1.2 互斥量mutex

先来看以下的代码来感受未进行线程互斥操作时,会有什么问题:

cpp 复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>
int cnt = 100;

void *Routine(void *args)
{
    while (1)
    {
        if (cnt > 0)
        {
            usleep(1000);
            --cnt;
            std::cout << "进行抢票操作,票数变为" << cnt << std::endl;

        }
        else
        {
            break;
        }
    }
    return nullptr;
}

int main()
{
    pthread_t t1, t2, t3;
    pthread_create(&t1, nullptr, Routine, nullptr);
    pthread_create(&t2, nullptr, Routine, nullptr);
    pthread_create(&t3, nullptr, Routine, nullptr);

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);

    return 0;
}

在以上的代码当中我们使用接口创建了3个线程之后,让这三个线程同时对一个全局的变量cnt进行操作,那么这就可以简单的模拟在不同的平台下对一个班车的车票进行售卖的操作,运行程序接下来看看执行的结果是什么样的。

通过以上的输出结果就可以看出确实能让不同的线程交替的对一个变量进行操作了,但是这时我们也发现了一个问题就是为什么最后的cnt变量会被减到-2呢,在代码当中我们不是将对应的判断条件限制为cnt大于0吗,当对应的cnt值为0的时候不应该就要跳出while循环吗?

实际上是当线程在运行的时候在usleep(1000)运行的时候是有时间间隔的,那么这时候其他的线程也可能在访问对应的资源,那么这就可能会导致当一个线程对cnt进行修改的时候,另一个线程还未得到修改之后的值,这时就会导致数据不一致的问题。

其实本质上出现以上的问题就是因为--cnt不是原子的,那么这时才会出现数据不一致的问题。

那么这时候你就会好奇了,为什么--的操作不是原子的呢,该操作不是只需要进行一步即可实现吗?

实际上只是我们认为是这样的,在操作系统当中该语句的实现其实是分为多步进行的,具体的步骤如下所示:
• load :将共享变量ticket从内存加载到寄存器中
• update : 更新寄存器里面的值,执行-1操作
• store :将新值,从寄存器写回共享变量ticket的内存地****址

因此以上的cnt--一句代码就可以理解为以下的形式,首先是当前所在的线程会将内存当中的数据加载到CPU当中,CPU当中对该数据进行处理之后将其的值保存到对应的寄存器当中,之后再将寄存器当中的值写回到内存中。

那么这时候再来看以上的代码就会发现实际上出现以上问题的本质是在cnt进行判断该的时候数据是不一致的,并且出现数据不一致的问题实际上cnt--这一句虽然不是产生的主要原因,但是也是和这有关。

那么为了解决在以上多执行流在访问数据的时候出现数据不一致的问题,那么在Linux当中就提供了互斥量的来实现以上提到锁的概念。

有了互斥量之后就可以将临界资源当中的临界区进行保护

在pthread库当中互斥量对应的接口如下所示:

首先是创建互斥量的方式有两种分别是静态创建动态创建

cpp 复制代码
//静态分配
pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;
cpp 复制代码
 #include <pthread.h>

int pthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr);

参数:
mutex为要进行初始化的的互斥锁的指针
attr为互斥锁的属性,一般置为NULL即可

返回值:
当返回值为0时表示互斥锁初始化成功,否则为初始化失败

在以上使用动态分配的时候在最后就需要进行销毁对应的接口如下所示:

cpp 复制代码
#include <pthread.h>

int pthread_mutex_destroy(pthread_mutex_t *mutex);

参数:
mutex是需要进行销毁的互斥锁

返回值:
当销毁成功时函数的返回值为0,为其他值为失败

有了以上的互斥量之后接下来就可以使用一下的函数进行加锁和解锁,对应的接口如下所示:

cpp 复制代码
#include <pthread>

//加锁接口
int pthread_mutex_lock(pthread_mutex_t *mutex);
//解锁接口
int pthread_mutex_unlock(pthread_mutex_t *mutex);

参数:以上两个函数的返回值mutex都为加锁使用的mutex互斥量

返回值:当返回值为0表示加锁或者解锁成功,否则就是失败

有了以上的函数接下来就可以使用以上的接口来将我们模拟实现抢票的代码进行加锁,让其不再出现数据不一致的问题,实现的代码如下所示:
使用静态分配:

cpp 复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>
int cnt = 100;


pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;
void *Routine(void *args)
{
    while (1)
    {
        pthread_mutex_lock(&mutex);
        if (cnt > 0)
        {
            usleep(10000);
            cnt--;
            std::cout << "进行抢票操作,票数变为" << cnt << std::endl;
            pthread_mutex_unlock(&mutex);
        }
        else
        {
            pthread_mutex_unlock(&mutex);
            break;
        }
    }
    return nullptr;
}

int main()
{
    pthread_t t1, t2, t3;
    pthread_create(&t1, nullptr, Routine, nullptr);
    pthread_create(&t2, nullptr, Routine, nullptr);
    pthread_create(&t3, nullptr, Routine, nullptr);

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);

    return 0;
}

使用动态分配:

cpp 复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>
int cnt = 100;


pthread_mutex_t mutex;
void *Routine(void *args)
{
    while (1)
    {
        pthread_mutex_lock(&mutex);
        if (cnt > 0)
        {
            usleep(10000);
            cnt--;
            std::cout << "进行抢票操作,票数变为" << cnt << std::endl;
            pthread_mutex_unlock(&mutex);
        }
        else
        {
            pthread_mutex_unlock(&mutex);
            break;
        }
    }
    return nullptr;
}

int main()
{

    pthread_mutex_init(&mutex,nullptr);
    pthread_t t1, t2, t3;
    pthread_create(&t1, nullptr, Routine, nullptr);
    pthread_create(&t2, nullptr, Routine, nullptr);
    pthread_create(&t3, nullptr, Routine, nullptr);

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);

    pthread_mutex_destroy(&mutex);

    return 0;
}

运行的效果如下所示:

这时就发现不再会出现数据不一致的问题了。

1.3 锁本质原理理解

以上我们已经了解了在出现多执行流数据不一致的问题时候,只需要使用锁将临界资源当中的临界区进行加锁即可,但是以上我们只是了解到锁能实现以上的功能,但是现在我们还不知道锁的原理是什么,那么接下来就来了解锁的本质。

首先来看以上的代码,在代码当中红色框出来的地方就是对应的临界区,在临界区当中只会有一个执行流会在该内部执行。

实际上在临界区当中的代码就是要进行保护的代码,这时只要在临界区当中存在一个线程,那么这时其他的线程就无法进入到线程当中,但是这时你可能就会想到要是线程被切换了呢,那么这时候其他的线程能进入到临界区当中吗?

其实无论是线程是在正常的运行 或者线程被切换了,只要有线程在临界区当中运行,那么这时其他的线程就无法得到对应的锁来进入到临界区当中。这也就说明即使线程被切换了,那么锁还是会被线程持有,只有等到该线程再次被调度并且出了临界区之后才会将锁释放,那么这时候其他的线程才能得到锁

实际上可以通过以下的现实场景来理解以上的情景。

以上的图示就表示的是当前存在一个超级自习室,在该自习室当中只能让一个同学进行自习,那么这时候如果你是排队当中的第一个同学,那么你就可以第一个进入到自习室当中,但是要注意的是自习室是有一个大门的,大门需要钥匙才能进入到自习室当中,当自习室当中的人将自习室锁起来之后自习室是无法被其他的人打开的。那么这时就可以将临界区类比为自习室,将一个个的线程类比为同学,当一个同学进入到自习室之后如果要去吃饭的,那么他就想着我要是出去的时候把钥匙放回去,那么这时候就没办法回来的时候第一时间进入到自习室当中了,毕竟这时候就需要重新的排队了,那么这时你就想着我把钥匙带在身上不就好了嘛,这样其他人就没办法再和我竞争了,这时其实就可以将线程在被切换的时候类比以上的情况。

以上我们已经大致的理解了锁的作用,但是目前我们还是感性的理解,那么接下来就来理性的理解锁的实现原理是什么样的。

之前我们在提到一个变量的--操作不是原子的,那么在锁当中实际是执行了以下的操作:

通过以上的加锁和解锁的汇编代码距可以看出加锁和解锁的操作是原子的,实际上为了实现锁的原子性操作在操作系统当中提供了exchange和swap等的指令。本质上内存当中会使用一个变量来表示当前内存对应的临界区当中是否有执行流访问,那么这时候通常在无执行流访问的时候设置为1,否则就为0。

当线程要访问对应的临界区的时候就不是再将对应的标识变量拷贝出来,修改之后再写入,而是和对应的变量进行交换,那么当临界区当中无执行流访问的时候就会将线程当中的0交换到内存当中。

通过以上就可以看出锁实现的实现本质是将内存当中的变量交换对应线程上寄存器当中,而不是拷贝,那么这时就能保证对应的变量只有一份,而谁申请到了该变量就能访问临界区。

1.4 互斥锁封装

以上我们已经了解了在操作系统当中是如何使用锁来实现线程互斥的,并且还了解了锁的本质和原理,那么接下来就来试着来将pthread库当中提供的mutex互斥锁的接口封装为面向对象的。

实现的代码如下所示:

cpp 复制代码
#include <iostream>
#include <pthread.h>

namespace MutexModule
{
    class Mutex
    {
    public:
        Mutex()
        {
            int n = pthread_mutex_init(&_mutex, nullptr);
            (void)n;
        }
        Mutex(const Mutex &) = delete;
        Mutex &operator=(const Mutex &) = delete;

        ~Mutex()
        {
            int n = pthread_mutex_destroy(&_mutex);
            (void)n;
        }

        // 加锁
        void Lock()
        {
            int n = pthread_mutex_lock(&_mutex);
            (void)n;
        }

        // 解锁
        void UnLock()
        {
            int n = pthread_mutex_unlock(&_mutex);
            (void)n;
        }

    private:
        pthread_mutex_t _mutex;
    };

    // 创建LockGard对象即可在其初始化和销毁的时候调用加锁和解锁
    class LockGard
    {

    public:
        LockGard(Mutex &mutex)
            : _mutex(mutex)
        {
            _mutex.Lock();
        }

        ~LockGard()
        {
            _mutex.UnLock();
        }

    private:
        Mutex &_mutex;
    };
}

有了以上的Mutex类之后接下来就可以在用来的抢票代码当中转为使用我们自己创建的Mutex库了。

cpp 复制代码
#include "Mutex.hpp"
#include <unistd.h>

int cnt = 100;
using namespace MutexModule;
Mutex mutex;

void *Routine(void *args)
{
    while (1)
    {
        {
            LockGard lock(mutex);
            if (cnt > 0)
            {
                usleep(10000);
                cnt--;
                std::cout << "进行抢票操作,票数变为" << cnt << std::endl;
            }
            else
            {
                break;
            }
        }
    }
    return nullptr;
}

int main()
{

    pthread_t t1, t2, t3;
    pthread_create(&t1, nullptr, Routine, nullptr);
    pthread_create(&t2, nullptr, Routine, nullptr);
    pthread_create(&t3, nullptr, Routine, nullptr);

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);


    return 0;
}

以上实现的原理就是一个对象是会在其出作用域的时候会自动地调用析构函数,那么这时候就会使得无论是cnt--跳出还是break跳出都会调用LockGuard析构函数从而来实现锁地解锁。

2. 线程同步

以上我们在了解了线程地互斥之后那么接下来就来继续了解线程地同步,实际上以上我们实现的代码确实能实现抢票的要求并且最-后也不会出现抢票的结果出现负数的问题,但是目前有一个问题即使,如果在抢票过程当中将进行操作的线程标识出来,那么久会发现是一直是线程1在进行操作其他的线程都一直无法进入到临界区当中。

那么这时候我们久要分析出现问题的原因了,在此我们还是通过自习室的例子来引入,以上我们知道你进入自习室之后是会拥有锁的,那么接下来当你出了自习室之后你把钥匙放回去的时候,接下来你还是距离钥匙最近的,那么如果你想着要是放回去那么接下来久需要重新排队了,那么这时候会花费非常多的时间,因此接下来你有重新拿着钥匙回到了自习室,周而复始其他的同学久一直无法进入到自习室当中了。

那么通过以上当中的示例久可以映射出线程同步的概念,在多个线程同时访问同一临界区当中的资源时,如果是进行纯互斥其实也是没问题的,但是这样实际上设计是不太合理的,毕竟实现出多个线程,那么就应该让线程都给合理的时间片来访问。

以上的自习室的例子当中如何合理的让所有的同学都能访问到自习室当中呢,这就需要让每个同学出自习室之后不能再立刻拿到自习室的钥匙而是要排到自习室外的队伍末尾这样就可以让大家都能在等待之后进入自习室。

2.1 条件变量

那么在线程当中要解决多个线程访问同一资源的时候会出现一个线程一直访问资源的问题就引入了条件变量的概念,条件变量的概念如下所示:
条件变量(Condition Variable) 就是线程同步里的一种机制,它主要用来让线程 等待某个条件成立,再继续往下执行。

以下的情况可以使用到条件变量:
• 当⼀个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。

• 例如⼀个线程访问队列时,发现队列为空,它只能等待,只到其它线程将⼀个节点添加到队中。这种情况就需要用到条件变量。

2.2 竞争和同步

同步 :在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步

竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解

2.3 线程同步接口

在pthread库当中提供了对应的接口来实现线程的同步,接口如下所示:

将条件变量创建出来和mutex类似初始化的方式也是有两种分别是静态初始化的动态初始化。对应的接口如下所示:

静态初始化:

cpp 复制代码
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

动态初始化:

cpp 复制代码
#include <pthread.h>

int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr);

参数:
cond:指向要初始化的条件变量
attr:条件变量属性,通常传 NULL,表示使用默认属性

返回值:
成功返回 0
失败返回错误码

销毁接口:

cpp 复制代码
 #include <pthread.h>

int pthread_cond_destroy(pthread_cond_t *cond);

参数:
cond:指向要销毁的条件变量

返回值:
成功返回 0
失败返回错误码

线程等待和唤醒:

cpp 复制代码
#include <pthread.h>

 
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);

参数:
cond:条件变量。
mutex:互斥锁,保证等待条件时的共享数据一致性。

返回值:
成功返回 0。
失败返回错误码(如 EINVAL)。

唤醒一个线程接口:

cpp 复制代码
#include <pthread.h>

int pthread_cond_signal(pthread_cond_t *cond);
参数:
cond:条件变量

返回值:
成功返回 0。
失败返回错误码(如 EINVAL)。

唤醒所有的线程:

cpp 复制代码
#include <pthread.h>

int pthread_cond_broadcast(pthread_cond_t *cond);

参数:
cond:条件变量

返回值:
成功返回 0。
失败返回错误码(如 EINVAL)。

有了以上的接口那么接下来就可以使用以上的接口将原来实现的模拟抢票的代码实现线程之间的同步。

实现的代码如下所示:

cpp 复制代码
#include "Mutex.hpp"
#include <unistd.h>

int cnt = 100;

using namespace MutexModule;

pthread_cond_t cond=PTHREAD_COND_INITIALIZER;

Mutex mutex;
void *Routine(void *args)
{
    const char* str=static_cast<const char*>(args);
    while (1)
    {
        {
            LockGard lock(mutex);
            pthread_cond_signal(&cond);
            if (cnt > 0)
            {
                usleep(10000);
                cnt--;
                std::cout <<(char*)args<< "进行抢票操作,票数变为" << cnt << std::endl;
                pthread_cond_wait(&cond,mutex.GetMutex());
                
            }
            else
            {
                break;
            }
        }
    }
    return nullptr;
}

int main()
{
    pthread_cond_init(&cond,nullptr);
    pthread_t t1, t2, t3;
    pthread_create(&t1, nullptr, Routine, (void*)"pthread-1");
    pthread_create(&t2, nullptr, Routine, (void*)"pthread-2");
    pthread_create(&t3, nullptr, Routine, (void*)"pthread-3");

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);

    pthread_cond_destroy(&cond);

    return 0;
}

注:以上的代码当中需要在pthread_cond_wait的时候使用到mutex变量,那么在此就需要给原来实现的Mutex类当中添加对应的获取pthread_mutex_t变量的接口。

实现代码如下所示:

cpp 复制代码
 pthread_mutex_t* GetMutex()
 {
     return &_mutex;
 }

以上的代码编译之后运行就会发现不会再出现一个线程一直占据着临界区的问题了。

2.4 条件变量封装

以上我们在了解了条件变量的接口之后那么接下来就将以上的接口封装为面向对象的形式。

实现代码如下所示:

cpp 复制代码
#pragma once
#include <iostream>
#include <signal.h>
#include "Mutex.hpp"

using namespace MutexModule;

namespace CondModule
{
    class Cond
    {
    public:
        Cond()
        {
            int n = pthread_cond_init(&_cond, nullptr);
            (void)n;
        }

        ~Cond()
        {
            int n = pthread_cond_destroy(&_cond);
            (void)n;
        }

        void Wait(Mutex &mutex)
        {
            int n = pthread_cond_wait(&_cond, mutex.GetMutex());
            (void)n;
        }

        void Signal()
        {
            int n = pthread_cond_signal(&_cond);
            (void)n;
        }

        void BroadCast()
        {
            int n = pthread_cond_broadcast(&_cond);
            (void)n;
        }

    private:
        pthread_cond_t _cond;
    };
}

以上就实现对条件变量的封装,接下来就需要来分析以上pthread库当中提供的pthread_cond_wait当中为什么要传一个锁呢?

实际上要解答这个问题就需要考虑到多个线程执行的情况,当一个一个进程在临界区当中使用该接口被休眠之后,那么这时候该线程还是将锁拿在手里,这时如果不将锁释放的话,那么其他的线程在该线程休眠期间都无法进入到临界区当中;同时还有一个原因是要保证线程wait的原子性,那么这时候就可以保证能得到其他线程发出的信号。

因此总的来说有以下两个原因:

  • 条件检查 :共享变量的访问必须加锁,保证数据一致性。

  • 原子操作 :释放锁并进入等待是一个原子动作,避免丢失信号。

3. 生产消费者模型

3.1 生产消费者模式概念

以下是生产者消费者模型的概念:

生产者--消费者模型是一种多线程同步的经典模式。

  • 生产者线程:不断生产数据(比如消息、任务),放到共享缓冲区。

  • 消费者线程:不断从共享缓冲区取数据,进行处理。

  • 缓冲区:共享的临界资源,通常是队列。

实际上该模型可以通过现实当中的例子来理解,例如以下的示例:

我们知道超市当中的商品都是由生产的工厂批发到超市当中的,并且会由多个厂商都是进行批发的工作,当厂家会时刻的关注超市当中对应商品的数量,当商品的数量不足的时候就会对超市的商品进行补货,在超市当中的消费者就是进入到超市当中进行商品购买的人群,消费者可以选择自己要购买的商品。但是在此就会存在一个问题,那就是当消费者在超市当中要选购的商品的数量超出了当前超市当中对应的商品的数量时,对应的厂家就需要对超市进行补货,但是如果没有限制生产者和消费者只有一个能在超市当中进行操作的话机会生产者和消费者都无法确定当前商品的实际库存。

实际上在线程同步当中的模型也是和以上的示例类似,本质上提到的交易场所就是内存当中的一块区域。而这一块内存空间就是一块临界资源,各个角色之间会由以下的关系:
三种关系:1.生产者和生产者之间是互斥和同步的 2.消费者和消费者之间是同步和互斥的 3.生产者和消费者之间是互斥的
两种角色:生产者和消费者由线程来承当
一个交易场所:以特定的结构组成的一块内存空间

以上总结就可以使用**"321"**原则来记忆。

3.2 生产者消费者模型的优势

以上我们就了解了生产者消费者模型的概念,那么接下来就要来分析相比普通的实现方式该模型有什么优势呢?

实际上在生产者消费者模型当中生产者就不会再直接和消费者进行通讯,这样使得生产者和消费者之间的耦合度降低,生产者在生产出数据之后直接将数据丢到阻塞队列当中即可,之后的操作生产者就不需要再进行关心了,而消费者就只需要从阻塞队列当中得到对应的数据,不需要关心具体是从哪来的,本质上阻塞队列就相当于一个缓冲区,平衡了生产者和消费者之间的处理能力。

因此总的来说生产者消费者模型会有以下的优势:

  • 解耦

    生产者和消费者不直接依赖对方,而是通过缓冲区交互。
    生产和消费的速度可以不同步,互不干扰。

  • 提高并发效率

    多个生产者可以并发生产,多个消费者可以并发消费。
    系统整体吞吐量更高。

  • 资源利用合理

    缓冲区作为"中间仓库",能平衡生产快、消费慢或消费快、生产慢的情况。
    避免"忙等"浪费 CPU。

  • 结构清晰、可扩展性好

    容易扩展:增加新的生产者或消费者线程即可。
    系统逻辑更清晰,职责分明。

3.3 基于BlockingQueue的生产消费模型

概念

在多线程当中阻塞队列(BlockingQueue)是一种非常典型的生产者消费者模型的数据结构,在此和不同的队列不同的是在阻塞队列当中当队列当中的数据空的时候会将消费者进行阻塞操作,那么这时候消费者就无法再进行从队列当中取数据的操作,当阻塞队列满的时候生产者就会被阻塞,生产者就无法再向队列当中写入数据。

代码实现

cpp 复制代码
#include"cond.hpp"
#include <queue>

const int initcount = 5;

using namespace MutexNamespace;
using namespace CondNamespace;


template <class T>
class BlockQueue
{

private:
    bool IsFull()
    {
        return _queue.size() >= _max_cnt;
    }

    bool IsEmpty()
    {
        return _queue.empty();
    }

public:
    BlockQueue(int count = initcount)
        : _max_cnt(count),
          c_pthread_cnt(0),
          p_pthread_cnt(0)
    {
    }

    ~BlockQueue()
    {
    }

    //入阻塞队列
    void Equeue(const T &x)
    {

        {
            LockGuard lock(_mutex);
            while (IsFull())
            {
                c_pthread_cnt++;
                std::cout << "生产者休眠,个数:" << c_pthread_cnt << std::endl;
                _full_cond.Wait(_mutex);
                c_pthread_cnt--;
            }
            _queue.push(x);
            if (p_pthread_cnt > 0)
            {
                _emp_cond.Signal();
                std::cout << "唤醒消费者" << std::endl;
            }
        }
    }

    //出阻塞队列
    T Pop()
    {
        T ret;
        {
            LockGuard lock(_mutex);
            while (IsEmpty())
            {
                p_pthread_cnt++;
                _emp_cond.Wait(_mutex);
                //std::cout << "消费者休眠,个数:" << p_pthread_cnt << std::endl;
                p_pthread_cnt--;
            }

            ret = _queue.front();
            _queue.pop();

            if (c_pthread_cnt > 0)
            {
                _full_cond.Signal();
                std::cout << "唤醒生产者" << std::endl;
            }
        }

        return ret;
    }

private:
    // 任务队列
    std::queue<T> _queue;
    // 任务队列内最大任务个数
    int _max_cnt;

    // 锁
    Mutex _mutex;
    //条件变量
    Cond _full_cond;
    Cond _emp_cond;

    int c_pthread_cnt; // 生产者休眠个数
    int p_pthread_cnt; // 消费者休眠个数
};

以上机实现了对应阻塞队列的代码,那么接下来就可以试着来基于以上的代码来实现一个生产消费模型的实例,在此可以分为单生产多消费和多生产多消费两种模式来进行测试。

单生产多消费测试代码如下所示:

cpp 复制代码
#include "BlockQueue.hpp"
#include <signal.h>
#include <unistd.h>

void *Product(void *args)
{
    int cnt = 1;
    while (1)
    {
        BlockQueue<int> *bq = static_cast<BlockQueue<int> *>(args);
        bq->Equeue(cnt++);
        std::cout << "生产者插入数据进入阻塞队列" << std::endl;
    }
}

void *Consume(void *args)
{
    while (1)
    {

        sleep(1);
        BlockQueue<int> *bq = static_cast<BlockQueue<int> *>(args);
        bq->Pqueue();
        std::cout << "消费者从阻塞队列当中取出数据" << std::endl;
    }
}

int main()
{
    pthread_t c1;
    pthread_t p[3];
    BlockQueue<int> *block = new BlockQueue<int>();
    pthread_create(&c1, nullptr, Product, (void *)block);
    pthread_create(&p[0], nullptr, Consume, (void *)block);
    pthread_create(&p[1], nullptr, Consume, (void *)block);

    pthread_join(c1, nullptr);
    pthread_join(p[0], nullptr);
    pthread_join(p[1], nullptr);

    return 0;
}

编译以上代码运行的结果如下所示:

以上就发现我们实现的单生产者多消费者的模式是可以进行的,那么接下来就继续来测试多生产者多消费者的模式能不能运行,代码如下所示:

cpp 复制代码
#include "BlockQueue.hpp"
#include <signal.h>
#include <unistd.h>

void *Product(void *args)
{
    int cnt = 1;
    while (1)
    {
        BlockQueue<int> *bq = static_cast<BlockQueue<int> *>(args);
        bq->Equeue(cnt++);
        std::cout << "生产者插入数据进入阻塞队列" << std::endl;
    }
}

void *Consume(void *args)
{
    while (1)
    {

        sleep(1);
        BlockQueue<int> *bq = static_cast<BlockQueue<int> *>(args);
        bq->Pqueue();
        std::cout << "消费者从阻塞队列当中取出数据" << std::endl;
    }
}

int main()
{
    pthread_t c[3];
    pthread_t p[3];
    BlockQueue<int> *block = new BlockQueue<int>();
    pthread_create(&c[0], nullptr, Product, (void *)block);
    pthread_create(&c[1], nullptr, Product, (void *)block);
    pthread_create(&c[2], nullptr, Product, (void *)block);
    pthread_create(&p[0], nullptr, Consume, (void *)block);
    pthread_create(&p[1], nullptr, Consume, (void *)block);

    pthread_join(c[0], nullptr);
    pthread_join(c[1], nullptr);
    pthread_join(c[2], nullptr);
    pthread_join(p[0], nullptr);
    pthread_join(p[1], nullptr);

    return 0;
}

运行效果如下所示:

若改为生产慢消费快的情况就会运行出以下的结果:

以上阻塞队列实现我们是使用模板来实现的,那么这就说明在阻塞队列当中的元素是可以插入除了内置类型之外的元素的,那么接下来就实现在队列当中插入函数指针。在main函数所在的文件当中引入一下的task.hpp文件。

cpp 复制代码
#pragma once
#include<iostream>
#include<functional>



using func_t=std::function<void()>;



void Download()
{
    std::cout<<"下载的任务正在运行......"<<std::endl;
}

重新将main函数所在的文件进行修改为以下的形式:

cpp 复制代码
#include "BlockQueue.hpp"
#include <signal.h>
#include <unistd.h>
#include"task.hpp"

void *Product(void *args)
{
    int cnt = 1;
    while (1)
    {
        sleep(2);
        BlockQueue<func_t> *bq = static_cast<BlockQueue<func_t> *>(args);
        bq->Equeue(Download);
        std::cout << "生产者插入数据进入阻塞队列" << std::endl;
    }
}

void *Consume(void *args)
{
    while (1)
    {

        BlockQueue<func_t> *bq = static_cast<BlockQueue<func_t> *>(args);
        bq->Pqueue();
        std::cout << "消费者从阻塞队列当中取出数据" << std::endl;
    }
}

int main()
{
    pthread_t c[3];
    pthread_t p[3];
    BlockQueue<func_t> *block = new BlockQueue<func_t>();
    pthread_create(&c[0], nullptr, Product, (void *)block);
    pthread_create(&c[1], nullptr, Product, (void *)block);
    pthread_create(&c[2], nullptr, Product, (void *)block);
    pthread_create(&p[0], nullptr, Consume, (void *)block);
    pthread_create(&p[1], nullptr, Consume, (void *)block);


    pthread_join(c[0], nullptr);
    pthread_join(c[1], nullptr);
    pthread_join(c[2], nullptr);
    pthread_join(p[0], nullptr);
    pthread_join(p[1], nullptr);

    return 0;
}

3.4 POSIX信号量

在之前学习进程间通信时我们就已经了解到了System V当中的信号量,但是System V标准当前已经较为老旧,而且System V当中的信号量只能用于进程当中,那么接下来我们就来学习一种在线程当中也能使用的信号量,其本质上是隶属POSIX标准。使用POSIX来实现线程之间的同步。

实际上POSIX信号量相比之前我们学习过的System V信号量的接口使用起来要简单的许多,对应的接口如下所示:

初始化信号量:

cpp 复制代码
#include <semaphore.h>

int sem_init(sem_t *sem, int pshared, unsigned int value);

参数:
sem:sem_t类型对象指针
pshared:当为0时表示线程间共享,非0表示进程间共享
value:信号量的初始值

返回值;返回值为0表示初始化成功,非0表示失败

销毁信号量:

cpp 复制代码
#include <semaphore.h>

int sem_destroy(sem_t *sem);

参数:
sem:为sem_t结构体的指针

返回值:为0表示销毁成功,非0表示失败

通过之前的学习我们就知道信号量的本质上就是一个计数器,当访问对应的临界资源的时候,就会对信号量进行PV操作,因此在POSIX信号量当中提供以下进行PV操作的接口:

等待信号量:

cpp 复制代码
#include <semaphore.h>

int sem_wait(sem_t *sem);

函数作用:
若信号量当中的计数器值大于0时将信号量当中的计数器进行--,否则将该线程进入到线程等待

参数:
sem:sem_t类型的指针

返回值:
当为0时时候表示P操作成功,否则表示失败

唤醒信号量:

cpp 复制代码
#include <semaphore.h>

int sem_post(sem_t *sem);

函数作用:
当信号量当中的计数器值加一,若有线程处于阻塞状态那么久唤醒一个线程

参数:
sem:sem_t类型的指针

返回值:
当为0时时候表示V操作成功,否则表示失败

和之前我们学习mutexcond类似也可以将以上POSIX信号量当中提供的接口封装为面向对象的形式,实现的代码如下所示:

cpp 复制代码
#pragma once
#include <iostream>
#include <semaphore.h>

namespace SemModule
{
    class Sem
    {
    public:
        Sem(int x=5)
        {
            int n = sem_init(&_sem, 0, x);
            (void)n;
        }
        ~Sem()
        {
            int n = sem_destroy(&_sem);
            (void)n;
        }

        void P()
        {
            int n = sem_wait(&_sem);
            (void)n;
        }

        void V()
        {
            int n = sem_post(&_sem);
            (void)n;
        }

    private:
        sem_t _sem;
    };

}

3.5 基于环形队列的生产消费模型

以上我们在了解POSIX信号量之后接下来在来实现基于环形队列的生成消费者模型

在此环形队列在之前我们久已经了解过实现的方式就是使用数组来模拟实现,只不过在下标的移动当中需要进行取模的操作,那么这时候我们就可以在环形队列当中创建两个下标分别是head和tail,一开始这两个下标都指向数组的第一个元素,当向队列当中插入数据的时候将tail向后移动一位,从队列当中取出数据的时候将head向后移动一位。此时要判队列是否为空就只需要判断head是否等于tail,要判断队列当中是否为满实际上和判断为空是一样的,通常来说我们会将队列当中空刚元素这样以区分空和满。

那么在环形队列当中会有以下的要求:

1.队列为空的时候,生产者先运行
2.当队列为满的时候,消费者先运行
3.生产者和消费者不能套一个圈以上
4.消费者的下标不能超过生产者

那么对于以上的要求就可以分析,就可以得到只要生产者和消费者不是访问队列当中的同一个位置的时候那么这两者都是可以同时运行的,除了两个角色在同一位置的时候都是可以同步执行的,并且当队列为空的时候那么这时消费者就需要阻塞,生产者就先运行;当队列为满的时候生产者就需要阻塞,消费者就先运行。

对于以上的要求我们该如何实现呢?

在此实际上使用到信号量就可以实现以上的要求,使用两个信号量就很容易实现,一个信号量用于标识队列当中剩余可插入数据的个数,另外一个信号量用于标识当前队列当中用于标识已经插入的数据个数,只需要将一开始将将生产者的信号量初始化为队列的大小,将消费者的信号量初始化为0。

那么接下来就可以试着来实现对于的基于环形队列的生产消费者模型的代码:

cpp 复制代码
#pragma once
#include<iostream>
#include <semaphore.h>
#include<pthread.h>
#include<vector>


int cnt=5;
template<class T>
class Ringqueue
{

    private:
    void CLock()
    {
        pthread_mutex_lock(&c_mutex);
    }
    void SLock()
    {
        pthread_mutex_lock(&s_mutex);
    }

    void CUnLock()
    {
        pthread_mutex_unlock(&c_mutex);
    }

    void SUnLock()
    {
        pthread_mutex_unlock(&s_mutex);
    }

    public:
    Ringqueue(int capacity=5)
    :_q(capacity),
    _capacity(capacity),
    phead(0),
    ptail(0)
    {
        pthread_mutex_init(&c_mutex,nullptr);
        pthread_mutex_init(&s_mutex,nullptr);
        sem_init(&c_sem,0,capacity);
        sem_init(&s_sem,0,0);
    }

    ~Ringqueue()
    {
        pthread_mutex_destroy(&c_mutex);
        pthread_mutex_destroy(&s_mutex);
        sem_destroy(&c_sem);
        sem_destroy(&s_sem);
    }


    void Equeue(const T& x)
    {
        sem_wait(&c_sem);
        CLock();
        _q[ptail]=x;
        ptail++;
        ptail%=_capacity;
        CUnLock();
        sem_post(&s_sem);
    }


    void Pqueue(T* out)
    {
        sem_wait(&s_sem);
        SLock();
        *out=_q[phead];
        phead++;
        phead%=_capacity;
        SUnLock();
        sem_post(&c_sem);
    }


    private:
    std::vector<T> _q;
    int _capacity;

    pthread_mutex_t c_mutex;
    pthread_mutex_t s_mutex;

    sem_t c_sem;
    sem_t s_sem;

    int phead;
    int ptail;
};

以上我们就将环形队列的代码实现完毕,以上调用的是pthread库当中提供的接口,和POSIX信号量的原始接口来实现的,那么接下来再实现一份用我们实现的库来实现的环形队列代码。

实现代码如下所示:

cpp 复制代码
#pragma once
#include <iostream>
#include <semaphore.h>
#include <pthread.h>
#include <vector>
#include "Sem.hpp"
#include "Mutex.hpp"

using namespace MutexModule;
using namespace SemModule;

int cnt = 5;
template <class T>
class Ringqueue
{

public:
    Ringqueue(int capacity = 5)
        : _q(capacity),
          _capacity(capacity),
          phead(0),
          ptail(0),
          c_sem(capacity),
          s_sem(0)
    {
    }

    ~Ringqueue()
    {
       
    }

    void Equeue(const T &x)
    {

        c_sem.P();
        {
            LockGard lock(c_mutex);
            _q[ptail] = x;
            ptail++;
            ptail %= _capacity;
        }
        s_sem.V();
    }

    void Pqueue(T *out)
    {
        s_sem.P();
        {
            LockGard lock(s_mutex);
            *out = _q[phead];
            phead++;
            phead %= _capacity;
        }
        c_sem.V();
    }

private:
    std::vector<T> _q;
    int _capacity;

    Mutex c_mutex;
    Mutex s_mutex;

    Sem c_sem;
    Sem s_sem;

    int phead;
    int ptail;
};

接下来就可以测试我们实现的基于环形队列的生产消费者模式是否实现的符合要求,测试代码如下所示:

cpp 复制代码
#include "Ringqueue2.hpp"
#include <signal.h>
#include <unistd.h>
#include "task.hpp"

void *Product(void *args)
{
    int cnt = 1;
    while (1)
    {
        sleep(2);
        Ringqueue<int> *bq = static_cast<Ringqueue<int> *>(args);
        bq->Equeue(cnt++);
        std::cout << "生产者插入数据进入阻塞队列" << std::endl;
    }
}

void *Consume(void *args)
{
    while (1)
    {
        sleep(1);
        Ringqueue<int> *bq = static_cast<Ringqueue<int> *>(args);
        int out;
        bq->Pqueue(&out);
        std::cout << "消费者从阻塞队列当中取出数据" << std::endl;
    }
}

int main()
{
    pthread_t c[3];
    pthread_t p[3];
    Ringqueue<int> *block = new Ringqueue<int>();
    pthread_create(&c[0], nullptr, Product, (void *)block);
    pthread_create(&c[1], nullptr, Product, (void *)block);
    pthread_create(&c[2], nullptr, Product, (void *)block);
    pthread_create(&p[0], nullptr, Consume, (void *)block);
    pthread_create(&p[1], nullptr, Consume, (void *)block);

    pthread_join(c[0], nullptr);
    pthread_join(c[1], nullptr);
    pthread_join(c[2], nullptr);
    pthread_join(p[0], nullptr);
    pthread_join(p[1], nullptr);

    return 0;
}

以上代码编译之后程序输出的结果如下所示:

以上就是本篇的所有内容了,接下来在下一篇当中我们将继续线程相关概念的学习......

相关推荐
小王努力学编程3 小时前
brpc远程过程调用
linux·服务器·c++·分布式·rpc·protobuf·brpc
关关长语3 小时前
Docker在Linux中离线部署
linux·docker·容器
明月看潮生5 小时前
编程与数学 03-009 Linux 操作系统应用 13_Linux 系统安全与用户认证
linux·青少年编程·系统安全·编程与数学
艾莉丝努力练剑5 小时前
【编码表 && STL】C++编程基石:从字符编码表到STL标准库的完整入门指南
java·linux·c++
工头阿乐6 小时前
Ubuntu 安装与使用C++ onnxruntime库
linux·c++·ubuntu
艾莉丝努力练剑6 小时前
【测试开发/测试】详解测试用例(下):详解设计测试用例的方法
linux·经验分享·测试用例·bug·测试
努力努力再努力wz6 小时前
【C++进阶系列】:位图和布隆过滤器(附模拟实现的源码)
java·linux·运维·开发语言·数据结构·c++
Akshsjsjenjd7 小时前
Tomcat 简介与 Linux 环境部署
java·linux·tomcat
_BigMao7 小时前
Linux服务器从零开始-部署.net控制台程序(AlmaLinux)
linux·服务器·.net