如果要让执行流同时访问临界资源的不同区域的话,就需要引入信号量了。
信号量(信号灯)本质是一个计数器,是描述临界资源中资源数目的计数器,信号量能够更细粒度地对临界资源进行管理。
每个执行流在进入临界区之前先申请信号量,申请成功就有了操作临界资源的权限,当操作完毕后就应该释放信号量。
信号量的PV操作:
信号量的初始化函数
返回值说明:
注意:POSIX信号量和System V信号量作用相同,都是用于同步操作,达到无冲突地访问共享资源的目的,但是POSIX信号量可以用于线程间同步。
信号量的销毁函数
返回值说明:
等待信号量(申请信号量)
返回值说明:
发布信号量(释放信号量)
返回值说明:
信号量本质是一个计数器,如果将信号量的初始值设置为1,那么此时该信号量叫做二元信号量
信号量的初识值为1,说明信号量所描述的临界资源只有一份,此时信号量的作用基本等价于互斥锁。
例如,下面我们实现一个多线程抢票系统,其中我们用二元信号量模拟实现多线程互斥。
#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 = 1000;
void* ticketGet(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(nullptr);
}
int main()
{
pthread_t t1, t2, t3, t4;
pthread_create(&t1, nullptr, ticketGet, (void*)"thread 1");
pthread_create(&t2, nullptr, ticketGet, (void*)"thread 2");
pthread_create(&t3, nullptr, ticketGet, (void*)"thread 3");
pthread_create(&t4, nullptr, ticketGet, (void*)"thread 4");
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
pthread_join(t3, nullptr);
pthread_join(t4, nullptr);
return 0;
}
生产者关注的是空间资源,消费者关注的是数据资源
对于生产者和消费者来说,它们关注的资源是不同的:
blank_sem和data_sem的初始值设置
现在我们用信号量来描述环形队列中的空间资源(blank_sem)和数据资源(data_sem),在我们初识化信号量时给它们设置的初始值是不同的;
生产者申请空间资源,释放数据资源
对于生产者来说,生产者每次生产数据前都需要先申请blank_sem:
当生产者生产完数据之后,应该释放data_sem:
消费者申请数据资源,释放空间资源
对于消费者来说,消费者每次消费数据前都需要先申请data_sem:
当消费者消费完数据之后,应该释放blank_sem:
第一个规则:生产者和消费者不能对同一个位置进行访问
生产者和消费者在访问环形队列时:
第二个规则:无论是生产者还是消费者,都不能超过对方一圈以上
ringQeueu的代码实现如下:
#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); // 申请blank信号量
_q[_p_pos] = data; // 生产数据
V(_data_sem); // 释放data信号量
// 更新下一次生产的位置
_p_pos++;
_p_pos %= _cap;
}
void pop(T& out)
{
P(_data_sem); // 申请data信号量
out = _q[_c_pos]; // 消费数据
V(_blank_sem); // 释放blank信号量
// 更新下一次消费的位置
_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; // 描述数据资源
};
为了方便理解,我们实现单生产者单消费者的模型。在主函数中创建一个生产者线程和一个消费者线程,生产者线程不断将数据放入环形队列,消费者线程不断从环形队列里取出数据进行消费。
#include "ringQueue.hpp"
void* productor(void* arg)
{
ringQueue<int>* rq = (ringQueue<int>*)arg;
while (1)
{
sleep(1);
int data = rand() % 100 + 1;
rq->push(data);
std::cout << "productor: " << data << std::endl;
}
}
void* consumer(void* arg)
{
ringQueue<int>* rq = (ringQueue<int>*)arg;
while (1)
{
int data = 0;
rq->pop(data);
std::cout << "consumer: " << data << std::endl;
}
}
int main()
{
srand((unsigned int)time(nullptr));
pthread_t p, c;
ringQueue<int>* rq = new ringQueue<int>();
pthread_create(&p, nullptr, productor, rq);
pthread_create(&c, nullptr, consumer, rq);
pthread_join(p, nullptr);
pthread_join(c, nullptr);
delete rq;
return 0;
}
运行结果如下:
在我们自己所写的代码中,我们虽然只让生产者一秒生产一次,而没让消费者的消费速度受限制,但是它们最终的步调是一致的,这正是信号量的作用所在。
在blank_sem和data_sem两个信号量的保护后,该环形队列中不可能会出现数据不一致的问题。
因为只有当生产者和消费者指向同一个位置并访问时,才会导致数据不一致的问题,而此时生产者和消费者在对环形队列进行写入或读取数据时,只有两种情况会指向同一位置:
环形队列为空时
环形队列为满时
但是在这两种情况下,生产者和消费者不会同时对环形队列进行访问:
也就是说,当环形队列为空和满时,我们已经通过信号量保证了生产者和消费者的串行化过程。而除了这两种情况之外,生产者和消费者指向的都不是同一个位置,因此该环形队列中不可能会出现数据不一致的问题。并且大部分情况下生产者和消费者指向并不是同一位置,因此大部分情况下该环形队列可以让生产者和消费者并发地执行。