『Linux』第九讲:Linux多线程详解(五)_ 信号量

「前言」文章是关于Linux多线程方面的知识,上一篇是 Linux多线程详解(四),今天这篇是 Linux多线程详解(五),内容大致是信号量,讲解下面开始!

「归属专栏」Linux系统编程

「笔者」枫叶先生(fy)

「座右铭」前行路上修真我

「枫叶先生有点文青病」

「每篇一句」

求其上,得其中;

求其中,得其下;

求其下,必败。
——《孙子兵法》

十、POSIX信号量

10.1 分析之前代码的不足

在进入话题正文之前先谈三个概念:串行、并行、并发

  • 并发:并发是指同时处理多个任务,并且这些任务可能会相互影响,需要协调和管理。例如,在一个多用户系统中,多个用户可能同时访问同一个资源,需要通过并发控制来避免冲突和竞争
  • 并行:并行是指同时执行多个任务,每个任务都在不同的处理器上执行,相互之间独立,不会相互影响。例如,在一个多处理器系统中,多个任务可以同时在不同的处理器上执行,提高系统的处理能力和效率
  • 串行:串行是指按照一定的顺序依次执行任务,每个任务必须在前一个任务完成后才能开始执行。在串行执行中,每个任务都是独占CPU资源的,直到该任务执行完毕后,才会释放CPU资源给下一个任务使用

