小编是双非本科大二菜鸟不赘述,欢迎米娜桑来指点江山哦
1319365055
非科班转码社区诚邀您入驻
小伙伴们,满怀希望,所向披靡,打码一路向北
一个人的单打独斗不如一群人的砥砺前行
这是和梦想合伙人组建的社区,诚邀各位有志之士的加入!!
社区用户好文均加精(“标兵”文章字数2000+加精,“达人”文章字数1500+加精)
直达: 社区链接点我
我们将可能会被多个执行流同时访问的资源叫做临界资源,临界资源需要进行保护否则会出现数据不一致等问题。
当我们仅用一个互斥锁对临界资源进行保护时,相当于我们将这块临界资源看作一个整体,同一时刻只允许一个执行流对这块临界资源进行访问。
但实际可以将这块临界资源再分割多个区域,当多个执行流访问临界资源时,如果执行流访问的是临界资源的不同区域,那么我们可以让这些执行流同时访问这些不同区域,此时不会出现数据不一致等问题
信号量(信号灯)本质是一个计数器,是描述临界资源中资源数目的计数器,信号量能够更细粒度的对临界资源进行管理。
每个执行流在进入临界区之前都应该先申请信号量,申请成功就有了操作特点的临界资源的权限,当操作完毕后就应该释放信号量。
P操作:申请信号量称为P操作,操作本质是申请获得临界资源中某块资源的使用权限,当申请成功时临界资源中资源的数目应该减一,因此P操作的本质就是让计数器 -1。
V操作:释放信号量称为V操作,操作本质是归还临界资源中某块资源的使用权限,当释放成功时临界资源中资源的数目就应该加一,因此V操作的本质就是让计数器加 +1。
P V 操作必须是原子操作 \color{red} {PV操作必须是原子操作} PV操作必须是原子操作:
多个执行流操作是竞争式的在临界资源中申请信号量,因为信号量本来就是临界资源,但信号量本质又是来保护临界资源的,因此这里矛盾就是我不能使用临界资源来保护临界资源,所以PV操作必须是原子的
内存当中变量的 ++、-- 操作并不是原子操作,因此信号量不可能只是简单的对一个全局变量进行 ++、-- 操作。
申请信号量失败被挂起等待
当执行流在申请信号量时,临界资源可能已经全部被申请了,此时信号量的值就是 0,也就是说该执行流就应该在该信号量的等待队列当中进行等待,直到有信号量被释放时再被唤醒( 信号量虽然本质是计数器,但不意味着只有计数器,还包括一个等待队列!)
我们使用 sem_init ,函数原型如下:
int sem_init(sem_t *sem, int pshared, unsigned int value);
这里 sem 即需要初始化的信号量;pshared 即传入0值表示线程间共享,传入非零值表示进程间共享;value 即信号量初始值(计数器的初始值)。初始化成功返回 0,失败后返回 -1。
POSIX 信号量和 System V 信号量作用相同,都是用于同步操作,达到无冲突的访问共享资源的目的,但 POSIX 信号量可以用于线程间同步。
我们使用 sem_destroy,函数原型如下:
int sem_destroy(sem_t *sem);
这里 sem 即需要销毁的信号量,销毁成功返回 0,失败返回 -1。
我们使用 sem_destroy,函数原型如下:
int sem_wait(sem_t *sem);
sem 即需要等待的信号量。等待成功返回0,信号量的值 -1; 等待失败返回 -1,信号量的值保持不变。
我们使用 sem_post,函数原型如下:
int sem_post(sem_t *sem);
sem 即需要发布的信号量。发布成功返回0,信号量的值 +1; 等待失败返回 -1,信号量的值保持不变。
信号量本质是一个计数器,二元信号量其实就是将信号量的初始值设置为1。
信号量的初始值为1,说明信号量所描述的临界资源只有一份,此时信号量的作用基本等价于互斥锁。例如,我们还是实现一个多线程抢票系统,并用二元信号量模拟实现多线程互斥。
我们在主线程当中创建四个新线程,让这四个新线程执行抢票逻辑,其中我们用全局变量 tickets 记录当前剩余的票数,此时 tickets 是会被多个执行流同时访问的临界资源,我们在逻辑当中加入二元信号量,让每个线程在访问全局变量 tickets 之前先申请信号量,访问完毕后再释放信号量,此时二元信号量达到了互斥的效果避免了负数票数的情况:
#include
#include
#include
#include
#include
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;
};
Sem sem(1); //二元信号量
int tickets = 2000;
void* TicketGrabbing(void* arg)
{
std::string name = (char*)arg;
while (true){
sem.P();
if (tickets > 0){
usleep(1000);
std::cout << name << " get a ticket, tickets left: " << --tickets << std::endl;
sem.V();
}
else{
sem.V();
break;
}
}
std::cout << name << " quit..." << std::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;
}
生产者关注的是环形队列当中是否有空间(blank),消费者关注的是环形队列当中是否有数据(data),只要有数据消费者就可以进行消费。
我们用信号量来描述环形队列当中的空间资源(blank_sem)和数据资源(data_sem),在我们初始信号量时给它们设置的初始值是不同的:blank_sem 的初始值应该设为环形队列的容量,因为刚开始时环形队列当中全是空间;data_sem 的初始值应该设为 0,因为刚开始时环形队列当中没有数据。
对于生产者来说,每次生产数据前都需要先申请 blank_sem:如果 blank_sem 的值不为 0,则信号量申请成功,此时生产者可以进行生产操作;反之则信号量申请失败,此时生产者需要在 blank_sem 的等待队列下进行阻塞等待,直到有新的空间后再被唤醒。
生产完数据后就该释放 data_sem:注意虽然生产前是对 blank_sem 进行的 P 操作,但是现在 V 操作应该对 data_sem 进行而不是 blank_sem。
生产者在生产数据前申请到的是 blank 位置,生产完数据后该位置中存储的是生产的数据,在该数据被消费者消费之前,该位置不再是 blank 位置而是 data 位置。生产者生产完数据后,环形队列当中会多一个 data 位置,因此我们应该对 data_sem 进行 V 操作
消费者同理,消每次消费数据前都需要先申请 data_sem:如果 data_sem 不为 0 则信号量申请成功,此时消费者可以进行消费;反之信号量申请失败,此时消费者需要在 data_sem 的等待队列下进行阻塞等待,直到环形队列当中有新的数据后再被唤醒。
当消费者消费完数据后,应该释放blank_sem:虽然消费者在进行消费前是对 data_sem 进行的 P 操作,但是当消费者消费完数据,V 操作应该对 blank_sem 进行而不是 data_sem。
消费者在消费数据前申请到的是 data 位置,消费完数据后该位置的数据已经被消费过了,再次被消费就没有意义了,为了让生产者后续可以在该位置生产新的数据,我们应该将该位置算作 blank 位置而不是 data 位置。当消费完数据后,意味着环形队列当中多了一个 blank 位置,因此应该对 blank_sem 进行 V 操作
环形队列模型中生产者和消费者必须遵守如两个规则:
如果生产者和消费者访问的是环形队列中的同一个位置,那么此时生产者和消费者就相当于同时对这一块临界资源进行了访问,这当然是不允许的。
生产者从消费者的位置一直按顺时针方向进行生产,如果生产者的速度比消费者的速度快,那么当生产了一圈后再次遇到消费者,此时生产者就不应该再继续生产了,因为再生产就会覆盖还未被消费的数据。同理,如果消费者的速度比生产者的速度快,那么当消费了一圈数据后再次遇到生产者,此时消费者就不应该再继续消费了,因为会消费到缓冲区中保存的废弃数据。
#pragma once
#include
#include
#include
#include
#include
#define NUM 8
template<class T>
class RingQueue
{
private:
//P操作
void P(sem_t& s)
{
sem_wait(&s);
}
//V操作
void V(sem_t& s)
{
sem_post(&s);
}
public:
RingQueue(int cap = NUM)
: _cap(cap), _p_pos(0), _c_pos(0)
{
_q.resize(_cap);
sem_init(&_blank_sem, 0, _cap); //blank_sem初始值设置为环形队列的容量
sem_init(&_data_sem, 0, 0); //data_sem初始值设置为0
}
~RingQueue()
{
sem_destroy(&_blank_sem);
sem_destroy(&_data_sem);
}
//向环形队列插入数据(生产者调用)
void Push(const T& data)
{
P(_blank_sem); //生产者关注空间资源
_q[_p_pos] = data;
V(_data_sem); //生产
//更新下一次生产的位置
_p_pos++;
_p_pos %= _cap;//取模达到环形效果
}
//从环形队列获取数据(消费者调用)
void Pop(T& data)
{
P(_data_sem); //消费者关注数据资源
data = _q[_c_pos];
V(_blank_sem);
//更新下一次消费的位置
_c_pos++;
_c_pos %= _cap;//取模达到环形效果
}
private:
std::vector<T> _q; //环形队列
int _cap; //环形队列容量
int _p_pos; //生产位置
int _c_pos; //消费位置
sem_t _blank_sem; //描述空间资源
sem_t _data_sem; //描述数据资源
};
注意当没有设置环形队列的大小时,默认将容量上限设置为 8。
p_pos 只会由生产者线程更新,c_pos 只会由消费者线程更新,所以这两个变量访问时不需要保护,因此代码中将 p_pos 和 c_pos 的更新放到了 V 操作之后,就是为了尽量减少临界区的代码。
为了方便理解,这里实现单生产者、单消费者的生产者消费者模型。于是在主函数就只需要创建一个生产者线程和一个消费者线程,生产者线程不断生产数据放入环形队列,消费者线程不断从环形队列里取出数据进行消费:
#include "RingQueue.hpp"
void* Producer(void* arg)
{
RingQueue<int>* rq = (RingQueue<int>*)arg;
while (true){
sleep(1);
int data = rand() % 100 + 1;
rq->Push(data);
std::cout << "Producer: " << data << std::endl;
}
}
void* Consumer(void* arg)
{
RingQueue<int>* rq = (RingQueue<int>*)arg;
while (true){
sleep(1);
int data = 0;
rq->Pop(data);
std::cout << "Consumer: " << data << std::endl;
}
}
int main()
{
srand((unsigned int)time(nullptr));
pthread_t producer, consumer;
RingQueue<int>* rq = new RingQueue<int>;
pthread_create(&producer, nullptr, Producer, rq);
pthread_create(&consumer, nullptr, Consumer, rq);
pthread_join(producer, nullptr);
pthread_join(consumer, nullptr);
delete rq;
return 0;
}
环形队列要让生产者线程向队列中 Push 数据,让消费者线程从队列中 Pop 数据,因此就必须要让这两个线程同时看到环形队列,所以我们在创建生产者线程和消费者线程时,需要将环形队列作为线程执行例程的参数进行传入,此时生产者消费者步调是一致的。
我们可以让生产者不停的进行生产,而消费者每隔一秒进行消费,模拟供大于求:
void* Producer(void* arg)
{
RingQueue<int>* rq = (RingQueue<int>*)arg;
while (true){
int data = rand() % 100 + 1;
rq->Push(data);
std::cout << "Producer: " << data << std::endl;
}
}
void* Consumer(void* arg)
{
RingQueue<int>* rq = (RingQueue<int>*)arg;
while (true){
sleep(1);
int data = 0;
rq->Pop(data);
std::cout << "Consumer: " << data << std::endl;
}
}
由于生产者生产的更快,运行后一瞬间生产者就将环形队列就满了,此时生产者想要再进行生产,但空间资源已经为 0了,于是生产者只能在 blank_sem 的等待队列下进行阻塞等待,直到由消费完一个数据后对 blank_sem 进行了 V 操作,生产者才会被唤醒。但由于生产者的生产速度很快,生产者生产完一个数据后又会进行等待,因此后续生产者和消费者的步调又变成一致的了:
当然也可以让生产者每隔一秒进行生产,而消费者不停的进行消费,模拟供不应求:
void* Producer(void* arg)
{
RingQueue<int>* rq = (RingQueue<int>*)arg;
while (true){
sleep(1);
int data = rand() % 100 + 1;
rq->Push(data);
std::cout << "Producer: " << data << std::endl;
}
}
void* Consumer(void* arg)
{
RingQueue<int>* rq = (RingQueue<int>*)arg;
while (true){
int data = 0;
rq->Pop(data);
std::cout << "Consumer: " << data << std::endl;
}
}
在 b l a n k s e m 和 d a t a s e m 两个信号量的保护后,该环形队列中不可能会出现数据不一致的问题 \color{red} {在 blank_sem 和 data_sem 两个信号量的保护后,该环形队列中不可能会出现数据不一致的问题} 在blanksem和datasem两个信号量的保护后,该环形队列中不可能会出现数据不一致的问题:
因为只有当生产者和消费者指向同一个位置时,才会导致数据不一致的问题,而此时生产者和消费者在对环形队列进行写入或读取数据时,只有两种情况会指向同一个位置:
也就是说环形队列为空和满时,我们已经通过信号量保证了生产者和消费者的串行化过程。而除了这两种情况之外,生产者和消费者指向的都不是同一个位置,环形队列中就不会出现数据不一致的问题。并且大部分情况下并不会指向同一个位置,因此大部分情况下可以让生产者和消费者并发执行