目录
一、信号量的概念
1. 信号量的作用
在以前,大家应该都了解过system V中的信号量。在这里,要讲的是POSIX信号量。它的作用和system V的信号量的作用是相同的,都是用于同步操作,达到无冲突的访问共享资源的目的。但是与system V信号量不同的是,POSIX信号量可以用于线程同步。
例如在上篇文章“同步与生产消费模型”中所写的生产消费模型的代码中,缓冲区被锁给保护起来了,在这个区域内,同时只能有一个线程可以访问。这也就是说,只要我们对资源进行整体加锁,其实就是默认了对这份资源的整体使用。
但是也可能存在这么一种情况,虽然这份公共资源是被整体使用的,但它允许同时访问不同的区域。就好比电影院的放映厅可以看成一份公共资源,每个放映厅中同时只能放一个电影,但是它有用大量的座位,可以被多个观众买票观看。
因此,信号量的本质其实就是一个“计数器”,用于衡量临界资源中资源数量的多少。每个线程在访问公共资源之前,都要先去申请一个信号量,只有申请到了信号量的线程才可以继续向下执行代码,否则只能阻塞等待。这就好比我们去电影院买票,只有买了票的观众才能进入放映厅,没有买到票的只能在外面等待电影结束。
只要拥有了信号量,那么这个线程在未来的某段时间内拥有了临界资源的一部分。因此,申请信号量的本质,就是对临界资源中特定小块资源的“预订”机制。多线程下的不同线程访问同一份公共资源的不同区域的行为,就需要由程序员自行编码实现,信号量只提供将公共资源划分为不同区域的功能。即电影院中的售票功能由信号量提供,而座位安排则有工作人员自行决定。
在之前我们写的生产消费模型的代码中,为了控制线程串行访问临界资源,需要通过循环的方式加锁以适应多线程的访问。其本质是因为我们不知道临界资源的使用情况,所以只能通过锁是否被释放来判断临界资源这一整体是否被占用。有了信号量之后,我们就可以明确知道临界资源的资源块数量,通过资源块数量来了解临界资源的使用情况,进而更好的分配临界资源,而无需再像之前那样循环判断锁的状态。
2. 信号量的PV操作
线程要访问临界资源之前,就要先申请信号量。而我们知道,每个线程内都有一个单独的栈结构,保存了自己的函数体。因此,要让多个线程看到同一个信号量,就必须保证信号量也是公共资源。既然信号量是公共资源,就说明信号量也需要被保护起来,以免线程安全问题。
信号量是一个计数器,这就说明信号量其实就可以看做一个数字。当有线程申请信号量时,信号量就需要--;反之线程释放信号量时,就需要++。因此,保证信号量安全的方式,就是让信号量的++和--操作是原子的。
在信号量中,对计数器--,即申请信号量的操作,被叫做“P操作”;对计数器++,即释放信号量的操作,被叫做“V操作”。信号量的核心操作,就是“PV操作”,也叫做“PV原语”。
3. 信号量操作接口
3.1 初始化信号量
信号量其实也是一种数据类型。所以,要使用信号量,首先就要用“sem_t”定义出一个信号量。比如“sem_t sem”。这一点和锁、条件变量都是一样的。
当信号量被定义出来后,就需要进行初始化。信号量的初始化函数是sem_init:
第一个参数sem为要初始化的信号量;第二个参数pshared表示共享方式,0为线程间共享,非0为进程间共享。一般在使用时填为0即可;第三个参数是信号量的初始值,取决于共享资源中资源数量的多少。
3.2 销毁信号量
当信号量不需要使用时,使用sem_destroy销毁即可:
3.3 等待信号量(P操作)
当有线程来申请信号量时,可以使用sem_wait函数,该函数会将信号量-1:
3.4 发布信号量(V操作)
当有线程使用完资源,需要归还信号量时,就可以使用“sem_post”函数,该函数会将信号量+1:
二、循环队列
在上一篇文章“线程同步与生产消费模型”中,我们用“阻塞队列”模拟实现了一个简单的生产消费模型。在阻塞队列中,每次只能有一个生成线程或消费线程访问缓冲区。但是,里面却存有多份资源。很显然,在这种可以将一份公共资源——缓冲区划分为多个小块的情况下,阻塞队列的效率就比较低。既然如此,我们能不能设计另一种访问方式,支持多个线程访问这份公共资源中的不同部分的结构呢?循环队列就可以实现这一目标。
环形队列,可以看成一个首尾相连的数组:
在环形队列中,同时会有两个线程存在,即消费线程和生产线程。在一开始的时候,消费线程和生产线程在同一起始位置。向后运行时,消费线程和生产线程只有两种情况会在同一位置,即环形队列为空或为满时。在其他情况下,消费线程和生产线程所访问的都是同一份资源的不同区域。
这样看可能不太好理解,举个例子。现在有A和B两个人,在它们两个人面前有一张桌子,这个桌子上摆了10个盘子。现在A和B要玩一个放苹果的游戏。游戏开始前,A和B在同一位置站着。游戏开始后,A沿着桌子走,往盘子里面放苹果;B跟在A后面,从盘子里面拿苹果。在这个游戏里面有两个规则:放苹果的人不能超过拿苹果的人;拿苹果的人也不能超过放苹果的人。
通过上面的规则限制,就保证了A永远在B的前面。当A和B相遇时,只有两种情况。一种是A放苹果的速度太快,套了B一个圈,A就只能站在B旁边等B拿完苹果后往空盘子上放苹果;第二种就是B拿苹果的速度太快,直接跟到了A的身后,A放一个苹果,B就拿一个。这两种情况分别就对应了整张桌子上苹果的满和空两种状态。
在上面的例子里面,A就是生产线程,B就是消费线程。A往空盘子里面放苹果的行为,就是生产线程向循环队列传输数据;B从盘子里拿苹果的行为,就是消费线程从循环队列里面拿取数据。
要注意,虽然这个结构的名字是循环队列,但是它的本质上是一个数组。因为消费线程和生产线程要通过数组的下标来传输拿取数据和判断它们是否遵守了不能超过对方的规则。然后通过取模的方式让消费线程和生产线程在数组中循环访问。
那么在这个结构里,信号量的作用是什么呢?其实就是用来衡量循环队列中资源数量的多少。
这里的资源对生产线程和消费线程来讲,是不同的。
对于生产线程而言,这里的资源指的是循环队列中可存放数据的空间。对于消费线程而言,这里的资源指的是循环队列中可拿取资源的数量。因此,可以通过分别给消费线程和生产线程定义一个信号量的方式,让它们提前知道资源的使用情况。
通过上面的讲解,大家应该也都知道了,循环队列相比阻塞队列将资源视为一个整体,循环队列中的资源被划分为了多个小块,在大部分情况下,单生产单消费都是可以并发运行的,只有在满和空时才会有互斥与同步的关系。
三、使用循环队列模拟实现生产消费模型
1. 注意事项
首先要知道,对于生产者和消费者而言,生产者和消费者的信号量代表不同的资源。
生产者的资源是循环队列中的空位个数,所以初始值应为循环队列的可放入数据空间个数。当生产者申请成功信号量,即执行P操作时,表明该线程可以继续向下运行;申请失败,则阻塞在申请信号的位置。当生产执行完将数据放入到循环队列中的任务后,就需要释放信号量,即执行V操作。但是要注意,这里生产者的资源是可放入资源的空位,所以在生产者放入资源后,并不是对生产者的信号量做V操作,而是对消费者的信号量做V操作,++消费者的信号量,告知消费者此时有资源可以消费。生产者自身的信号量保持不变。
消费者的资源是可拿取资源的个数,所以初始值应为0。当线程进入时,首先会遇到信号量为0的情况,此时就需要等待生产者生产数据并++消费者的信号量。当线程申请到信号量后,就可以向下运行。拿取数据后,消费者就要释放信号量,执行V操作。同样的,这里的消费者也是释放生产者的信号量,++生产者的信号量,告诉生产者此时又有空位可以放入数据了。
那么如何控制生产线程和消费线程的位置呢?就是通过数组下标来控制。当为空或为满时,生产线程和消费线程的下标相同,此时就说明该位置同时一定只能有一个线程访问,需要维护互斥与同步。在其他情况下,因为已经用信号量保证了生产线程和消费线程一定不会超过对方,所以其他情况下生产线程和消费线程访问的就是不同区域,支持并发。
2. 循环队列文件
#pragma once
#include<iostream>
#include<vector>
#include<semaphore.h>
#include<cassert>
#include<pthread.h>
static const int gcap = 5;//环形队列中的数据个数
template<class T>
class RingQueue
{
public:
RingQueue(const int& cap = gcap)
:_queue(cap), _cap(cap)
{
int n = sem_init(&_spaceSem, 0, _cap);//初始化生产者的信号量
assert(n == 0);
n = sem_init(&_dataSem, 0, 0);//初始化消费者的信号量
assert(n == 0);//返回0说明信号量初始化成功
(void)n;
_ProductorStep = _ConsumerStep = 0;//从0下标开始访问
pthread_mutex_init(&_pmutex, nullptr);
pthread_mutex_init(&_cmutex, nullptr);//初始化锁
}
void P(sem_t& sem)//p操作,申请信号量
{
int n = sem_wait(&sem);
assert(n == 0);
(void)n;
}
void V(sem_t& sem)//v操作,释放信号量
{
int n = sem_post(&sem);
assert(n == 0);
(void)n;
}
void Push(const T& in)//向唤醒队列发送数据
{
P(_spaceSem);//生产者申请信号量
pthread_mutex_lock(&_pmutex);//加锁
_queue[_ProductorStep++] = in;//将数据放入循环队列
_ProductorStep %= _cap;//取模,保证下标一直位于数组中
V(_dataSem);//释放消费者的信号量,告知消费者有数据可消费
pthread_mutex_unlock(&_pmutex);//解锁
}
void Pop(T* out)//从环形队列接收数据
{
P(_dataSem);//消费者申请信号量
pthread_mutex_lock(&_cmutex);//加锁
*out = _queue[_ConsumerStep++];//消费者获取数据
_ConsumerStep %= _cap;//取模,保证下标位于数组中
V(_spaceSem);//释放生产者的信号量,告知生产者有空位可存放数据
pthread_mutex_unlock(&_cmutex);//解锁
}
~RingQueue()
{
sem_destroy(&_spaceSem);
sem_destroy(&_dataSem);
pthread_mutex_destroy(&_pmutex);
pthread_mutex_destroy(&_cmutex);
}
private:
std::vector<T> _queue;//环形队列
int _cap;//队列中的数据个数
sem_t _spaceSem;//生产者的信号量(空间资源)
sem_t _dataSem;//消费者的信号量(数据资源)
int _ProductorStep;//生产者的位置
int _ConsumerStep;//消费者的位置
pthread_mutex_t _pmutex;//生产者的锁
pthread_mutex_t _cmutex;//消费者的锁
};
3. 测试文件
#include"RingQueue.hpp"
#include"Task.hpp"
#include<pthread.h>
#include<cstdlib>
#include<ctime>
#include<unistd.h>
const int pNum = 4;
const int cNum = 8;
std::string selfName()
{
char name[64];
snprintf(name, sizeof(name), "thread[0x%x]", pthread_self());
return name;
}
void* ProductorRoutine(void* args)//生产工作
{
RingQueue<CalTask>* rq = static_cast<RingQueue<CalTask>*>(args);//获取工作
while(true)
{
//执行生产活动
int x = rand() % 10 + 1;
int y = rand() % 20 + 1;
char ch = oper[rand() % oper.size()];
CalTask cal(x, y, ch, mymath);
rq->Push(cal);//派发任务
std::cout << selfName() << "生产者派发了一个任务: " << cal.tostring() << std::endl;
sleep(1);
}
}
void* ConsumerRoutine(void* args)//消费工作
{
RingQueue<CalTask>* rq = static_cast<RingQueue<CalTask>*>(args);//获取工作
while(true)
{
//执行消费活动
CalTask cal;
rq->Pop(&cal);
std::string result = cal();
std::cout << selfName() << "消费者消费了一个任务: " << result << std::endl;
}
}
int main()
{
srand(time(0));//生成随机数种子
RingQueue<CalTask>* rq = new RingQueue<CalTask>;
pthread_t p[pNum], c[cNum];
for(int i = 0; i < pNum; ++i) pthread_create(p + i, nullptr, ProductorRoutine, (void*)rq);
for(int i = 0; i < cNum; ++i) pthread_create(c + i, nullptr, ConsumerRoutine, (void*)rq);
for(int i = 0; i < pNum; ++i) pthread_join(p[i], nullptr);
for(int i = 0; i < pNum; ++i) pthread_join(c[i], nullptr);
delete rq;
return 0;
}
4. 任务文件
#include<iostream>
#include<functional>
#include<string>
#include<stdio.h>
class CalTask//计算任务
{
typedef std::function<int(int, int, char)> func_t;//函数指针
public:
CalTask()
{}
CalTask(const int& x, const int& y, const char& op, func_t func)
:_x(x), _y(y), _op(op), _func(func)
{}
std::string operator()()
{
int result = _func(_x, _y, _op);//外部传入的计算函数
char buffer[64];
snprintf(buffer, sizeof(buffer), "%d %c %d = %d", _x, _op, _y, result);
return buffer;
}
std::string tostring()
{
char buffer[64];
snprintf(buffer, sizeof(buffer), "%d %c %d = ?", _x, _op, _y);
return buffer;
}
private:
int _x;
int _y;
char _op;
func_t _func;
};
std::string oper = "+-*/%";//提供五种计算方法
int mymath(int x, int y, char op)//计算方法
{
int result = 0;
switch(op)
{
case '+':
{
result = x + y;
break;
}
case '-':
{
result = x - y;
break;
}
case '*':
{
result = x * y;
break;
}
case '/':
{
if(y == 0)
{
std::cout << "div zero error!" << std::endl;
result = -1;
}
else
result = x / y;
break;
}
case '%':
{
if(y == 0)
{
std::cout << "mod zero error" << std::endl;
result = -1;
}
else
result = x % y;
break;
}
default:
break;
}
return result;
}
在上面的循环队列中,有几个注意事项。首先是在循环队列中的push和pop函数中,加锁的位置要在申请信号量后面,这样才能保证线程进来后先让信号量判断是否有内容可读,要不要向后运行。第二个就是,因为是要通过下标来判断生产线程和消费线程的使用的资源,所以最好提供两个变量,标识消费线程和生产线程的位置。
5. 循环队列的高效问题
在循环队列中,虽然支持生产线程和消费线程并发运行,但是在同一时刻,只允许一个生产线程和一个消费线程生产拿取数据。在多生产多消费的情况下,依然可以看做串行运行。因此,使用循环队列的生产消费模型的高效并不是体现在传入和拿取任务的时候,而是支持多个生产线程并发拿取数据生成任务和多个消费线程并发执行计算任务上,这一点和使用阻塞队列的生产消费模型是一样的。