✨✨ 欢迎大家来到贝蒂大讲堂✨✨
🎈🎈养成好习惯,先赞后看哦~🎈🎈
所属专栏:Linux学习
贝蒂的主页:Betty's blog
1. POSIX 信号量
1.1 信号量的概念
为了解决多执行流访问临界区,造成数据不一致等问题,我们除了使用互斥锁外,我们还可以使用一种 POSIX
信号量的方法。
当我们运用互斥锁来保护临界资源时,意味着我们把这块临界资源视为一个不可分割的整体,在同一时刻只准许一个执行流对其进行访问。
其实我们也能将这块临界资源进一步划分成多个区域。当多个执行流有访问临界资源的需求时,若让这些执行流同时去访问临界资源的不同区域,此时也并不会引发数据不一致等问题。信号量就是基于此的解决方法。
POSIX
信号量本质上是一个计数器,用于衡量临界资源中的资源数目。它对临界资源内部的资源数进行统计,同时操作系统为其提供了一种对临界资源的预定机制。所有执行流在访问临界资源之前,必须先申请信号量。
信号量的 PV
操作:
- P操作:我们将申请信号量称为P操作,申请信号量的本质就是申请获得临界资源中某块资源的使用权限,当申请成功时临界资源中资源的数目应该减一,因此P操作的本质就是让计数器减一。
- V操作:我们将释放信号量称为V操作,释放信号量的本质就是归还临界资源中某块资源的使用权限,当释放成功时临界资源中资源的数目就应该加一,因此V操作的本质就是让计数器加一。
并且由于因信号量的 PV
操作同样属于临界资源,所以 PV
操作肯定是原子的。
值得注意的是: 虽然 POSIX信号量和SystemV信号量作用相同,都是用于同步操作,但POSIX信号量常用于线程间同步,而 SystemV 信号量常用于进程间通信。
1.2 信号量的接口
1.2.1 初始化信号量
我们首先需要使用 sem_init
初始化信号量,其用法如下:
- 函数接口:int sem_init(sem_t *sem, int pshared, unsigned int value);
- 参数:
sem
:需要初始化的信号量。pshared
:传入0值表示线程间共享,传入非零值表示进程间共享。value
:信号量的初始值(计数器的初始值)。
- 返回值:初始化信号量成功返回0,失败返回-1。
1.2.2 销毁信号量
在使用完信号量之后,我们就需要用 sem_destory
对其进行销毁,其用法如下:
- 函数接口:int sem_destroy(sem_t *sem);
- 参数:
sem
:需要销毁的信号量。
- 返回值:销毁信号量成功返回0,失败返回-1。
1.2.3 申请信号量
申请信号量也就是 P 操作,我们需要使用 sem_wait
函数,其用法如下:
- 函数接口:int sem_wait(sem_t *sem);
- 参数:
sem
:需要申请的信号量。
- 返回值:申请信号量成功返回0,信号量的值减一。申请信号量失败返回-1,信号量的值保持不变。如果信号量为 0,则该执行流会被阻塞,直至信号量大于 0。
1.2.4 释放信号量
释放信号量也就是 V 操作,我们需要使用 sem_post
函数,其用法如下:
- 函数接口:int sem_post(sem_t *sem);
- 参数:
sem
:需要释放的信号量。
- 返回值:释放信号量成功返回0,信号量的值加一。释放信号量失败返回-1,信号量的值保持不变。
如果信号量的初始值为1,那么此时信号量所描述的临界资源只有一份,这个临界资源也只能同时被一个执行流访问。此时信号量的作用基本等价于互斥锁,这种信号量我们称为二元信号量。
比如我们下面可以通过二元信号量实现我们的抢票逻辑:
cpp
#include <iostream>
#include <cstdio>
#include <string>
#include <unistd.h>
#include <pthread.h>
#include <semaphore.h>
class Sem
{
public:
Sem(int num)
{
sem_init(&_sem, 0, num);
}
~Sem()
{
sem_destroy(&_sem);
}
void P()
{
sem_wait(&_sem);
}
void V()
{
sem_post(&_sem);
}
private:
sem_t _sem;
};
int tickets = 1000;
Sem sem(1);
void *getTickets(void *args)
{
uint64_t i = (uint64_t)args;
char buffer[64] = {0};
snprintf(buffer, sizeof(buffer), "thread %llu", i);
while (true)
{
sem.P();
if (tickets > 0)
{
usleep(1000);
std::cout << buffer << " get a ticket,tickets left: " << --tickets << std::endl;
sem.V();
}
else
{
sem.V();
break;
}
}
std::cout << buffer << " quit ..." << std::endl;
return nullptr;
}
int main()
{
pthread_t tids[5];
for (uint64_t i = 0; i < 5; i++)
{
pthread_create(tids + i, nullptr, getTickets, (void *)i);
}
for (int i = 0; i < 5; i++)
{
pthread_join(tids[i], nullptr);
}
return 0;
}
2. 生产者消费者模型
2.1 概念
生产者 - 消费者模型是一种经典的多线程或多进程同步模型。它主要用于解决在数据生产和数据消费速度不一致的情况下,如何安全、高效地处理数据的问题。
在这个模型中,有两类角色:生产者 和消费者。生产者负责生产数据,例如在一个文件读取系统中,生产者可能是读取文件内容并将其转换为特定格式数据的线程或进程;消费者则负责消费(处理)生产者生产的数据,比如将读取到的数据进行进一步的分析或者存储到数据库中的线程或进程。
利用该模型我们能实现生产者与消费者之间的解耦,并且生产者在生产时,其它生产者可以获取数据,消费者可以处理数据,消费者在消费时也是同理,一定程度上实现了并发。
2.2 特点
生产者-消费者模型一般具有以下三个特点:
- 三种关系: 生产者和生产者(互斥关系)、消费者和消费者(互斥关系)、生产者和消费者(互斥关系、同步关系)。
- 两种角色: 生产者和消费者。(通常由进程或线程承担)
- 一个交易场所: 通常指的是内存中的一段缓冲区。
因为容器是能够被多个执行流访问的一个共享资源,所以生产者与生产者,消费者与消费者,生产者与消费者之间是一个互斥关系,而我们访问数据一定是生产者先生产,消费者再消费,所以生产者与消费者之间是一个同步关系。
3. 生产者消费者模型的实现
3.1 基于阻塞队列实现
阻塞队列就是队列的一种,但其要求:
- 当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中放入了元素。
- 当队列满时,往队列里存放元素的操作会被阻塞,直到有元素从队列中取出。
其中阻塞队列最典型的应用场景实际上就是管道的实现。
首先我们可以先实现 BlockingQueue
的框架:首先我们需要一个队列 _q
作为成员变量以及表示其容量的 _cap
,并且因为涉及多执行流访问,需要一把互斥锁 _mutex
,最后我们还需要两个条件变量 _empty
与·_full
分别表示当我们队列为空时,执行消费的执行流需加入 _empty
条件变量与当我们队列为满时,执行生产的执行流需加入该条件变量 _full
。
cpp
#include <iostream>
#include <pthread.h>
#include <queue>
#include <unistd.h>
const int defaultnum = 5;
template <class T>
class BlockQueue
{
bool IsFull()
{
return _q.size() == _cap;
}
bool IsEmpty()
{
return _q.empty();
}
public:
BlockQueue(int cap = defaultnum)
: _cap(cap)
{
pthread_mutex_init(&_mutex, nullptr);
pthread_cond_init(&_full, nullptr);
pthread_cond_init(&_empty, nullptr);
}
~BlockQueue()
{
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_full);
pthread_cond_destroy(&_empty);
}
void Push(const T&data);
void Pop(T&data);
private:
std::queue<T> _q;
int _cap;
pthread_mutex_t _mutex;
pthread_cond_t _full;
pthread_cond_t _empty;
};
并且我们实现生产 Push
与消费 Pop
操作也十分简单,生产时如果队列为满,则加入条件变量 _full
等待,没有则正常生产,生产完毕后该队列一定有数据,这时我们就需要唤醒 _empty
条件变量执行消费操作。而消费操作正好对应,如果消费时如果队列为空,则加入条件变量 _empty
等待,否则正常消费,消费完毕后该队列一定不为空,这时我们就需要唤醒 _full
条件变量执行生产操作。并且生产与消费操作都属于临界资源,所以需要加锁。
cpp
void Push(const T&data)
{
pthread_mutex_lock(&_mutex);
while(IsFull())
{
pthread_cond_wait(&_full,&_mutex);
}
_q.push(data);
pthread_mutex_unlock(&_mutex);
pthread_cond_signal(&_empty);
}
void Pop(T&data)
{
pthread_mutex_lock(&_mutex);
while(IsEmpty())
{
pthread_cond_wait(&_empty,&_mutex);
}
data=_q.front();
_q.pop();
pthread_mutex_unlock(&_mutex);
pthread_cond_signal(&_full);
}
需要注意的是,<font style="color:rgb(28, 31, 35);">pthread_cond_wait</font>
函数作为让当前执行流进行等待的函数,存在调用失败的可能性,若调用失败,该执行流会继续往后执行。
在多生产者的情形下,当消费者消费了一个数据后,若使用 <font style="color:rgb(28, 31, 35);">pthread_cond_broadcast</font>
函数唤醒多个生产者,此时若阻塞队列仅有一个空位,且唤醒的生产者与消费者竞争,当生产者持续竞争锁成功时,就可能出现错误。鉴于此,为避免上述情况发生,必须让线程被唤醒后再次进行判断,以确认是否真正满足生产消费条件,所以这里要用 <font style="color:rgb(28, 31, 35);">while</font>
进行判断。
最后我们创建多个线程,进行对应的生产与消费操作即可。
cpp
#include "BlockQueue.hpp"
#include <cstdlib>
#include <ctime>
void *Producer(void *args)
{
pthread_detach(pthread_self());
BlockQueue<int> *bq = static_cast<BlockQueue<int> *>(args);
while (true)
{
int data = rand() % 100 + 1;
bq->Push(data);
std::cout << "Producer: " << data << std::endl;
}
}
void *Consumer(void *args)
{
pthread_detach(pthread_self());
BlockQueue<int> *bq = static_cast<BlockQueue<int> *>(args);
while (true)
{
int data = 0;
bq->Pop(data);
std::cout << "Consumer: " << data << std::endl;
sleep(1);
}
}
int main()
{
srand((unsigned int)time(nullptr));
BlockQueue<int> *bq = new BlockQueue<int>;
for (int i = 0; i < 5; i++)
{
pthread_t tid;
pthread_create(&tid, nullptr, Producer, bq);
}
for (int i = 0; i < 5; i++)
{
pthread_t tid;
pthread_create(&tid, nullptr, Consumer, bq);
}
while(true);
return 0;
}
3.2 基于循环队列实现
我们同样也可以通过循环队列来实现生产者消费者模型,并且在循环队列不为空或者满的情况下,生产者与消费者可以同步执行。并且要求:
- 当生产和消费指向同一个资源的时候,只能一个执行流访问。为空的时候,由生产者去访问;为满的时候,由消费者去访问。
- 消费者不能超过生产者。
- 生产者不能把消费者套圈,因为这样会导致数据被覆盖。
首先我们可以先实现 RingQueue
的框架:首先我们可以使用数组来模仿队列 _q
,以及表示其容量的 _cap
,然后用 _p_pos
与 _c_pos
分别表示生产者与消费者访问数据的下标,其中我们需要两个信号量 _blank_sem
与·_data_sem
分别表示队列未填数据的个数与已填数据的个数,并且因为涉及多执行流访问,我们最后要用两把互斥锁 _p_mutex
与 _c_mutex
来保护生产与消费的临界资源。
cpp
#pragma once
#include <iostream>
#include <pthread.h>
#include <vector>
#include <semaphore.h>
#include <unistd.h>
const int defaultnum = 5;
template <class T>
class RingQueue
{
void P(sem_t &s)
{
sem_wait(&s);
}
void V(sem_t &s)
{
sem_post(&s);
}
void Lock(pthread_mutex_t *mutex)
{
pthread_mutex_lock(mutex);
}
void UnLock(pthread_mutex_t *mutex)
{
pthread_mutex_unlock(mutex);
}
public:
RingQueue(int cap = defaultnum)
: _cap(cap), _p_pos(0), _c_pos(0)
{
_q.resize(_cap);
sem_init(&_blank_sem, 0, _cap);
sem_init(&_data_sem, 0, 0);
pthread_mutex_init(&_p_mutex, nullptr);
pthread_mutex_init(&_c_mutex, nullptr);
}
~RingQueue()
{
sem_destroy(&_blank_sem);
sem_destroy(&_data_sem);
pthread_mutex_destroy(&_p_mutex);
pthread_mutex_destroy(&_c_mutex);
}
void Push(const T &data);
void Pop(T &data);
private:
std::vector<T> _q;
int _cap;
int _p_pos;
int _c_pos;
sem_t _blank_sem;
sem_t _data_sem;
pthread_mutex_t _p_mutex;
pthread_mutex_t _c_mutex;
};
我们实现生产 Push
与消费 Pop
操作也十分简单,生产时如果队列为满,那么未填数据个数 _blank_sem
为 0,该执行流就会被阻塞,没有则正常生产。而消费操作正好对应,如果消费时如果队列为空,那么已填数据个数 _data_sem
为 0,该执行流就会被阻塞,否则就正常消费,并且生产与消费操作都属于临界资源,所以需要加锁。
cpp
void Push(const T &data)
{
P(_blank_sem);
Lock(&_p_mutex);
_q[_p_pos] = data;
_p_pos++;
_p_pos %= _cap;
UnLock(&_p_mutex);
V(_data_sem);
}
void Pop(T &data)
{
P(_data_sem);
Lock(&_c_mutex);
data = _q[_c_pos];
_c_pos++;
_c_pos %= _cap;
UnLock(&_c_mutex);
V(_blank_sem);
}