下面进入正文,代码样例是上一篇的Linux多线程(四)的代码

  • 一个线程在操作临界资源的时候,一定是要满足临界资源的条件的
  • 可是,是否满足条件,我们是无法直接得知的(不能事先得知,在没有访问临界资源之前,是无法得知是否满足条件
  • 只能先进行加锁,再检测,再操作,再解锁。根据检测的结果决定线程下一步怎么走
  • 进行检测也是在访问临界资源
  • 比如上面的代码样例中,线程是无法知道是否满足生产条件的,线程必须申请到锁,进入临界区才能检测是否满足条件,根据检测的结果决定线程下一步怎么走

注意:只要我们对临界资源进行加锁,就默认了对这个资源进行整体使用

但实际情况可能存在:一份临界资源,允许被不同线程同时访问不同的区域(把一份共享资源分成许多份使用) ,可以进行这样操作就是信号量 

10.2 信号量概念

信号量主要用于线程同步和互斥的

POSIX信号量和SystemV信号量作用相同,都是用于同步和互斥操作,达到无冲突的访问共享资源目的, 但POSIX可以用于线程间同步和互斥

信号量:semaphore

信号量的本质:

信号量(信号灯)本质是一个计数器,是描述临界资源中资源数目的计数器,信号量能够更细粒度的对临界资源进行管理

信号量解释:

  • 以例子进行解释,比如一个电影院,这个电影院看作是临界资源,电影院里面的每一个座位看作是临界资源划分成的一个个子资源;
  • 我只有买了票,这个座位在一定时间内才是归属我的,即便我买了票不去,这个座位也是我的,反过来,你没买票,这个座位就一定不是你的。买的票一定对应着一个座位。
  • 同样道理,我想用某种资源的时候,可以对这种资源进行预订,就相当于买票,买了票我就已经预订了这个座位,我预订了这个资源,它在未来的某一时间一定属于我
  • 假设电影院的座位只有100个,你不能卖出第101张票吧,信号量就相当于这里的票一样,票卖完了就不允许再卖了,再卖就会发生 ‘座位冲突’,也就对应进程访问该资源会发生冲突
  • 线程想要访问某个临界资源的条件是:申请到信号量,申请到了信号量就相当于预订了临界资源的某一部分小资源,就允许该线程对该资源的访问
  • 将一块临界资源拆分成一个个的子资源,不同的线程可以对不同的子资源进行访问,从而实现并发(程序员编码保证不同的线程可以并发访问临界资源的不同区域)

而申请信号量的前提是:所有线程要看到一个同一个信号量,所以信号量本身就是临界资源

  • 既然信号量是临界资源,是临界资源就要保证自身的安全性。
  • 信号量的本质是一个计数器,计数器的操作只有 ++(递增)和 --(递减)
  • 进行 ++ 和 -- 操作就要保证信号量自身的安全性,所以这个计数器就不能只是单纯整型int,因为这样的 ++ 和 -- 操作不是原子性的
  • 所以Linux对信号量进行封装了一种类型 sem_t,这种类型可以保证 ++ 和 -- 操作是原子性的

只要申请到了信号量,在未来就一定可以拥有该临界资源的一部分。

申请信号量的本质:对临界资源中特定小块资源进行预订(申请信号量就是一种预订机制)

信号量的核心操作是 ++ 和 --(PV操作

  • 对信号量++,归还信号量资源(V操作)
  • 对信号量--,申请信号量资源(P操作)

信号量这种预订机制,就有可能我们在真正访问临界资源之前,我们就可以提前知道临界资源的使用情况

  • 只要申请信号量成功,临界资源就一定会有一部分资源是你的
  • 只要申请信号量失败,就说明条件不就绪(不满足),你只能进行等待
  • 所以,就不需要像互斥锁,只有加锁后进行判断才能知道临界资源的使用情况

提前知道临界资源的使用情况:类比上面的电影院(临界资源),有一场电影只有100张票(临界资源分成100个小资源),只卖了80张,每张电影票对应着一个唯一的座位,电影还没有开始我们就已经预知了该电影院资源(座位)的使用情况

10.3 POSIX信号量函数

初始化信号量

初始化信号量的函数叫做 sem_init,man 3 sem_init 查看

函数:sem_init

头文件:#include <semaphore.h>

函数原型:
         int sem_init(sem_t *sem, int pshared, unsigned int value);

参数:
    第一个参数sem:需要初始化的信号量
    第二个参数pshare:0表示线程间共享,非零表示进程间共享
    第三个参数value:信号量初始值(信号量初始值)

返回值:
    初始化信号量成功返回0,失败返回-1,错误码被设置

 销毁信号量

销毁信号量的函数叫做 sem_destroy, man 3 sem_destroy查看

函数:sem_destroy

头文件:#include <semaphore.h>

函数原型:
         int sem_destroy(sem_t *sem);

参数:
    参数sem:需要销毁的信号量

返回值:
    销毁信号量成功返回0,失败返回-1,错误码被设置

等待信号量(申请信号量)(P操作)

等待信号量的函数叫做 sem_wait,man 3 sem_wait查看

函数:sem_wait

头文件:#include <semaphore.h>

函数原型:
         int sem_wait(sem_t *sem);

参数:
    参数sem:需要等待的信号量

返回值:
    等待信号量成功返回0,信号量的值-1
    等待信号量失败返回-1,错误码被设置,信号量的值保持不变

 发布信号量(释放信号量)(V操作)

发布信号量的函数叫做 sem_post,man 3 sem_post查看

函数:sem_post

头文件:#include <semaphore.h>

函数原型:
         int sem_post(sem_t *sem);

参数:
    参数sem:需要发布的信号量

返回值:
    等待信号量成功返回0,信号量的值+1
    等待信号量失败返回-1,错误码被设置,信号量的值保持不变

注意:信号量为1时,说明临界资源是一整个整体使用的(信号量所描述的临界资源只有一份),提供互斥功能(此时信号量的作用基本等价于互斥锁),提供互斥功能的信号量也叫二元信号量

十一、基于环形队列的生产消费模型

11.1 环形队列

环形队列本质上是一个数组,环形队列是固定大小的,在数据结构学过,不解释

环形队列的判空判满问题:(1)计数器,(2)空出最后一个位置

11.2  环形队列的生产消费模型

以但单生产单消费为例,生产者和消费者在这两种情况下可能会访问同一个位置:(1)环形队列空的时候,(2)环形队列满的时候,对于其他情况生产者和消费者访问的位置都不一样

 

环形队列的生产消费模型,必须要满足两个条件:

  1. 消费者不能超过生产者
  2. 无论是生产者还是消费者,不允许将对方套一个圈以上

对于生产者和消费者来说,它们关注的资源是不同的:

  • 生产者关注的是环形队列当中是否有空间,只要有空间生产者就可以进行生产。
  • 消费者关注的是环形队列当中是否有数据,只要有数据消费者就可以进行消费。

这里就需要用信号量来描述环形队列当中的空间资源和数据资源,需要创建两个信号量,假设是A,B,A信号量用来标识空间资源的多少,B信号量用于标识数据资源的多少,并且这两个信号量初始化的值不同:

  • A信号量初始值我们应该设置为环形队列的容量,因为刚开始时环形队列当中全是空的,空间资源为环形队列的容量
  • B信号量初始值我们应该设置为0,因为刚开始时环形队列当中没有数据

生产者申请空间资源,释放数据资源

  • 对于生产者来说,生产者每次生产数据前都需要先申请(等待)A信号量,
  • 如果A信号量的值不为0,则信号量申请成功,A信号量-1(即等待A信号量成功,P操作),此时生产者可以进行生产操作。
  • 如果A信号量的值为0,则信号量申请失败,此时生产者需要在A信号量的等待队列下进行阻塞等待,直到环形队列当中有新的空间后再被唤醒

当生产者生产完数据后,应该释放B信号量,因为此时队列中的数据已经+1,B信号量也需要+1(即释放(发布)B信号量,V操作)

消费者申请数据资源,释放空间资源 

  • 对于消费者来说,消费者每次消费数据前都需要先申请(等待)B信号量,
  • 如果B信号量的值不为0,则信号量申请成功,B信号量-1(即等待A信号量成功,P操作),此时消费者可以进行消费操作。
  • 如果B信号量的值为0,则信号量申请失败,此时消费者需要在B信号量的等待队列下进行阻塞等待,直到环形队列当中有新的数据后再被唤醒

当消费者消费完数据后,应该释放A信号量,因为此时队列中的数据已经-1,空出一个空间资源,A信号量也需要+1(即释放(发布)A信号量,V操作)

下面进行编写代码 

11.3 代码实现

Linux多线程(四)实现的是是基于queue的生产者消费者模型,其空间可以动态分配(采用互斥锁+条件变量实现);现在基于固定大小的环形队列重写这个生产者消费者模型(采用POSIX信号量实现)

为了方便理解,实现的代码也是单生产者,单消费者

RingQueue.hpp

用C++STL库当中的vector模拟实现环形队列 

#pragma once

#include <iostream>
#include <vector>
#include <semaphore.h>
#include <cassert>

static const int gcap = 5;

template <class T>
class RingQueue
{
private:
    // P操作,等待信号量
    void P(sem_t &sem)
    {
        int n = sem_wait(&sem);
        assert(n == 0);
        (void)n;
    }
    // V操作,发布信号量
    void V(sem_t &sem)
    {
        int n = sem_post(&sem);
        assert(n == 0);
        (void)n;
    }

public:
    RingQueue(const int &cap = gcap) : _ringQueue(cap), _cap(cap)
    {
        sem_init(&_spaceSem, 0, _cap);
        sem_init(&_dataSem, 0, 0);
        _productorStep = _consumerStep = 0;
    }
    // 生产者生产数据
    void Push(const T &in)
    {
        // 申请信号量,申请成功继续执行代码,失败阻塞等待
        P(_spaceSem);
        _ringQueue[_productorStep] = in;
        _productorStep++;
        _productorStep %= _cap;
        // 此时数据+1,发布_dataSem信号量
        V(_dataSem);
    }
    // 消费者消费数据
    void Pop(T *out)
    {
        // 申请信号量,申请成功继续执行代码,失败阻塞等待
        P(_dataSem);
        *out = _ringQueue[_consumerStep];
        _consumerStep++;
        _consumerStep %= _cap;
        // 此时数据-1,发布_spaceSem信号量
        V(_spaceSem);
    }

    ~RingQueue()
    {
        sem_destroy(&_spaceSem);
        sem_destroy(&_dataSem);
    }

private:
    std::vector<T> _ringQueue;
    int _cap;
    sem_t _spaceSem;    // 空间资源
    sem_t _dataSem;     // 数据资源
    int _productorStep; // 生产者的下标,共用_ringQueue,两套下标
    int _consumerStep;  // 消费者的下标,共用_ringQueue
};

Main.cc 

主函数只需要创建一个生产者线程和一个消费者线程,生产者线程不断生产数据放入环形队列,消费者线程不断从环形队列里取出数据进行消费 

#include "RingQueue.hpp"
#include <pthread.h>
#include <unistd.h>
#include <ctime>

using namespace std;

void *consumer(void *ringqueue)
{
    RingQueue<int> *rq = static_cast<RingQueue<int> *>(ringqueue);
    while (true)
    {
        int data;
        rq->Pop(&data);
        cout << "消费完成,消费的数据是:" << data << endl;
        sleep(1);
    }
    return nullptr;
}

void *productor(void *ringqueue)
{
    RingQueue<int> *rq = static_cast<RingQueue<int> *>(ringqueue);
    while (true)
    {
        int data = rand() % 10 + 1;
        rq->Push(data);
        cout << "生产完成,生产的数据是:" << data << endl;
        sleep(1);
    }
    return nullptr;
}

int main()
{
    srand((unsigned int)time(nullptr) ^ 0x4589315); // 用于获取随机数

    RingQueue<int> *rq = new RingQueue<int>();
    // c:consumer p:productor
    pthread_t c, p;
    pthread_create(&c, nullptr, consumer, rq);
    pthread_create(&p, nullptr, productor, rq);

    // 线程等待
    pthread_join(c, nullptr);
    pthread_join(p, nullptr);
    delete rq;
    return 0;
}

 生产者消费者步调一致

生产者每隔一秒生产一个数据,而消费者是每隔一秒消费一个数据,因此运行代码后我们可以看到生产者和消费者的执行步调是一致的

测试运行结果

 

生产者生产快,消费者消费慢

修改代码,生产者生产快,消费者消费慢

 

测试运行结果

  • 第一条打印是因为两个线程同时执行,消费者线程消费了一次数据才sleep(1)
  • 此时由于生产者生产的很快,运行代码后一瞬间生产者就将环形队列打满了,此时生产者想要再进行生产,但空间资源已经为0了,生产者只能阻塞等待空间资源
  • 消费者消费一个环形队列里面的数据,生产者又会生产一个数据,因此后续生产者和消费者的步调又变成一致的了

 

生产者生产慢,消费者消费快

修改代码,生产者生产慢,消费者消费快

测试运行

  • 第一条打印是因为两个线程同时执行,生产者线程生产了一个数据才sleep(1)
  • 此时由于消费者消费的很快,运行代码由于环形队列中没有数据,只能阻塞等待生产者生产数据
  • 生产者生产一个数据,消费者又会消费一个数据,因此后续生产者和消费者的步调又变成一致的了

--------------------- END ----------------------

「 作者 」 枫叶先生
「 更新 」 2023.6.3
「 声明 」 余之才疏学浅,故所撰文疏漏难免,
          或有谬误或不准确之处,敬请读者批评指正。

猜你喜欢

转载自blog.csdn.net/m0_64280701/article/details/130995539