👍作者主页:进击的1++
🤩 专栏链接:【1++的Linux】
文章目录
- 一,可重入与线程安全
- 二,死锁
- 三,线程同步
- 什么是线程同步?
- 怎么实现线程同步
- 条件变量
- 四,生产者与消费者模型
- 1,生产者与消费者模型的基本组成及其概念
一,可重入与线程安全
线程安全: 多个执行流在执行同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
重入: 同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
常见的线程不安全情况:
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
常见的线程安全的情况:
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
- 类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
常见不可入的情况:
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
常见的可入情况:
- 不使用全局变量或静态变量
- 不使用用malloc或者new开辟出的空间
- 不调用不可重入函数
- 不返回静态或全局数据,所有数据都有函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
可重入与线程安全的联系与区别:
联系:
函数是可重入的,那就是线程安全的
函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
区别:
可重入函数是线程安全函数的一种
线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
线程安全描绘的是线程之间互相影响的一种状态或者可能性。而重入描述的是一个函数可不可以被重复进入。
这个insert是加锁的,所以多个线程访问的时候是安全的,比如main函数里insert执行到第二句的时候,信号来了,导致它处理信号去了,但是main函数执行流是申请锁了,它抱着锁,信号递达的时候执行信号捕捉的方法,执行handler,handler里面也有一个insert,那么insert就重入了,可是insert函数进来的时候,信号捕捉执行流要进行申请锁。此时就出现,主线程申请锁成功了正在访问临界资源,然后信号来了,执行了信号处理函数,此时又进行申请锁了,也就是说同一个进程申请了两次锁,第一次我成功申请了锁,第二次我又去申请锁,但是锁没了(其实是被你自己申请了),此时你就被挂起了。可最尴尬的是你是抱着锁被挂起的,你在等别人释放锁唤醒你,可是锁被你拿着呢,没有人释放,也就没有人唤醒。所以你这个进程就被永远的挂起了,这就叫做一个线程是安全的,但不一定是可重入的。
二,死锁
什么是死锁:
死锁是指在一组执行流中的各个执行流均占有不会释放的资源,但因互相申请被其他执行流所站用的不会释放的资源而处于的一种永久等待状态。
死锁的条件:
互斥条件:一个资源每次只能被一个执行流使用。
请求与保持条件:一个执行流因请求资源而阻塞时,并且对已获得的资源保持不放。
不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺。
循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系。
那么该如何避免死锁呢?
- 破坏死锁的四个必要条件
- 加锁顺序一致
- 避免锁未释放的场景
- 资源一次性分配
避免死锁的算法:
死锁检测算法
银行家算法
三,线程同步
什么是线程同步?
在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步。
怎么实现线程同步
实现线程同步即实现怎么能够让线程按照某种特定的顺序去访问临界资源?
我们用条件变量来实现。条件变量:当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。
就像我们去手机店买手机,发现想要的那款手机没有货,所以我们没有买,回去了,第二天又来问,还是没有,第三天,第四天。。。你连续一个月每天都去问,有没有货,这样是不是浪费了你的时间,**(做法没错,但不合理)**但如果你将导购的微信加上,等有货时,他微信通知你,你再去买,这样是不是就方便的多。
一般而言,因为有锁的缘故,我们比较困难去了解资源的情况(判断资源是否满足,也是访问资源的过程),这样让一方通知另一方资源已就绪的场景就是条件变量。
竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。
为什么要有线程同步呢?
主要是为了解决访问临界资源合理性的问题。不造成别人的饥饿问题和自身资源的浪费。
条件变量
当我们申请临界资源时,先要做资源是否存在的判断,那么对资源的判断也是对资源进行访问的一种,因此对资源的判断也要在加锁和解锁之间。常规的检测方式注定了我们要进行频繁的申请和释放锁,有没有办法让我们的线程在检测到资源不就绪时就不在频繁的去自检,而是去等待通知,等条件就绪的时候再去唤醒呢?—这种方式就是我们的条件变量。
条件变量的使用:
条件变量的使用与互斥量的使用大同小异,都可以进行直接用宏初始化或者调用初始化函数进行初始化。
pthread_cond_wait函数是,当由于条件不满足而调用它时,该执行流将会进行阻塞式等待,而且还会将锁打开,直到收到唤醒的信号时,会再次申请锁,并从阻塞时的位置继续向后执行。
pthread_cond_timedwait 函数与上述函数不同的是其比pthread_cond_wait多了一个时间参数,表示历经多长时间后,即使每被唤醒也解除阻塞。
这个函数和pthread_ cond_ wait主要差别在于第三个参数,这个abstime,从函数的说明来
看,这个参数并不是像红字所描述的经历了abstime段时间后,而是到达了abstime时间,后才解锁,所以这里当我们用参数的时候不能直接就写个时间间隔,比如5S,而是应该写上到达的时间点所以初始化的过程为:
struct timespec timeout;
//定义时间点
timeout.tv_ sec= time(0)+ 1; //time(0)代表的是当前时间而//tv_ sec 是指的是秒
timeout.tv_ nsec=0;
//tv_ nsec代表的是纳秒时间
pthread_cond_signal
函数的作用是发送一个信号给另外一个正在处于阻塞等待状态的线程,使其脱离阻塞状态,继续执行.如果没有线程处在阻塞等待状态,pthread_cond_signal也会成功返回。
使用pthread_cond_signal一般不会有“惊群现象”产生,他最多只给一个线程发信号。假如有多个线程正在阻塞等待着这个条件变量的话,那么是**根据各等待线程优先级的高低确定哪个线程接收到信号开始继续执行。**如果各线程优先级相同,则根据等待时间的长短来确定哪个线程获得信号。但无论如何一个pthread_cond_signal调用最多发信一次。(其本质就是将接收到信号的状态由S状态改为R状态)
而pthread_cond_broadcast会给所有阻塞在这个条件变量下的线程发信号。
下面我们展示一段相关代码,以便对上述结论有更深的理解:
//定义全局的锁和条件变量
pthread_mutex_t mtx=PTHREAD_ADAPTIVE_MUTEX_INITIALIZER_NP;
pthread_cond_t ct=PTHREAD_COND_INITIALIZER;
int i=0;
int j=0;
void* ctr(void* argv)
{
while(true)
{
cout<<(++j)<<"唤醒----"<<endl;
pthread_cond_signal(&ct);
sleep(1);
}
}
void* work(void* argv)
{
while(true)
{
pthread_mutex_lock(&mtx);
pthread_cond_wait(&ct,&mtx);
cout<<++i<<" doing------"<<endl;
pthread_mutex_unlock(&mtx);
}
}
int main()
{
pthread_t boss;
pthread_t staff[3];
pthread_create(&boss,nullptr,ctr,nullptr);
for(int i=0;i<3;i++)
{
pthread_create(staff+i,nullptr,work,nullptr);
}
pthread_join(boss,nullptr);
for(int i=0;i<3;i++)
{
pthread_join(staff[i],nullptr);
}
return 0;
}
四,生产者与消费者模型
1,生产者与消费者模型的基本组成及其概念
基本组成: 生产者,消费者,交易场所。
基于Blockingqueue的生产者消费者模型: 在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)。
我们以购物为例:
我们将生产者比作生产产品的工厂,将消费者比作购物的人,交易场所则是商场。我们的数据比作商品。
为什么要有超市的存在:其本质是作为商品的缓冲区(暂存商品),从而提高效率。
设计的核心思想:尽可能减少代码耦合,如果发现代码耦合,就要采取解耦技术。让数据模型,业务逻辑和视图显示三层之间彼此降低耦合,把关联依赖降到最低,而不至于牵一发而动全身。超市的存在也是解耦的一种手段。
那么他们三个之间都存在什么样的关系呢?
消费者和消费者之间:互斥,比如强演唱会的票
生产者和生产者 : 互斥,比如商战。
生产者和消费者:存在互斥的关系,比如向消费者想要拿货架中
的商品,而生产者也想往货架中方商品,此时就有了谁先谁后的问题;同步关系,工厂生产出来商品后,顾客才能进行购买,顾客购买后,商品不足时,工厂才会进行生产。
下面我们用相关代码来模拟这一过程:
ypedef std::function<int(int,int)> func_t;
class Operat
{
public:
inline static int Add(int x,int y)
{
return x+y;
}
inline static int Mult(int x,int y)
{
return x*y;
}
inline static int Sub(int x,int y)
{
return x-y;
}
};
pthread_t tid[3];
func_t func[3]={Operat::Add,Operat::Mult,Operat::Sub};
template<class T>
class Blockqueue
{
bool IsEmpty()
{
return dp.size()==0;
}
bool IsFill()
{
return dp.size()==cap;
}
public:
Blockqueue()
{
pthread_mutex_init(&mtx,nullptr);
pthread_cond_init(&EMPTY,nullptr);
pthread_cond_init(&Fill,nullptr);
}
~Blockqueue()
{
pthread_mutex_destroy(&mtx);
pthread_cond_destroy(&EMPTY);
pthread_cond_destroy(&Fill);
}
void Push(T& in)
{
//制作任务
int n= pthread_mutex_lock(&mtx);
assert(n==0);
while(IsFill())
{
std::cout<<"生产者等待"<<std::endl;
pthread_cond_wait(&Fill,&mtx);
}
std::cout<<"制作任务中"<<std::endl;
dp.push(in);
pthread_mutex_unlock(&mtx);
pthread_cond_signal(&EMPTY);
}
void Pop(T* out)
{
//消费
int n= pthread_mutex_lock(&mtx);
assert(n==0);
// // 当我被唤醒时,我从哪里醒来呢??从哪里阻塞挂起,就从哪里唤醒, 被唤醒的时候,我们还是在临界区被唤醒的啊
// // 当我们被唤醒的时候,pthread_cond_wait,会自动帮助我们线程获取锁
// // pthread_cond_wait: 但是只要是一个函数,**就可能调用失败**
// // pthread_cond_wait: 可能存在 ** 伪唤醒 的情况**
while(IsEmpty())
{
pthread_cond_wait(&EMPTY,&mtx);
std::cout<<"消费者等待"<<std::endl;
}
std::cout<<"拿到任务"<<std::endl;
*out=dp.front();
dp.pop();
pthread_mutex_unlock(&mtx);
pthread_cond_signal(&Fill);
}
private:
std::queue<T> dp;
int cap=4;
pthread_mutex_t mtx;
pthread_cond_t EMPTY;
pthread_cond_t Fill;
};
void* productor(void* argv)
{
Blockqueue<func_t>* b=(Blockqueue<func_t>*)argv;//必须用指针接收,否则拷贝构造会产生一个新的对象,
//导致有一把新锁产生
while(true)
{
int n=rand()%3;
b->Push(func[n]);
sleep(1);
}
return nullptr;
}
void* consumer(void* argv)
{
Blockqueue<func_t>* b=(Blockqueue<func_t>*)argv;
while(true)
{
func_t ret;
b->Pop(&ret);
int x=rand()%6;
int y=rand()%7;
std::cout<<"结果"<<x<<"--"<<y<<"="<<ret(x,y)<<std::endl;
sleep(1);
}
return nullptr;
}
int main()
{
srand((unsigned)time(nullptr));
Blockqueue<func_t>* p_blockq=new Blockqueue<func_t>;
pthread_t c[2],p[2];
pthread_create(c,nullptr,consumer,p_blockq);
pthread_create(c+1,nullptr,consumer,p_blockq);
pthread_create(p,nullptr,productor,p_blockq);
pthread_create(p+1,nullptr,productor,p_blockq);
pthread_join(c[0],nullptr);
pthread_join(c[1],nullptr);
pthread_join(p[0],nullptr);
pthread_join(p[1],nullptr);
delete p_blockq;
return 0;
}
下面我们用一张图来形象的展示代码所代表的意思:
接下来请看VCR: 当我们的顾客去消费时,发现没有要买的商品,此时顾客就会通知工厂并回到家等通知,工厂接到通知后开始生产,将生产好多商品送到超市后,通知顾客来买,此时顾客就可以购物了,若工厂想要在超市的货还有但未满的情况下继续补货,此时要是有顾客来购物,他们就需要进行竞争,(我先买东西还是你先补货)若是在只有没货的情况下进行补货,且工厂生产较快,那么在顾客购物的这段期间,我工厂就可以专心我的货的制造,这是不是就提高了效率;反过来,我消费过快,工厂长在进行补货的时候,我是不是就可以去用我所买的东西,这是不是也提高了效率。
超市就像临界资源一样,我们的生产者想要访问,消费者也想要访问,为了不会因为时序问题而导致数据发生错误,我们只允许一个执行流进入超市,因此消费者和消费者,生产者和生产者,消费者和生产者都有竞争关系。
我们再来看看互斥与同步的关系: 我们的互斥是为了保护共享数据的安全,因此之允许一个执行流访问临界资源,但是在判断临界资源时,由于没有人通知,我们只能频繁的一次又一次的去判断是否到达了访问条件,并且其他人也无法进入访问,这显然是浪费了资源,有了同步后,我们就可以回到家等通知,其他满足条件的线程则也可以进入,这不就增加了效率吗?所以说同步对互斥的缺点进行了补充。
生产消费者模型中,谁把数据放到队列里,谁把数据拿到,不是主要矛盾,处理数据需要多长时间,获取数据需要多长时间,这才是主要矛盾,生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的。
阻塞队列最经典的应用场景:管道
生产者消费者模型的优势:
解耦
支持并发
提高效率
平衡速度差异。