作者:@小萌新
专栏:@Linux
作者简介:大二学生 希望能和大家一起进步!
本篇博客简介:简单介绍linux中信号量的概念
信号量
- 信号量的概念
- 信号量的使用
- 信号量函数
- 二元信号量模拟互斥功能
- 基于环形队列的生产者消费者模型
- 空间资源和数据资源
- 生产者和消费者使用资源
- 两个规则
- 代码实现
- 信号量如何保护环形队列
信号量的概念
信号量的本质就是一个计数器 它描述着临界资源中资源数目的大小
- 临界资源:多线程执行流共享的资源叫做临界资源
拿我们现实生活中的电影院举例 一个电影院中的座位是固定的 我们这里以100个为例
假设在一场电影开场前 我们就在手机app买了一张票 那么是不是只有我们到了电影院才有一个位置属于我呢?
显然不是 我们买完票的时候就获得了一个座位从电影开场到结束的使用权 此时电影院剩余的位置就变成了99个
而信号量的作用就是计数 如果电影院剩余位置是大于0的 就接受预定 如果不是就不接受预定
我们可以将电影院的所有座位看成是一份临界资源 而单个的座位则是这个临界资源的一小份 将一个个的人看作是线程的话 那么实际上我们就是在对这份临界资源实现并发操作 而信号量则是保证临界资源安全 不会被多预定造成异常的
信号量的使用
每个执行流想要使用临界资源之前都要申请信号量 每个执行流使用完临界资源之后都要释放信号量
信号量的PV操作:
- P操作:我们将申请信号量称为P操作,申请信号量的本质就是申请获得临界资源中某块资源的使用权限,当申请成功时临界资源中资源的数目应该减一,因此P操作的本质就是让计数器减一
- V操作:我们将释放信号量称为V操作,释放信号量的本质就是归还临界资源中某块资源的使用权限,当释放成功时临界资源中资源的数目就应该加一,因此V操作的本质就是让计数器加一。
因为信号量要被多个执行流访问并申请 所以说它实际上也是一个临界资源 我们也需要对它进行加锁操作 所以说申请和释放信号量的过程必须加锁
并且当信号量为0时 申请信号量的执行流将被挂起到信号量的等待队列中 直到有信号量被释放才被唤醒 所以说虽然信号量的本质是计数器 但是信号量中却不止有计数器 还有等待队列的等
信号量函数
初始化信号量
初始化信号量的函数叫做sem_init,该函数的函数原型如下:
int sem_init(sem_t *sem, int pshared, unsigned int value);
参数说明:
- sem:需要初始化的信号量。
- pshared:传入0值表示线程间共享,传入非零值表示进程间共享。
- value:信号量的初始值(计数器的初始值)。
返回值说明:
- 初始化信号量成功返回0,失败返回-1。
销毁信号量
销毁信号量的函数叫做sem_destroy,该函数的函数原型如下:
int sem_destroy(sem_t *sem);
参数说明:
- sem:需要销毁的信号量。
返回值说明:
- 销毁信号量成功返回0,失败返回-1。
等待信号量(申请信号量)
等待信号量的函数叫做sem_wait,该函数的函数原型如下:
int sem_wait(sem_t *sem);
参数说明:
- sem:需要等待的信号量。
返回值说明:
- 等待信号量成功返回0,信号量的值减一。
- 等待信号量失败返回-1,信号量的值保持不变。
发布信号量(释放信号量)
发布信号量的函数叫做sem_post,该函数的函数原型如下:
int sem_post(sem_t *sem);
参数说明:
- sem:需要发布的信号量。
返回值说明:
- 发布信号量成功返回0,信号量的值加一。
- 发布信号量失败返回-1,信号量的值保持不变。
二元信号量模拟互斥功能
当信号量为1时 此时信号量的作用便可以等价于锁
p操作为加锁 v操作为解锁
下面是一段不加锁的抢票操作
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <string>
using namespace std;
int ticket = 1000;
void* TicketGrabbing(void* arg)
{
string name = (char *)arg;
while(1)
{
if (ticket > 0)
{
usleep(1000);
cout << name << ": get a ticket ticket left : " << --ticket << endl;
}
else
{
break;
}
}
cout << "there is no ticket" << endl;
pthread_exit((void *)0);
}
int main()
{
pthread_t tid1 , tid2 , tid3 , tid4;
pthread_create(&tid1, nullptr, TicketGrabbing, (void*)"thread 1");
pthread_create(&tid2, nullptr, TicketGrabbing, (void*)"thread 2");
pthread_create(&tid3, nullptr, TicketGrabbing, (void*)"thread 3");
pthread_create(&tid4, nullptr, TicketGrabbing, (void*)"thread 4");
pthread_join(tid1, nullptr);
pthread_join(tid2, nullptr);
pthread_join(tid3, nullptr);
pthread_join(tid4, nullptr);
return 0;
}
注意 usleep必须放在if里面才能大概率出现负数票
之后编译运行我们发现这种情况
下面我们在抢票逻辑中加上二元信号量 让每个执行流访问临界资源的时候首先申请信号量 访问完毕释放信号量
信号量部分代码如下
class SEM
{
private:
sem_t _sem;
public:
SEM(int num = 1)
{
sem_init(&_sem , 0 , num);
}
~SEM()
{
sem_destroy(&_sem);
}
void P()
{
sem_wait(&_sem);
}
void V()
{
sem_post(&_sem);
}
};
之后我们只要定义一个sem对象 像使用锁一样使用它就能得到我们想要的结果
注意: 申请锁(二元信号量)的过程必须要在if之前操作 因为进入if之后申请最后别的资源释放信号量之后一定会运行下面的代码
我们发现这里的票数就是0了
那么这里为什么一直是2线程在抢票呢? 因为此时2线程处于活跃状态 竞争能力比较强 所以说一直是它申请到信号量
基于环形队列的生产者消费者模型
空间资源和数据资源
对于生产者和消费者来说 它们关注的资源是不同的
- 生产者关注的是队列中是否还有空间 只要有空间就能进行生产
- 消费者关注的是队列中是否还有数据 只有有数据就能进行消费
初始空间设置
我们使用两个信号量来分别设置生产者和消费者的空间 对于这个队列来说 它一开始是空的 没有数据
所以对于生产者来说 它的信号量就是空间大小 对于消费者来说 它的信号量大小就是0
生产者和消费者使用资源
对于生产者
对于生产者来说它每次申请资源需要申请空间资源(P操作) 但是生产数据完毕之后不是对于空间资源进行V操作而是对于数据资源进行V操作 这是因为申请完数据之后整个环形队列中就会多一个数据资源而不是空间资源
对于消费者
对于消费者同理 当消费者消费一个数据之后整个环形队列中会多一个空间资源 而数据资源不会变化
两个规则
生产者和消费者不能同时访问一个位置
- 如果生产者和消费者同时访问一个位置 相当于它们对于同一块临界资源进行了访问 这是不被允许的
- 当它们同时访问不同位置的时候就可以同时进行生产和消费 没有冲突
无论是生产者和消费者 都不能将对方套一个圈以上
如果是生产者将消费者套一个圈以上的话 消费者上一圈的数据都没有消费完就被覆盖了 相当于是一些数据丢失了
如果是消费者将生产者套了一个圈以上的话 生产者还没有来得及生产数据 就会产生一些异常情况
代码实现
我们STL中的vector库来实现一个环形队列
W> 1 #pragma once
2
3 #include <iostream>
4 using namespace std;
5 #include <unistd.h>
6 #include <pthread.h>
7 #include <string>
8 #include <vector>
9 #include <semaphore.h>
10
11 const int CAP = 8;
12
13
14 template<class T>
15 class RingQueue
16 {
17 private:
18 vector<T> _q;
19 int _cap; // 容量大小
20 int _p_pos; // 生产者位置
21 int _c_pos; // 消费者位置
22 sem_t _blank_sem; // 空间信号量
23 sem_t _data_sem; // 数据信号量
24 private:
25 void P(sem_t& s)
26 {
27 sem_wait(&s);
28 }
29 void V(sem_t& s)
30 {
31 sem_post(&s);
32 }
33 public:
W> 34 RingQueue(int num = CAP)
35 :_cap(CAP),
36 _p_pos(0),
37 _c_pos(0)
38 {
39 _q.resize(CAP);
40 sem_init(&_blank_sem , 0 , _cap);
41 sem_init(&_data_sem , 0 , _cap);
42 }
43
44 ~RingQueue()
45 {
46 sem_destroy(&_blank_sem);
47 sem_destroy(&_data_sem);
48 }
49
50 void Push(const T& data)
51 {
52 this->P(_blank_sem);
53 _q[_p_pos] = data;
54 this->V(_data_sem);
55
56 // 下一次生产的位置
57 _p_pos++;
58 _p_pos %= _cap;
59 }
60
61 void Pop(T& data)
62 {
63 this->P(_data_sem);
64 data = _q[_c_pos];
65 this->V(_blank_sem);
66
67 // 下一次消费的位置
68 _c_pos++;
69 _c_pos %= _cap;
70 }
71
72 };
之后我们再写一个main函数 开始运行
1 #include "RingQueue.hpp"
2
3 void* P_run(void* args)
4 {
5 RingQueue<int>* rq = (RingQueue<int>*)args;
6 cout << "im producer" << endl;
7 while(1)
8 {
9 int data = rand()%100+1;
10 rq->Push(data);
11 cout << "producer :" << data << endl;
12 }
13 }
14
15 void* C_run(void* args)
16 {
17 auto rq = (RingQueue<int>*)args;
18 sleep(3);
19 cout << "im consumer" << endl;
20 while(1)
21 {
22 sleep(1);
23 int data = 0;
24 rq->Pop(data);
25 cout << "consumer :" << data << endl;
26 }
27 }
28
29
30 int main()
31 {
32 pthread_t p;
33 pthread_t c;
34 srand((unsigned int)time(nullptr));
35 auto* rq = new RingQueue<int>;
36 pthread_create(&p , nullptr , P_run , rq);
37 pthread_create(&c , nullptr , C_run , rq);
38
39 pthread_join(p , nullptr);
40 pthread_join(c , nullptr);
41 delete rq;
42 return 0;
43 }
解释下上面的main函数
我们创造了两个线程 一个作为生产者线程 一个作为消费者线程 他们共用一个环形队列
首先生产者开始生产数据三秒 消费者休眠三秒
之后消费者每休眠一秒消费一个数据
当信号量满的时候 生产者就会阻塞在那里 直到消费者消费数据 生产者才会再生产
运行代码如下
同时我们还可以发现 消费数据和生产数据的顺序是一样的 这就是因为环形队列的缘故
信号量如何保护环形队列
在blank_sem和data_sem两个信号量的保护后,该环形队列中不可能会出现数据不一致的问题。
只有当生产者和消费者指向环形队列的同一个位置的时候才会出现数据不一致的问题 而只有两种情况会让生产者和消费者指向同一个位置
- 队列为空
- 队列为满
而当队列为空的时候消费者是不能消费的
当队列为满的时候生产者是不能生产的
而除了这两种情况外其他所有情况生产者和消费者都是不能指向同一位置的 所以说信号量保护了环形队列的数据一致性问题