上一章节我们学习了线程的同步与互斥,学习了互斥锁和条件变量的使用。本章我们将学习编程的一个重要模型,生产者消费者模型,并且运用之前学的线程同步和互斥的相关接口来实现阻塞队列和环形队列,最后再来实现一个简易的线程池。
目标已经确定,准备开讲啦……
生产者消费者模型是同步与互斥的最典型的应用场景:(重新认识条件变量)
1.消费者有多个,消费者之间是什么关系呢?
2.供应商有多个,供应商之间是什么关系呢?
3.消费者和供应商之间又是什么关系呢?
除了要保证临界资源的安全性之外,还要保证生产消费过程中的合理性。
3 2 1 原则:
3种关系
线程承担的2种角色
1个交易场所
基于生产者和消费者模型的阻塞队列。
设计的这个队列要保证,队列元素如果为满的情况下,就不能让生产者生产了,如果为空的情况下,就不能让消费者来消费了,那么这个的队列就称作为阻塞队列。
生产接口:
这就是纯互斥,生产者一直在抢占锁,而导致消费线程的饥饿。同样的道理,消费线程也是如此。
这种场景没错,但是不合理:
这种就叫做同步式的阻塞队列。
既然是阻塞队列,再结合线程互斥与同步来维护该队列:
代码演示:
#pragma once
#include
#include
#include
#include
#include
// 默认容量大小
const uint32_t gDefaultCap = 5;
template <class T>
class BlockQueue
{
public:
BlockQueue(uint32_t cap = gDefaultCap)
: cap_(cap)
{
pthread_mutex_init(&mutex_, nullptr);
pthread_cond_init(&conCond_, nullptr);
pthread_cond_init(&proCond_, nullptr);
}
~BlockQueue()
{
pthread_mutex_destroy(&mutex_);
pthread_cond_destroy(&conCond_);
pthread_cond_destroy(&proCond_);
}
private:
uint32_t cap_; // 容量
queue<T> bq_; // blockqueue
pthread_mutex_t mutex_; // 保护阻塞队列的互斥锁
pthread_cond_t conCond_; // 让消费者等待的条件变量
pthread_cond_t proCond_; // 让生产者等待的条件变量
};
我们这里用的是C++的类模板,阻塞队列里的内容就可以相对灵活一些了。
阻塞队列类内函数:
入队(push):
// 生产接口
void push(const T &in) // const &: 纯输入
{
// 先把队列锁住
lockQueue();
while (isFull()) // ifFull就是我们在临界区中设定的条件
{
proBlockWait();
}
// 条件满足,可以生产
pushCore(in); // 生产完成
// wakeupCon(); // 唤醒消费者
// 把队列解锁
unlockQueue();
wakeupCon(); // 生产完了,生产者就要唤醒消费者
}
生产之前要判断判断,是否适合生产:
if(满)
不生产(不仅仅要不生产),休眠(更要休眠),休眠期间消费线程就可以去申请锁了。else if(不满)
生产,唤醒消费者。为什么要用while
判断而不用if
判断:
mutex_
(因为不能拿着锁去等)。因为一些原因导致了被伪唤醒了:
先解锁还是先唤醒,以生产者为例:
当消费者在解锁之前被唤醒时:
当消费者在解锁之后被唤醒时:
pthread_cond_wait
里返回并且把锁重新持有,接下来进行后续操作,进行消费。出队(pop):
// 消费接口
T pop()
{
// 先把队列锁住
lockQueue();
while (isEmpty())
{
conBlockwait(); // 阻塞等待,等待被唤醒,?
}
// 条件满足,可以消费
T tmp = popCore();
// 把队列解锁
unlockQueue();
wakeupPro(); // 消费完了,消费者就要唤醒生产者
return tmp;
}
消费之前要判断是否适合消费:
if(空)
不消费,休眠。else if(有)
消费,唤醒生产者。消费接口唤醒生产者和解锁顺序同上生产者操作。
为了代码的可读性,也是为了以后能够修改方便,我们对加锁,条件变量等进行了封装:
void lockQueue()
{
pthread_mutex_lock(&mutex_);
}
void unlockQueue()
{
pthread_mutex_unlock(&mutex_);
}
bool isEmpty()
{
return bq_.empty();
}
bool isFull()
{
return bq_.size() == cap_;
}
// 生产者进行阻塞等待
void proBlockWait() // 生产者一定是在临界区中的!
{
// 1. 在阻塞线程的时候,会自动释放mutex_锁
pthread_cond_wait(&proCond_, &mutex_);
}
// 消费者进行阻塞等待
void conBlockwait() // 阻塞等待,等待被唤醒
{
// 1. 在阻塞线程的时候,会自动释放mutex_锁
pthread_cond_wait(&conCond_, &mutex_);
// 2. 当阻塞结束,返回的时候,pthread_cond_wait,会自动帮你重新获得mutex_,然后才返回
}
// 唤醒生产者
void wakeupPro()
{
// 一定要在生产者所在的条件变量下唤醒
pthread_cond_signal(&proCond_);
}
// 唤醒消费者
void wakeupCon()
{
// 一定要在消费者所在的条件变量下唤醒
pthread_cond_signal(&conCond_);
}
// 生产完成
void pushCore(const T &in)
{
bq_.push(in);
}
// 消费
T popCore()
{
T tmp = bq_.front();
bq_.pop();
return tmp;
}
因为阻塞队列我们实现的时候是用了类模版,所以我们可以给队列分配Task对象(任务)
#pragma once
#include
#include
using namespace std;
class Task
{
public:
Task() : elemOne_(0), elemTwo_(0), operator_('0')
{
}
Task(int one, int two, char op) : elemOne_(one), elemTwo_(two), operator_(op)
{
}
int operator() ()
{
return run();
}
int run()
{
int result = 0;
switch (operator_)
{
case '+':
result = elemOne_ + elemTwo_;
break;
case '-':
result = elemOne_ - elemTwo_;
break;
case '*':
result = elemOne_ * elemTwo_;
break;
case '/':
{
if (elemTwo_ == 0)
{
cout << "div zero, abort" << endl;
result = -1;
}
else
{
result = elemOne_ / elemTwo_;
}
}
break;
case '%':
{
if (elemTwo_ == 0)
{
std::cout << "mod zero, abort" << std::endl;
result = -1;
}
else
{
result = elemOne_ % elemTwo_;
}
}
break;
default:
cout << "非法操作: " << operator_ << endl;
break;
}
return result;
}
// 输出型参数
int get(int *e1, int *e2, char *op)
{
*e1 = elemOne_;
*e2 = elemTwo_;
*op = operator_;
}
private:
int elemOne_;
int elemTwo_;
char operator_;
};
生产者生产任务并放入到阻塞队列当中:
void *productor(void *args)
{
BlockQueue<Task> *bqp = static_cast<BlockQueue<Task> *>(args);
while (true)
{
// 1. 制作任务 --- 要不要花时间?? -- 网络,磁盘,用户
int one = rand() % 50;
int two = rand() % 20;
char op = ops[rand() % ops.size()];
Task t(one, two, op);
// 2. 生产任务
bqp->push(t);
cout << "producter[" << pthread_self() << "] "
<< (unsigned long)time(nullptr) << " 生产了一个任务: "
<< one << op << two << "=?" << endl;
sleep(1);
}
}
消费者从队列里拿任务,并执行任务:
const std::string ops = "+-*/%";
void *consumer(void *args)
{
BlockQueue<Task> *bqp = static_cast<BlockQueue<Task> *>(args);
while (true)
{
Task t = bqp->pop(); // 消费任务
int result = t(); // 处理任务 --- 任务也是要花时间的!
int one, two;
char op;
t.get(&one, &two, &op);
cout << "consumer[" << pthread_self() << "] "
<< (unsigned long)time(nullptr) << " 消费了一个任务: "
<< one << op << two << "=" << result << endl;
}
}
int main()
{
// 生产者用来生产计算任务,消费者用来消费计算任务
BlockQueue<Task> bq;
pthread_t c, p;
pthread_create(&c, nullptr, consumer, &bq);
pthread_create(&p, nullptr, productor, &bq);
pthread_join(c, nullptr);
pthread_join(p, nullptr);
return 0;
}
于是就实现了,生产一个任务消费一个任务:
生产者生产任务的时候和消费者消费任务的时候是并发
执行的:(重点)
消费者必须按照生产的节奏来走,和管道一样,写得慢那么读的也慢(得有访问控制,互斥同步机制)。
解耦体现在生产者生产的任务,可以通过阻塞队列派发给消费者。
生产和消费的速度不一致,如何理解?
生产消费的交易场所就是一个内存,这个内存具体呈现的是:队列、双端队列、环形队列、可能是其他结构用来资源数据或者任务交换的。
在我们之前学习数据结构的时候,我们学习过环形队列,【环形队列复习】。
生产消费模型用上了循环队列之后,就会有一个很大的优势:
并发
属性对比与需求:
queue
是整体被使用的,没法被切割。此时就相当于把循环队列这个临界资源分成了一小块一小块,只有满或空的时候,头指针和尾指针才会指向同一块数组空间,其他时间都是不冲突的!
访问同一个位置有可能吗?答:有可能!什么时候会发生呢?
多线程情况下根本就不用考虑队列为满还是为空,因为信号量帮我们考虑。
在之前的共享内存的学习中,我们简单的提到过信号量 传送门,信号量本质上是一个计数器,是一个描述临界资源数量的计数器。
保证不会在限有资源的情况下让多的线程进入到临界区对临界资源的访问。通过信号量来限制进入临界资源当中的线程的个数。
持有0和1的信号量叫做,二元信号量 == 互斥锁
信号量:1
1->0
— 加锁0->1
— 释放锁小结:
初始化一个未命名的信号量:
sem_wait
是一个信号量操作函数,用于请求和等待信号量的可用性。这个接口和锁 / 条件变量那里的等待是一样的,可以简单理解为,这个接口就是让信号量减减。
sem_post
是一个信号量操作函数,用于释放或增加信号量的值。
sem_post
和sem_wait
是一对重要的信号量操作函数,用于实现并发控制和临界区的进入与退出。
通过调用sem_post
来释放信号量,可以让其他线程获取信号量进入临界区,从而实现资源的共享和同步。
有了上述知识,我们就能可以来着手实现了:
#pragma once
#include
#include
#include
#include
using namespace std;
// 默认容量
const int gCap = 10;
template <class T>
class RingQueue
{
private:
vector<T> ringqueue_; // 环形队列
sem_t roomSem_; // 衡量空间计数器,productor
sem_t dataSem_; // 衡量数据计数器,consumer
uint32_t pIndex_; // 当前生产者写入的位置,如果是多线程,pIndex_也是临界资源
uint32_t cIndex_; // 当前消费者读取的位置,如果是多线程,cIndex_也是临界资源
pthread_mutex_t pmutex_;
pthread_mutex_t cmutex_;
};
除了两个信号量,生产消费的时候,还需要操生产和消费这两个指针,指向队列正确的位置。
操作的基本原则:
生产者:最关心的是什么资源?
N:[N,0] 从N到0的过程
消费者:最关心的是什么资源?
N:[0,N] 从0到N的过程
代码演示:
// 生产 -- 先申请信号量
void push(const T &in)
{
// 申请信号量在锁前面的话,如果是多线程,那么多个线程都可以申请到资源
// 然后再去争锁
sem_wait(&roomSem_); // 如果锁加在前面的话,信号量就无法被多次的申请(P操作)
// 在锁这里等时,每个线程都是拿着信号量去等
pthread_mutex_lock(&pmutex_);
ringqueue_[pIndex_] = in; // 生产的过程,有线程安全的问题
pIndex_++; // 写入位置后移
pIndex_ %= ringqueue_.size(); // 更新下标,保证环形特征
pthread_mutex_unlock(&pmutex_);
sem_post(&dataSem_); // V操作
}
// 消费
T pop()
{
sem_wait(&dataSem_); // 申请数据资源
pthread_mutex_lock(&cmutex_);
T temp = ringqueue_[cIndex_]; // 消费
cIndex_++;
cIndex_ %= ringqueue_.size(); // 更新下标,保证环形特征
pthread_mutex_unlock(&cmutex_);
sem_post(&roomSem_); // 数据已拿走,空间就露出来了,空间多了一个
return temp;
}
生产者和消费者都为空的时候,一定能保证生产线程先运行,因为一开始消费线程的数据信号量一开始为0,sem_wait(&dataSem_)
函数一开始要阻塞等待。
两个线程各自申请各自所关心的资源,各自释放对方所关心的资源,那么此时这两个就可以互相的互调,协同起来了。
环形队列的使用:(重点)
因为有信号量帮我们做了访问控制,所以我们不需要判断循环队列什么时候为满,什么时候为空:
RingQueue(int cap = gCap)
: ringqueue_(cap), pIndex_(0), cIndex_(0)
{
// 生产(空间信号量)
sem_init(&roomSem_, 0, ringqueue_.size());
// 消费(数据信号量)
sem_init(&dataSem_, 0, 0);
pthread_mutex_init(&pmutex_ ,nullptr);
pthread_mutex_init(&cmutex_ ,nullptr);
}
~RingQueue()
{
// 销毁信号量计数器
sem_destroy(&roomSem_);
sem_destroy(&dataSem_);
pthread_mutex_destroy(&pmutex_);
pthread_mutex_destroy(&cmutex_);
}
#include "RingQueue.hpp"
#include
#include
void *productor(void *args)
{
RingQueue<int> *rqp = static_cast<RingQueue<int> *>(args);
while (true)
{
int data = rand()%10;
rqp->push(data);
cout << "pthread[" << pthread_self() << "]" << " 生产了一个数据: " << data << endl;
// sleep(1);
}
}
void *consumer(void *args)
{
RingQueue<int> *rqp = static_cast<RingQueue<int> *>(args);
while (true)
{
int data = rqp->pop();
cout << "pthread[" << pthread_self() << "]" << " 消费了一个数据: " << data << endl;
sleep(1);
}
}
int main()
{
srand((unsigned long)time(nullptr)^getpid());
RingQueue<int> rq;
pthread_t c1,c2,c3, p1,p2,p3;
pthread_create(&p1, nullptr, productor, &rq);
pthread_create(&p2, nullptr, productor, &rq);
pthread_create(&p3, nullptr, productor, &rq);
pthread_create(&c1, nullptr, consumer, &rq);
pthread_create(&c2, nullptr, consumer, &rq);
pthread_create(&c3, nullptr, consumer, &rq);
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;
}
环形队列允许生产和消费同时进入临界区,没问题,只要不同时访问同一个位置就可以,但是如果是多生产多消费,那么就必须维护生产者和生产者之间,消费者和消费者之间的互斥关系。
生产者和生产者之间争一个出来访问环形队列, 消费者和消费者之间争一个出来访问环形队列。
只允许一个线程进入临界资源写入,只允许一个线程从临界资源当中读取。
我们只需要把任务交到这个线程的池子里面,其就能帮我们多线程执行任务,计算出结果。
当任务来时才创建线程,这个成本有点高,如果提前先把各种池化的东西准备好,等任务来的时候,直接把任务指派给某个线程。
无论是进程池还是线程池,本质上都是一种对于执行流的预先分配,当有任务时,直接指定,而不需要创建进程/线程来处理任务。
在我们之前学过的单例模式分为两种,一种是懒汉模式,一种是饿汉模式 [传送门] 。
用懒汉模式实现一个线程池:
#pragma once
#include
#include
#include
#include
#include
#include
#include
#include
#include "Log.hpp"
#include "Lock.hpp"
using namespace std;
int gThreadNum = 5;
template <class T>
class ThreadPool
{
private:
bool isStart_; // 表示是否已经启动
int threadNum_;
queue<T> taskQueue_;
pthread_mutex_t mutex_;
pthread_cond_t cond_;
// 改成懒汉模式
static ThreadPool<T> *instance;
const static int a = 100;
};
因为不用关心线程的退出信息,也不需要对线程进行管理,在创建好线程之后,直接detach
分离即可。
static
变量我们需要在类外初始化,模板类型还需要带上template
关键字:
template <class T>
ThreadPool<T> *ThreadPool<T>::instance = nullptr;
构造:
private:
ThreadPool(int threadNum = gThreadNum) : threadNum_(threadNum), isStart_(false)
{
assert(threadNum_ > 0);
pthread_mutex_init(&mutex_, nullptr);
pthread_cond_init(&cond_, nullptr);
}
// 将拷贝构造和赋值重载删掉
ThreadPool(const ThreadPool<T> &) = delete;
void operator=(const ThreadPool<T>&) = delete;
因为是懒汉模式的单例,提供一个指针作为单例,不对外开放构造函数。
同时,用delete
关键字,禁止拷贝构造和赋值重载。
析构:
~ThreadPool()
{
pthread_mutex_destroy(&mutex_);
pthread_cond_destroy(&cond_);
}
static ThreadPool<T> *getInstance()
{
static Mutex mutex;
if (nullptr == instance) // 仅仅是过滤重复的判断
{
LockGuard lockguard(&mutex); // 进入代码块,加锁。退出代码块,自动解锁。
if (nullptr == instance)
{
instance = new ThreadPool<T>();
}
}
return instance;
}
处理任务:
static void *threadRoutine(void *args) // args收到了类内指针
{
pthread_detach(pthread_self());
// 此时就拿到了线程池对象指针
ThreadPool<T> *tp = static_cast<ThreadPool<T> *>(args);
prctl(PR_SET_NAME, "follower");
while (1)
{
tp->lockQueue();
// 处理任务
while (!tp->haveTask())
{
tp->waitForTask();
}
// 这个任务就被拿到了线程的上下文中
T t = tp->pop();
tp->unlockQueue();
// for debug
int one, two;
char oper;
t.get(&one, &two, &oper);
// 规定,所有的任务都必须有一个run方法
Log() << "新线程完成计算任务: " << one << oper << two << "=" << t.run() << "\n";
}
}
void start()
{
// 作为一个线程池,不能被重复启动
assert(!isStart_);
for (int i = 0; i < threadNum_; i++)
{
pthread_t temp;
pthread_create(&temp, nullptr, threadRoutine, this);
}
isStart_ = true;
}
private:
void lockQueue() { pthread_mutex_lock(&mutex_); }
void unlockQueue() { pthread_mutex_unlock(&mutex_); }
bool haveTask() { return !taskQueue_.empty(); }
void waitForTask() { pthread_cond_wait(&cond_, &mutex_); }
void choiceThreadForHandler() { pthread_cond_signal(&cond_); }
T pop()
{
T temp = taskQueue_.front();
taskQueue_.pop();
return temp;
}
#include "ThreadPool.hpp"
#include "Task.hpp"
#include
#include
// 如何对一个线程进行封装, 线程需要一个回调函数,支持lambda
// class tread{
// };
int main()
{
// 给线程改名字
prctl(PR_SET_NAME, "master");
const string operators = "+/*/%";
// unique_ptr > tp(new ThreadPool());// 懒汉模式之后这个就不能用了
unique_ptr<ThreadPool<Task>> tp(ThreadPool<Task>::getInstance());
tp->start();
srand((unsigned long)time(nullptr));
// 派发任务的线程
while (true)
{
int one = rand() % 50;
int two = rand() % 10;
char oper = operators[rand() % operators.size()];
Log() << "主线程派发计算任务: " << one << oper << two << "=?"
<< "\n";
Task t(one, two, oper);
tp->push(t);
sleep(1);
}
return 0;
}