文章目录
- POSIX信号量
- 信号量接口讲解
- 基于信号量和环形队列实现生产消费模型
- 线程池的实现
Posix信号量和System V信号量作用相同,都是用于共享资源的同步访问,Posix信号量通常用于线程间通信,而System V信号量常用于进程间通信,这篇博客讲解的信号量是Posix标准下的信号量,之后不再特别说明。
POSIX信号量
讲解信号量之前,还需要解剖一下上篇博客写的生产消费模型
该模型是基于阻塞队列构建的,而访问(修改)队列的操作只有push和pop,push是向队尾插入数据,pop是删除队头的数据,在大多数时候,队头和队尾都不是同一块区域,在这种情况下我们就可以一边插入数据到队尾,一边读取队头的数据。
但是阻塞队列并不支持这样的边生产边消费的操作,同一时间下,阻塞队列只允许一个线程进入,也就是只能生产或消费,这也是其互斥特性的体现。但是这个互斥的粒度是否有些太粗了?为什么要线程互斥的访问整个队列,而不是队列下的一小块区域(资源)?
以上是我对阻塞队列的理解,我认为阻塞队列的互斥粒度太粗了,而线程值访问队列的一部分资源,我们只要保证这一部分资源的互斥性就能实现一个效率更高的生产消费模型。
回到正题,要实现粒度更细的互斥,我们可以使用信号量
信号量的本质就是一个计数器,记录当前还允许几个线程访问共享资源,信号量通常被用来保护共享资源,所有对信号量的操作都是原子的。信号量有两个操作
P:申请共享资源中的一块区域,使信号量- -
V:归还共享资源中的一块区域,注意并不是释放,使信号量++
当信号量为0时,无法对信号量进行- -操作,此时申请信号量也就失败了,当信号量申请失败,线程会陷入等待。当信号量的数值不为0,其他线程归还临界资源后,陷入等待的线程就会被唤醒。而等待信号量就绪的线程可能不止一个,但是这些线程都会被有序的唤醒,因为这些线程陷入等待的顺序也是有序的,即信号量实现了负载均衡,每个线程都有机会访问临界资源。这正是信号量机制中同步的体现
信号量接口讲解
使用信号量需要先包含semaphore.h头文件
sem_init:信号量的初始化,信号量类型为sem_t,创建一个sem_t类型的对象后,需要使用sem_init对其进行初始化,将其地址作为参数传入
sem:需要初始化的信号量地址
pshared:为0表示初始化的信号量用于线程间通信,非0表示用于进程间通信,这里我们使用0作为pshared的值
value:信号量的初始值,信号量的申请与归还都与value有关
sem_destroy:信号量的销毁,将其地址作为传入,销毁信号量(调用了sem_init就要调用sem_destroy)
sem_wait:信号量的申请操作P,将信号量–,如果信号量此时为0,就不能申请信号量,申请信号量的线程会陷入该信号量下的等待
sem_post:信号量的归还操作V,将信号量++,如果最终信号量的值大于0,该函数会唤醒在该信号量下等待的一个线程,并将继续锁定信号量(将信号量的value- -),使信号量的值-1
基于信号量和环形队列实现生产消费模型
在生产消费模型中有两个角色,生产者和消费者,生产者关心队列是否为满,因为满了就无法生产数据,从另一个角度来说生产者只关心队列的空间还剩多少。消费者关心队列是否为空,因为空了就无法消费数据,那么也可以理解为消费者只关心队列中数据还剩多少。
所以空间和数据就可以看作两个信号量,最开始时空间信号量的大小等于队列的大小,数据信号量的大小为0,因为队列中没有数据。生产者生产完一个数据,将空间信号量-1,数据信号量+1,消费者消费一个数据,将空间信号量+1,数据信号量-1。
而信号量这个机制可以理解为资源预定,只要线程拿到了信号量,不论拿到信号量之后线程有没有被切走,线程都可以进入临界资源并访问其一部分空间。资源预定也在锁中体现,如果线程拿到了锁,就可以进入临界资源并访问整块临界资源,并且不用当心线程被切走的问题。
但是信号量对临界资源的访问粒度更细,线程拿到信号量后只能访问临界资源的一部分区域,假设信号量被很多线程拿到,那么每个线程访问临界资源区域都不相同。要怎么保证不同线程访问临界资源不同的区域?这个规则的限制由程序员来做
比如一个vector数组,vector的不同下标对应着临界资源的不同区域,所以在线程拿到信号量之后,给线程分配不同的vector下标就能实现对vector的不同区域的访问。在环形队列中,因为队列尾插头删的性质,我们只能给线程队头和队尾的下标,而这两个下标属于共享资源,可以被所有线程看到,对于共享资源,我们需要对其进行保护,这个保护就是保证所有线程对下标的操作都是原子的,要保证原子,就需要对线程加锁
学习数据结构时,我们会在环形队列中空出一个位置,用来区别队列为满和为空两种情况,如果不空出位置,队列为满或为空时,队头和队尾都指向了同一个位置,但是满了就不能push数据,空了就不能pop数据,所以这就导致了接下来的操作不确定,因此我们需要空出一个位置来区别这两种状态。但是在使用信号量的模型中,通过信号量就可以判断当前队列的状态。队列满,数据信号量的大小与队列大小相同,队列空,空间信号量的大小与队列大小相同。
所以在这个模型中,我们要使用到一个环形队列,两个信号量,用来表示队头和队尾的两个下标以及用来保护这两个下标的锁。在大部分情况下,队列都不为空或者不为满,生产者向队尾生产数据的同时,消费者也能向队头消费数据,但是在队列为空时,消费者就无法消费数据,需要陷入等待,等待生产者生产数据。队列为满时,生产者就无法生产数据,需要陷入等待,等待消费者消费数据。
所以在这个模型中,每个线程对临界资源最小区域的访问是互斥的。生产者不能向同一个最小区域生产,消费者不能同时向一个最小区域消费,消费者在最小区域消费的同时,生产者不能在这个区域生产,反之也是如此。剖析了模型的大体结构,接下来就开始搭建这个模型
首先是模型中的成员
template <class T>
class ringqueue
{
private:
vector<T> rq; // 环形队列
sem_t con_sem; // 消费者信号量
sem_t pro_sem; // 生产者信号量
uint32_t con_index; // 队头,消费者消费的下标
uint32_t pro_index; // 队尾,生产者生产的下标
pthread_mutex_t con_mutex; // 保护消费者下标的锁
pthread_mutex_t pro_mutex; // 保护生产者下标的锁
};
构造和析构,对队列中的数据进行初始化和释放
ringqueue(int cap = 5):rq(cap), con_index(0), pro_index(0)
{
sem_init(&con_sem, 0, 0);
sem_init(&pro_sem, 0, rq.size());
pthread_mutex_init(&con_mutex, nullptr);
pthread_mutex_init(&pro_mutex, nullptr);
}
~ringqueue()
{
sem_destroy(&con_sem);
sem_destroy(&pro_sem);
pthread_mutex_destroy(&con_mutex);
pthread_mutex_destroy(&pro_mutex);
}
最后是最重要的生产和消费接口,实现了这两个接口,该模型也就能跑了,这里展示整个模型的代码
template <class T>
class ringqueue
{
public:
ringqueue(int cap = 5):_rq(cap), _con_index(0), _pro_index(0)
{
sem_init(&_con_sem, 0, 0);
sem_init(&_pro_sem, 0, _rq.size());
pthread_mutex_init(&_con_mutex, nullptr);
pthread_mutex_init(&_pro_mutex, nullptr);
}
~ringqueue()
{
sem_destroy(&_con_sem);
sem_destroy(&_pro_sem);
pthread_mutex_destroy(&_con_mutex);
pthread_mutex_destroy(&_pro_mutex);
}
// 生产与消费
void produce(const T &in)
{
// 两个接口都可以进一步优化,最后会讲解
pthread_mutex_lock(&_pro_mutex);
sem_wait(&_pro_sem);
_rq[_pro_index] = in;
_pro_index++;
_pro_index %= _rq.size(); // 保证环形队列的特性
sem_post(&_con_sem);
pthread_mutex_unlock(&_pro_mutex);
}
T consume()
{
// 两个接口都可以进一步优化,最后会讲解
pthread_mutex_lock(&_con_mutex);
sem_wait(&_con_sem);
T tmp = _rq[_con_index];
_con_index++;
_con_index %= _rq.size(); // 保证环形队列的特性
sem_post(&_pro_sem);
pthread_mutex_unlock(&_con_mutex);
return tmp;
}
private:
vector<T> _rq; // 环形队列
sem_t _con_sem; // 消费者信号量
sem_t _pro_sem; // 生产者信号量
uint32_t _con_index; // 队头,消费者消费的下标
uint32_t _pro_index; // 队尾,生产者生产的下标
pthread_mutex_t _con_mutex; // 保护消费者下标的锁
pthread_mutex_t _pro_mutex; // 保护生产者下标的锁
};
生产的过程:先对线程上锁,保证访问的互斥,接着申请信号量,拿到信号量后进入临界资源的最小区域生产数据,数据生产完将用于生产的下标+1,指向下一个可以生产的区域,然后将其取模,保证环形队列的特性,最后归还数据信号量(因为生产了一个数据,数据信号量+1),解锁
消费的过程类似,这里不再赘述,要注意的是,不论是生产还是消费,都是通过下标访问临界资源,并且它们的下标属于共享资源,但是由于锁的保护,下标永远不会出错。
// 测试环形队列的源文件
void *consumer(void *arg)
{
ringqueue<int> *rqp = static_cast<ringqueue<int> *>(arg);
while (1)
{
sleep(1);
int ret = rqp->consume();
cout << "线程[" << pthread_self() << "]消费了一个数据" << ret << endl;
}
}
void *producer(void *arg)
{
ringqueue<int> *rqp = static_cast<ringqueue<int> *>(arg);
while (1)
{
sleep(2);
int data = rand() % 10;
rqp->produce(data);
cout << "线程[" << pthread_self() << "]生产了一个数据" << data << endl;
}
}
int main()
{
srand((unsigned long)time(nullptr));
ringqueue<int> rq;
pthread_t c1, c2, c3;
pthread_t p1, p2, p3;
pthread_create(&c1, nullptr, consumer, &rq);
pthread_create(&c2, nullptr, consumer, &rq);
pthread_create(&c3, nullptr, consumer, &rq);
pthread_create(&p1, nullptr, producer, &rq);
pthread_create(&p2, nullptr, producer, &rq);
pthread_create(&p3, nullptr, producer, &rq);
while (1)
{
sleep(1);
}
pthread_join(c1, nullptr);
pthread_join(c2, nullptr);
pthread_join(c3, nullptr);
pthread_join(p1, nullptr);
pthread_join(p2, nullptr);
pthread_join(p3, nullptr);
return 0;
}
这段demo中生产者每隔2秒生产一次,而消费者每隔1秒消费一次。由于一开始的数据信号量为0,所以申请数据信号量的消费者线程会陷入等待,当一个生产者线程生产了数据,数据信号量会++,并且唤醒一个正在等待数据信号量的消费者线程,最后消费者的消费会随着生产者的生产进行,因为生产者生产的速度慢于消费者,会有这样一个时刻,三个消费者线程因为申请数据信号量失败,都陷入了数据信号量的等待,接着生产者线程生产了数据,数据信号量+1,一个在数据信号量下等待的消费者线程被唤醒,然后因为数据被消费完,该线程没有数据可以消费,又进入了数据信号量的等待,并且这个等待是有序的。
从运行截图可以看出,消费者线程的消费顺序是有序的,观察线程id的后2位,消费者线程id从80->76->72->80->76这样的往复,生产者生产一个数据消费者就消费一个数据,并且因为信号量机制,消费者线程消费的顺序是有序的
该模型中的生产与消费接口还能再优化,对于生产接口:线程一开始就被加了锁,所以只要有生产者在进行生产,其他生产线程就无法进入队列生产,加锁之后当前线程会申请空间信号量,申请成功后向队列生产数据。生产完数据,归还数据信号量,使其++,最后解锁,整个生产过程完成
如果生产者线程先加锁,再申请信号量,那么此时只有当前线程可以申请这个信号量,也就是说其他生产者线程无法申请信号量,因为要申请信号量必须先申请到锁。这样实现的模型当然不会有任何问题,但是这样加锁的粒度是否会太粗了?对信号量的申请需要加锁吗?之前说过,对信号量的P V操作是原子的,所以我们不需要对原子操作加锁,在生产的过程中,我们可以实现粒度更细的加锁
我们可以将申请信号量的操作放在加锁之前,先使一批线程申请到信号量,先预定了资源,只要有信号量,线程就能进入临界区,然后这些拿到信号量的线程再竞争锁,互斥的进入临界区生产数据。这样加锁是对信号量的资源预定机制的合理运用,实现了粒度更细的加锁
线程池的实现
线程池管理着一定数量的线程,这些线程等待监督管理者分配任务,线程池会循环读取(消费)任务列表中的任务,使用线程池可以减少短时间内不断的创建与销毁线程的资源消耗,可以有效的提高资源利用效率,充分的调度每一个线程。
线程池实际也是一个生产消费模型,只是它的消费接口没有对外暴露,只暴露出生产接口,即只能向线程池派发任务,线程池会调用其管理的线程完成任务。整理一下思路,这个模型中首先需要有一个任务列表,暂时用阻塞队列实现,所有线程对阻塞队列的访问都是互斥的,即同一时间只允许一个线程进入队列,所以这里就需要一把锁实现互斥。实现了队列的互斥,还需要实现同步,当队列满时,不能再push数据(派发任务),将生产者线程放入条件变量下等待,当队列为空,不能pop数据(执行任务),将消费者线程放入条件变量下等待,很显然,生产者和消费者的需要在两个不同的条件变量下等待,所以该线程池还需要两个条件变量。而线程池的线程数量和任务列表的容量也需要维护,所以还需要两个变量维护线程数量和任务表的容量。
分析完模型就可以开始搭建模型了,先搭建模型的成员与构造析构
template <class T>
class threadpool
{
public:
threadpool(uint32_t num = 5, uint32_t cap = 10) : _thread_num(num), _is_start(false), _pool_cap(cap)
{
pthread_mutex_init(&_mutex, nullptr);
pthread_cond_init(&_pro_cond, nullptr);
pthread_cond_init(&_con_cond, nullptr);
}
~threadpool()
{
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_pro_cond);
pthread_cond_destroy(&_con_cond);
}
private:
queue<T> _task;
uint32_t _pool_cap;
uint32_t _thread_num;
pthread_mutex_t _mutex;
pthread_cond_t _con_cond;
pthread_cond_t _pro_cond;
// 标识线程池是否启动的布尔变量
bool _is_start;
};
构造和析构,对锁,条件变量,线程数量等进行初始化和销毁。线程池的启动也应该封装成一个接口,调用该接口线程池才能开始执行任务
// 线程池的启动
void start()
{
assert(_thread_num > 0);
_is_start = true;
for (int i = 0; i < _thread_num; i++)
{
pthread_t temp;
pthread_create(&temp, nullptr, threadpool<T>::start_routine, this);
}
// debug
cout << "线程池启动..." << endl;
}
// 线程的执行函数
static void *start_routine(void *arg)
{
// 强转,得到线程池对象的指针
threadpool<T> *tp = static_cast<threadpool<T> *>(arg);
// 线程分离,不用回收线程资源
pthread_detach(pthread_self());
// 消费者线程的消费
while (1)
{
tp->lock();
while (tp->is_empty())
{
tp->wait_task();
}
// 读取任务
T task = tp->pop();
// 任务读取完成,通知生产者此时有位置可以进行生产
tp->wakeup_pro();
tp->unlock();
// 任务的执行,这个操作没有访问临界资源,不用加锁
task.run();
// debug
cout << "线程[" << pthread_self() << "]执行了一个任务:"
<< task._left << task._op << task._right << "=" << task._ret << endl;
}
}
线程池的启动:创建指定数量的线程,使其执行start_routine函数。这里有一个细节,如果将start_routine函数定义为类的成员函数,那么该函数会隐藏一个参数,用来接收this指针,所以类内的start_routine函数除了一个void* arg参数还有一个接收this指针的参数,而线程需要回调的start_routine函数的参数必须只有一个,并且是void* 类型。所以如果将start_routine函数定义为类的成员函数,那么创建的线程就无法调用对应的start_routine函数,因为成员函数多了一个参数。
为了使线程能够调用对应的start_routine函数,我们只能将其定义为静态成员函数,由于静态成员属于整个类,不属于单独的对象,所以静态成员函数不用接收this指针。创建的线程就可以调用静态的start_routine函数,但是线程需要使用互斥锁,条件变量,阻塞队列,也就是需要访问threadpool类的对象所拥有的资源。线程池下的线程都是由threadpool类的对象创建的,这些线程都需要访问同一线程池对象的资源,所以我们可以将this指针传入start_routine函数,只需要对arg指针进行强转就能访问线程池对象的相关资源。
线程的内部消费,每个线程执行start_routine后,都要对任务列表进行消费,读取队列中的任务并执行。消费的过程:将当前线程上锁,循环的检测队列是否有任务可以被读取,如果没有任务,线程就陷入等待,如果有任务,线程就读取任务,唤醒正在等待生产条件就绪的生产者,然后解锁,最后运行读取到的任务。
// 对外暴露的push接口
void push(const T &in)
{
lock();
while (is_full())
{
wait_run();
}
_task.push(in);
wakeup_con();
unlock();
}
对外暴露的push接口:派发任务,接收任务对象in,将线程上锁,循环的检测队列是否有位置生产数据,如果没有位置生产就陷入等待,如果有位置生产就将in任务push进队列,唤醒正在等待消费条件就绪的消费者线程,最后解锁
剩余工作就是对上面接口中一些系统接口的封装,如果不做这些封装,原生的使用系统接口也是可以的
bool is_empty() { return _task.empty(); }
bool is_full() { return _pool_cap == _task.size(); }
void lock() { pthread_mutex_lock(&_mutex); }
void unlock() { pthread_mutex_unlock(&_mutex); }
void wait_task() { pthread_cond_wait(&_con_cond, &_mutex); }
void wait_run() { pthread_cond_wait(&_pro_cond, &_mutex); }
void wakeup_con() { pthread_cond_signal(&_con_cond); }
void wakeup_pro() { pthread_cond_signal(&_pro_cond); }
T pop()
{
T temp = _task.front();
_task.pop();
return temp;
}
线程池的hpp文件
// threadpool.hpp
#pragma once
#include <iostream>
#include <pthread.h>
#include <queue>
#include "task.hpp"
#include <cassert>
using namespace std;
template <class T>
class threadpool
{
public:
threadpool(uint32_t num = 5, uint32_t cap = 10) : _thread_num(num), _is_start(false), _pool_cap(cap)
{
pthread_mutex_init(&_mutex, nullptr);
pthread_cond_init(&_pro_cond, nullptr);
pthread_cond_init(&_con_cond, nullptr);
}
~threadpool()
{
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_pro_cond);
pthread_cond_destroy(&_con_cond);
}
static void *start_routine(void *arg)
{
threadpool<T> *tp = static_cast<threadpool<T> *>(arg);
// 线程分离
pthread_detach(pthread_self());
while (1)
{
tp->lock();
while (tp->is_empty())
{
tp->wait_task();
}
// 执行任务
T task = tp->pop();
tp->wakeup_pro();
tp->unlock();
task.run();
// debug
cout << "线程[" << pthread_self() << "]执行了一个任务:"
<< task._left << task._op << task._right << "=" << task._ret << endl;
}
}
void start()
{
assert(_thread_num > 0);
_is_start = true;
for (int i = 0; i < _thread_num; i++)
{
pthread_t temp;
pthread_create(&temp, nullptr, threadpool<T>::start_routine, this);
}
cout << "线程池启动..." << endl;
}
void push(const T &in)
{
assert(_is_start); //确保线程池已经启动
lock();
while (is_full())
{
wait_run();
}
_task.push(in);
wakeup_con();
unlock();
}
private:
bool is_empty() { return _task.empty(); }
bool is_full() { return _pool_cap == _task.size(); }
void lock() { pthread_mutex_lock(&_mutex); }
void unlock() { pthread_mutex_unlock(&_mutex); }
void wait_task() { pthread_cond_wait(&_con_cond, &_mutex); }
void wait_run() { pthread_cond_wait(&_pro_cond, &_mutex); }
void wakeup_con() { pthread_cond_signal(&_con_cond); }
void wakeup_pro() { pthread_cond_signal(&_pro_cond); }
T pop()
{
T temp = _task.front();
_task.pop();
return temp;
}
queue<T> _task;
uint32_t _pool_cap;
uint32_t _thread_num;
pthread_mutex_t _mutex;
pthread_cond_t _con_cond;
pthread_cond_t _pro_cond;
bool _is_start;
};
搭建完线程池,可以简单的搭建一个task类,对线程池进行测试
// task.hpp
#pragma once
#include <iostream>
class task
{
public:
task(int left, int right, char op)
{
_left = left;
_right = right;
_op = op;
}
void operator()()
{
run();
}
void run()
{
switch (_op)
{
case '+':
_ret = _left + _right;
break;
case '-':
_ret = _left - _right;
break;
case '*':
_ret = _left * _right;
break;
case '/':
{
if (_right == 0)
{
cout << "div by 0" << endl;
_ret = -1;
}
else
{
_ret = _left / _right;
}
}
break;
case '%':
{
if (_right == 0)
{
cout << "mod by 0" << endl;
_ret = -1;
}
else
{
_ret = _left % _right;
}
}
break;
}
}
int _ret;
int _left;
int _right;
char _op;
};
// 测试程序
int main()
{
srand((unsigned long)time(nullptr));
threadpool<task> tp;
tp.start();
string ops = "+-*/%";
while (1)
{
int left = rand() % 50;
int right = rand() % 20;
char op = ops[rand() % ops.size()];
task temp(left, right, op);
cout << "主线程派发了一个任务:" << left << op << right << "=?" << endl;
tp.push(temp);
sleep(1);
}
return 0;
}
task.hpp是用来测试而封装的一个任务类,该类有run接口,调用run就等于将该任务执行,而task任务就是一些简单的加减乘除取模运算。主线程线程调用start启动线程池,然后每隔1秒派发一次任务
从运行结果可以看到,线程池不断的接收主线程派发的任务,并成功的执行了。由于主线程每隔1秒派发一次任务,而线程池的线程会一直接收任务,因为任务列表中没有任务,线程池的线程会陷入条件变量下的等待,主线程每派发一次任务,一个在条件变量下等待的线程就会被唤醒,派发一次唤醒一次。从线程id的后两位可以看出:线程池的线程执行任务的顺序为68->64->60->56->52,这是生产消费模型中的同步体现