生产者消费者模型
互斥与同步的理解
互斥量,信号量,条件变量
互斥的必要性,大家相比都知道抢票逻辑,如果线程与线程之间不做约束,对着一个全局变量进行–,模拟抢票的逻辑,在短期内可能不会出现问题,但是有时出现票数减到-1的情况。
测试:
将
sleep(1);
放在ticket--
之前模拟一下抢票前的一些准备工作,并且放在此处容易模拟出现问题,因为一个线程sleep后,线程就大概率会放入等待队列,切换其他线程,就会出现ticket值最后已经很小了,但是此时因为还没有ticket--
,多个线程同时进入进行修改。
#include
#include
#include
#include
int ticket= 100;
void* Routine(void* args)
{
while(ticket> 0)
{
if(ticket> 0)
{
sleep(1);
ticket--;
printf("thread is :%p ,ticket is :%d\n",pthread_self(),ticket);
}
else{
break;
}
}
}
int main()
{
#define NUM 5
pthread_t tids[NUM];
for(int i = 0; i < NUM;++i)
{
pthread_create(&tids[i],nullptr,Routine,nullptr);
}
for(int i = 0; i < NUM;++i)
{
pthread_join(tids[i],nullptr);
}
return 0;
}
结果:
减到了负数,不符合现实生活中的场景
对于减减是否原子性:
这个过程不是原子的,查看汇编代码也可看到它是经过三个步骤实现的。而只要在其中任意一个步骤进行了进程间切换,得到的值就可能是有问题的。每个进程上下文切换的时候会保存寄存器数据,即上一个进程将ticket–放到内存后并不会影响其他进程的寄存器上下文数据。也就是这样导致两个进程对一个临界资源的读写操作会造成二义性。
什么时候能减到负数:
if判断的时候,当ticket为1的时候已经进入了一个线程
。
此时有两种情况:
小总结:
多线程切换的情况下,极有可能出现数据交叉的现象。而影响多线程切换,往往是在从内核态到用户态的时候,操作系统会决策是否要进行线程切换,就是信号递达的时间点。但并不是一定会线程切换,通常使用一些需要阻塞线程的系统接口,操作系统会认为这个时间进行线程切换的效率高从而进行线程切换。
为了避免这种现象发生,我们引入锁的概念
锁可以用两种初始化,当把锁定义成全局或者静态的对象时可以用PTHREAD_MUTEX_INITIALIZER来进行初始化,这样初始化的锁不需要自己销毁。
pthread_mutex_init初始化的锁需要配合pthread_mutex_destroy来进行释放。定义的锁是在堆上开辟的。
pthread_mutex_lock是阻塞式等待。
pthread_mutex_trylock没有申请锁成功直接出错返回
观察下面的代码有什么问题:
#include
#include
#include
#include
pthread_mutex_t mutex;
int ticket= 100;
void* Routine(void* args)
{
while(1)
{
pthread_mutex_lock(&mutex);
if(ticket> 0)
{
usleep(10000);
ticket--;
printf("ticket is :%d,pthread is:%p\n",ticket,pthread_self());
}
else{
printf("break ...\n");
break;
}
pthread_mutex_unlock(&mutex);
}
return nullptr;
}
int main()
{
#define NUM 5
pthread_mutex_init(&mutex,NULL);
pthread_t tids[NUM];
for(int i = 0; i < NUM;++i)
pthread_create(&tids[i],nullptr,Routine,nullptr);
for(int i = 0;i < NUM;++i)
pthread_join(tids[i],nullptr);
pthread_mutex_destroy(&mutex);
return 0;
}
结果:
进程没有正常退出,而是被阻塞住了,由于第一个线程break的时候并没有释放锁,导致后面的线程都卡住了!!!
所以,编码的时候要注意每一条路径都要把锁释放掉。RAII的思想也就是利用对象的生命周期来实现释放锁。
小总结:
有了锁就不会出错,但是访问上的速度变慢了。
思考:为什么加了锁就不会出错了呢?
首先,加了锁,线程依旧有可能能会切换,任何时候线程都是可以切换,但是切换过后的线程没有锁资源,会在pthread_mutex_lock阻塞住,即使切换也会被阻塞,只有一个线程有锁资源,当他切换回来的时候恢复上下文,执行完才释放锁资源。即拥有锁的线程,在执行完临界区的过程中,不会再有其他线程进入临界区,间接就实现了原子性!!
Linux提供的这把锁,叫互斥量。
原子性的原理剖析:
从上面得知,单纯重复的自增一个数值在多线程都会出现数据的不一致的问题。
而为了实现互斥锁的操作,大多数的体系结构提供了swap或者exchange的命令,这些命令的作用是把寄存器和内存单元的数据进行交换,这个动作是一条指令,保证原子性!
而访问内存的总线周期也有先后之分,一个处理器上面的交换指令执行时另一个处理器只能等待总线周期。
mutex就是内存中定义的变量,假设是int,变量名叫mutex。
申请流程:movb与xchgb都是原子性的。xchgb不会被多个处理器执行是因为总线周期访问内存只允许一个cpu来访问。此处的
%al是寄存器数据
,是所有线程各自私有一份的。而mutex互斥锁,本质是内存中的一块空间,是可以被所有线程读取!!
释放流程:
注意:mov是拷贝数据,不改变原数据,xchgb是交换数据,是真正获取互斥锁的过程!能执行unlock的已经lock过,unlock不是原子性也是可以的!!
并且从上面可以得知,若一把锁被一个线程重复获取,也会被挂起。
交换指令如何实现一步交换?
xchgb的汇编原理,时序的概念,一个指令周期,访问总线的时候,汇编指令在特定的时间点是放在总线的,总线是可以被锁住的。即使xchgb是多条语句实现的,它把总线锁住单独执行的时候不会有其他线程干扰。
假设有这样一个场景,一个函数内部对临界资源上了锁,当线程访问临界区的时候,收到了一个信号,这个信号需要再次进入这个函数,相当于在进程的上下文中,需要再次进入该函数,而我们上面说过,一个线程不能重复获取一把锁。也算一种死锁。
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
死锁四个必要条件
解决死锁的方法:
破坏四个必要条件当中的一个就可以解决死锁。
一般互斥不可避免,因为是互斥造成的死锁问题。
请求与保持条件:请求自己的,不释放自己的,如果能够释放自己的,让给对面,就不会造成死锁。
不剥夺条件:别人不给锁,我也不会去抢夺别人的锁。
循环等待条件:如图,闭环!且尽量保证申请锁的顺序一样,编码上的建议。
一旦产生死锁,就会出现上面的四个条件。
而死锁检测算法,或者银行家算法是一些避免死锁的算法。
什么是同步?
同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问
题,叫做同
为什么需要同步?
在抢票的例子中,若当一个线程的的竞争锁的能力特别强,就会造成其他线程都是饥饿状态。虽然这样子函数并没有错,但是这是不合理的。
为什么会出现单个线程抢票能力特别强
因为当该线程++ticket后解锁,若他时间片没到的话,他就可以再次申请锁,而这是其他线程如果是调用pthread_mutex_lock申请锁的话,当申请不到锁的时候就会到锁的等待队列,从就绪队列拿下来,后续由于一开始拿着锁的线程不需要唤醒,所以重新争抢锁的概率大,会导致只有一个线程抢到票。引起其他线程的饥饿,这是因为其他线程需要被调度,要先从等待队列取出,然后加入就绪队列,设置状态为R状态,才有机会争抢锁,但有可能抢不到就又去等待队列呆着了。
由于其他线程都被阻塞了,从阻塞队列唤醒线程是需要时间的,而刚释放锁的线程相较于其他线程对于获取锁是更加有优势的。
同步的作用:
同步就是解决资源分配不合理的问题,它并不是解决有错误的问题。让线程进行有序的申请锁。
什么是条件变量?
当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。此时若该线程非阻塞轮询检测条件是否满足不合理,引入条件变量,可以在条件满足唤醒该线程。
条件变量是实现同步的工具。
原生线程库提供描述临界资源状态的一个对象。之前在不断申请锁,检测锁,正是因为不知道临界资源的状态,这是一种轮询的方式,也是十分消耗cpu资源的,所以我们需要通过某种手段直到临界资源的状态–条件变量。
即通过一种机制提醒在条件变量下等的是否有资源了。有资源就将在条件变量下的线程唤醒即可。相当于一个铃铛。
条件变量为何要搭配互斥量使用?
初始化和销毁条件变量,宏赋值的条件变量不需要手动销毁。
初始化函数第二个变量为锁的属性,一般设置为NULL即可
pthread_cond_signal唤醒指定条件变量下等待的线程(一个)!
pthread_cond_broadcast唤醒全部在条件变量下等待的线程(一批)。broadcast有广播的意思。
pthread_cond_t 中有一个等待队列,其中要执行的线程链接在队列,当pthread_cond_signal就可以取出头部的进行运行。
pthread_cond_timewait就是时间到了就醒来申请一次条件变量。
而pthread_cond_wait是在指定的条件变量进行等待。
就会入cond的等待队列,直到有人通过pthread_cond_signal/broadcast将线程唤醒。
第二个参数是锁的一层理解:
其中第二个变量是一个锁,假设pthread_cond_broadcast一次唤醒批量线程起来,如果同时对临界区进行访问是肯定不行,此时要再次争取第二个参数mutex锁,这样才能对临界资源起到保护。
这是他的一个理解,但如果是对pthread_cond_signal那么有什么用呢?
第二个参数是锁的二层理解:
由于当条件变量不满足而在条件变量下等待的线程可能在临界区,此时需要让出锁给另一个生产者/消费者,直到对方通知条件变量满足时该线程才会从条件变量下唤醒,再次申请互斥锁。
实验:
通过一个线程控制其他线程,此处t2为控制线程,也可以用pthread_cond_broadcast进行批量唤醒。
#include
#include
#include
#include
pthread_cond_t cond;
pthread_mutex_t mutex;
void* t1(void* args)
{
while(1)
{
pthread_cond_wait(&cond,&mutex);
printf("%s is running!\n",args);
}
return nullptr;
}
void* t2(void* args)
{
//控制其他线程
while(1)
{
sleep(1);
pthread_cond_signal(&cond);
}
return nullptr;
}
int main()
{
pthread_mutex_init(&mutex,nullptr);
pthread_cond_init(&cond,nullptr);
pthread_t tid1;
pthread_t tid2;
pthread_t tid3;
pthread_t tid4;
pthread_create(&tid1,nullptr,t1,(void*)"thread1");
pthread_create(&tid2,nullptr,t1,(void*)"thread2");
pthread_create(&tid3,nullptr,t1,(void*)"thread3");
pthread_create(&tid4,nullptr,t2,(void*)"thread4");
pthread_join(tid1,nullptr);
pthread_join(tid2,nullptr);
pthread_join(tid3,nullptr);
pthread_join(tid4,nullptr);
return 0;
}
结果:
线程同步起来,有序的运行。
生活中例子
现实生活当中,有消费者,超市,供货商。
消费者不直接与供货商打交道,消费者通常只与超市打交道,而供货商直接与超市打交道。
这样的模型实际就是生产者消费者模型,加了超市这个缓冲区,能让生产者和消费者解耦合,能够大大提升效率。 解耦,支持并发,支持忙闲不均,调节生产者消费者的步调。
超市在对生产者和消费者实际是一个临界区
生产者消费者核心:
学习这个模型,需要搞清楚三个关系,两个角色,一个交易场所。
三个关系:生产者与生产者(互斥),消费者和消费者(互斥),生产者和消费者(同步与互斥)。
两种角色:生产者,消费者
一个交易场所:缓冲区
管道也是一种生产者消费者模型。
队列是有上限的,队列在不满足生产和消费条件,生产和消费是会被阻塞的。
由于消费者和生产者共用的同一把互斥锁,此时假设消费者在条件变量下进行wait的时候,本身消费者若在互斥锁下面,会导致线程将锁一起带走,而生产者会因为申请不到锁而被阻塞,进程就整体阻塞住了。
生产者消费者模型最简单的代码实现:
我们这里设置生产一个消费一个,实际上有低水位线,高水位线,只需要更改signal的条件即可。
生产者可以决定消费者什么时候来,而消费者也可以决定什么时候让生产者来。因为消费者只有消费了才知道是否可以消费,而这样就是轮询检测了,不能实现同步了。
管道本质上也是基于阻塞队列,原理不一样而已。
block_queue.hpp
其中p_cond,c_cond实现生产者与消费者的同步,mutex实现生产者与消费者的互斥,p_mutex实现生产者与生产者的互斥,c_mutex实现消费者与消费者的互斥。
#pragma once
#include
#include
#define NUM 10
template<class T>
class BlockQueue
{
private:
std::queue<T> q;//临界资源
int cap;//标识queue的上限,不会访问,不是临界资源
pthread_cond_t p_cond;//produtor cond
pthread_cond_t c_cond;//consumer cond
pthread_mutex_t mutex;//生产者->消费者
pthread_mutex_t p_mutex;//生产者->生产者
pthread_mutex_t c_mutex;//消费者->消费者
public:
BlockQueue()
:cap(NUM)
{
pthread_cond_init(&p_cond,nullptr);
pthread_cond_init(&c_cond,nullptr);
pthread_mutex_init(&mutex,nullptr);
pthread_mutex_init(&p_mutex,nullptr);
pthread_mutex_init(&c_mutex,nullptr);
}
~BlockQueue()
{
pthread_cond_destroy(&p_cond);
pthread_cond_destroy(&c_cond);
pthread_mutex_destroy(&mutex);
pthread_mutex_destroy(&p_mutex);
pthread_mutex_destroy(&c_mutex);
}
//从阻塞队列拿数据
void Get(T* out)
{
pthread_mutex_lock(&c_mutex);
pthread_mutex_lock(&mutex);
//有伪唤醒
while(q.size() == 0)
{
//消费者不应该消费
pthread_cond_wait(&c_cond,&mutex);
}
*out = q.front();
q.pop();
pthread_mutex_unlock(&mutex);
//此时对于生产者来说,有空间可以
pthread_cond_signal(&p_cond);
pthread_mutex_unlock(&c_mutex);
}
//从阻塞队列放数据
void Put(const T& in)
{
pthread_mutex_lock(&p_mutex);
pthread_mutex_lock(&mutex);
while(q.size() == cap)
{
//此时生产者不应该生产
pthread_cond_wait(&p_cond,&mutex);
}
q.push(in);
pthread_mutex_unlock(&mutex);
//此时有数据可以消费,放在unlock前面和后面都可以
//放后面可以保证线程被唤醒后可以直接争取mutex锁资源,因为我已经释放。
pthread_cond_signal(&c_cond);
pthread_mutex_unlock(&p_mutex);
}
};
注意:上面的锁不加也可以。
test.cc
#include
using namespace std;
#include"block_queue.hpp"
#include
#include
void* t1(void* args)
{
//生产者
int count = 0;
BlockQueue<int>* bq = (BlockQueue<int>*)args;
while(1)
{
bq->Put(count);
count ++;
count %= 100;
printf("consumer :%d\n",count);
}
}
void* t2(void* args)
{
//消费者
BlockQueue<int>* bq = (BlockQueue<int>*)args;
while(1)
{
sleep(3);
int x= 0;
bq->Get(&x);
printf("thread is %p,count:%d\n",(int*)pthread_self(),x);
}
}
int main()
{
BlockQueue<int>* bq = new BlockQueue<int>();
pthread_t tid;
pthread_t tid2;
pthread_create(&tid,nullptr,t1,(void*)bq);
pthread_create(&tid2,nullptr,t2,(void*)bq);
pthread_join(tid,nullptr);
pthread_join(tid2,nullptr);
return 0;
}
结果:
block_queue.hpp中最能体现pthread_cond_wait为何要带上互斥锁,线程在临界区当中的条件变量下等待,此时需要释放互斥锁,避免死锁。
即在临界区在条件变量阻塞,也会在临界区当中唤醒,此时线程会在条件变量满足并且会一直等到把mutex锁竞争导醒来。
消费者最清楚有没有空间,而生产者最清楚有没有数据。
条件变量存在伪唤醒。
比如用广播唤醒,但只有少量资源,而多个线程在条件变量下阻塞的情况,可能有的线程把资源消耗完了,而其他线程拿到锁就从if语句走掉了。
pthread_cond_wait是函数,函数就有可能调用失败。
cpu单核下,伪唤醒可能低;在多核或者多cpu,每个cpu内部有缓存信息,条件变量会缓存到cpu内部,每个cpu的条件变量都会跟新,每个线程的条件变量都会满足。即也是多个线程被唤醒的情况,而资源不一定足够。
上述代码基于阻塞队列,生产者和生产者,消费者和消费者之间不加锁也是没有问题的,因为访问临界资源都已经加上锁了。
什么是信号量
信号量:本质是描述临界资源数目的计数器。
什么时候使用信号量
当我们的临界资源是可以看作多份的情况下,是可以做到多个线程同时访问的,只要访问的区域不是同一个即可。
在前面的例子当中,我们得知互斥锁能保证临界资源的安全 ,条件变量能帮我们知道临界资源的状态,而信号量可以描述临界资源数目的计数器。即之前的例子我们已经假设,临界资源必须被互斥访问,只允许一个执行流在特定的时刻访问临界资源!
但实际上,临界资源并不是一定只能允许一个线程同时访问的。
基于环形队列就一定要了生产者和生产者保持互斥,消费者和消费者保持互斥。
任何线程,如果想访问临界资源中的某一个,一定必须先申请信号量,使用完毕后释放信号量。
申请信号量等价于可以使用临界资源,信号量本质也是资源的预定机制。
如果需要申请信号量资源,前提是所有的线程,都必须看到信号量,信号量本身也是临界资源。即申请释放PV操作必须是原子性的。
P,V操作伪代码:主要阐述原理
int sem = NUM;
int arr[NUM];
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
//申请资源
void P()
{
pthread_mutex_lock(&lock);
if(sem > 0)
sem--;
else 释放锁并且挂起
pthread_mutex_unlock(&lock);
}
//释放资源
void V()
{
pthread_mutex_lock(&lock);
sem++;
pthread_mutex_unlock(&lock);
}
int main()
{
P(sem);
//访问临界资源
V(sem);
}
信号量类型:sem_t,后面我们的接口都是对这个信号量类型定义的变量做操作。
初始化匿名的信号量/销毁匿名的信号量。
第二个参数是否想要共享,0表示线程间共享。
第三个参数表示信号量计数器的初始值。
返回值0成功,-1失败。
sem_wait表示在申请信号量,信号量–,失败被阻塞;
sem_trywait,失败出错返回。
sem_timewait到一定的时间会重新申请一次信号量。
本质为信号量++,此过程不会被阻塞。
规则
生产者不能把消费者套一个圈,消费者也不能超过生产者。否则会造成数据的不安全,读到垃圾数据或覆盖有用的数据。
在不空也不满的时候,生产者和消费者是可以并行的。并且大部分时间都应该处于并行状态,这是环形队列高效的原因。
而空的时候应当让生产者先执行,阻塞消费者;反之;此时才是同步。
生产者关注格子资源,消费者关注数据资源。生产者最清楚有没有数据资源,而消费者最清楚有没有格子资源。
只有为空,或为满的时候,信号量才会挂起对应的线程。其余时刻生产者消费者是并行运行的。
并且一开始消费者不可能执行,获取信号量的时候信号量的值为0,就会在信号量下挂起。
假设一开始的格子资源为NUM,数据资源为0,则可以定义sem_t sem_space = NUM,sem_t sem_data = 0;
生产者就P(sem_space),V(sem_data)
消费者就P(sem_data),V(sem_space),一开始消费者的sem_data为0,则消费者被挂起,等待生产者生产。
可以通过条件判断决定什么时候处理信号量,从而实现高低水线。
生产者P则消费者一定在V,消费者在P而生产者一定在V的吗??错误的!!!!只有p_index和c_index相同的时候如此,其他大部分时候两者可以互相没有联系。
Task.hpp
#pragma once
#include
using namespace std;
#include
//任务,将一个数从1累加到top
class Task
{
public:
Task()
{}
Task(int t):top(t)
{}
int RunTask()
{
int res = 0;
for(int i = 1;i <= top;++i)
{
res+=i;
}
return res;
}
void Print()
{
printf("pthread:%p running task 1~%d",pthread_self(),top);
fflush(stdout);
}
private:
int top;
};
ringqueue.hpp
#include
#include
#include
using namespace std;
#include
#define NUM 5
template<class T>
class RingQueue
{
private:
vector<T> _rq;
int _num;//用计数器实现循环队列
sem_t c_sem;
sem_t p_sem;
size_t c_index;
size_t p_index;
pthread_mutex_t p_lock;
pthread_mutex_t c_lock;
public:
RingQueue()
:_rq(NUM)
,_num(0)
,c_index(0)
,p_index(0)
{
//开始时全为空格子
sem_init(&c_sem,0,0);
sem_init(&p_sem,0,NUM);
pthread_mutex_init(&p_lock,nullptr);
pthread_mutex_init(&c_lock,nullptr);
}
~RingQueue()
{
sem_destroy(&c_sem);
sem_destroy(&p_sem);
pthread_mutex_destroy(&p_lock);
pthread_mutex_destroy(&c_lock);
}
void Get(T* out)
{
//申请信号量
//消费者申请信号量
sem_wait(&c_sem);
//多生产者互斥
pthread_mutex_lock(&c_lock);
//此时一定有资源给消费者
*out = _rq[c_index];
//此时一定有空格子给生产者
sem_post(&p_sem);
//c_index的跟新不需要在信号量当中申请
c_index++;
c_index %= NUM;
//c_index对于生产者而言变成临界资源
pthread_mutex_unlock(&c_lock);
}
void Put(const T& in)
{
//生产者申请信号量
sem_wait(&p_sem);
pthread_mutex_lock(&p_lock);
//此时一定有资源给生产者
_rq[p_index] = in;
//此时一定有空间给消费者
sem_post(&c_sem);
p_index++;
p_index %= NUM;
pthread_mutex_unlock(&p_lock);
}
};
test.cc
#include"ringqueue.hpp"
#include
#include
#include
#include"Task.hpp"
void* Productor(void* args)
{
RingQueue<Task>* rq = (RingQueue<Task>*) args;
int count = 500;
while(1)
{
Task t(count);
rq->Put(t);
printf("pthread :%p,count is :%d\n",pthread_self(),count);
count ++;
count %= 1000;
}
}
void* Consumer(void* args)
{
RingQueue<Task>* rq = (RingQueue<Task>*) args;
while(1)
{
sleep(1);
//从环形队列拿任务
Task t;
rq->Get(&t);
//运行任务
int res = t.RunTask();
t.Print();
printf(" result is :%d\n",res);
}
}
int main()
{
pthread_t c;
pthread_t p;
RingQueue<int>* rq = new RingQueue<int>();
pthread_create(&c,nullptr,Consumer,(void*)rq);
pthread_create(&p,nullptr,Productor,(void*)rq);
pthread_join(c,nullptr);
pthread_join(p,nullptr);
return 0;
}
结果:
过程分析:
啥时候要加锁
在单生产,单消费的情况下是可以不用直接加锁(pthread_mutex_t)的。
多生产者和多消费者则需要加锁,因为这个p_index和c_index是被所有线程共享的。
互斥锁加/解在哪比较好?
加锁通常可以在sem_wait的后面会好一些,因为sem_wait通常可以允许批量线程进入,此时大家再争取一把锁,进来的锁都有资格访问临界资源。加在外面效率会低一点,信号量相当于没有用上。相当于竞争不到锁的线程还可以先竞争信号量,让等待的时间重叠了,会高效一些。
而解锁通常加在最后,要保护临界资源。