【Linux】面试重点:死锁和生产消费模型原理

面试要点来了~

文章目录


前言

上一篇的互斥量原理中我们讲解了锁的原理,我们知道每次线程申请锁的时候一旦申请成功这个线程自己就把锁带在自己身上了,这就保证了锁的原子性(因为只有一个锁),而当我们已经申请成功锁了然后再去申请锁会发生什么事呢?下面我们在死锁中回答这个问题。


一、死锁

死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态。
上面死锁的概念说一组进程中的各个进程,那么一个锁会引发死锁的问题吗?答案是会的,因为代码是程序员写的,所以一旦代码写的有问题即使是一把锁也会造成死锁的问题,比如说:申请锁却不去释放。申请了一把锁成功后又去申请同样的锁,这种情况下肯定会申请失败,一旦申请失败就导致该线程被阻塞挂起,那么这个锁就变成了死锁。申请了一把锁后又去申请其他的锁,结果其他的锁申请失败被阻塞,一旦被阻塞那么这个进程就会带着第一次申请成功的锁休眠,这样就造成死锁问题(因为第一把锁永远不会有线程申请成功了)。
当然造成死锁的条件有多种,下面我们直接给出结论:
1.互斥
2.请求与保持(我拿着我的然后申请你的)
3.环路等待(循环等待条件)
4.不剥夺条件
想要避免死锁,我们直接破坏上面4个条件中的任意一个即可。对于互斥这个条件想要破坏很简单,我们直接不加锁即可。对于请求与保持这个条件,我们只要主动的去释放锁那么就不会出现这样的问题。环路等待就是多个线程申请锁的顺序不一样,导致有的线程出现“请求与保持”的情况,对于这个问题我们只需要让每个线程按照顺序去申请锁即可。第四个条件的意思是:一个线程申请了锁然后只能自己释放锁,对于这样的情况一旦这个线程不去释放锁就会造成死锁问题,所以我们要剥夺这个线程的释放锁的条件,让其他线程去释放刚刚那个没有释放锁的线程。这里可能有些人会懵了,还可以一个线程申请锁,另一个线程释放刚刚那个线程申请的锁?答案是可以,下面我们用代码演示一下:
#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void *threadRoutine(void* args)
{
    cout<<"我是一个新线程"<<endl;
    pthread_mutex_lock(&mutex);
    cout<<"我拿到了锁"<<endl;
    pthread_mutex_lock(&mutex);//由于再次申请锁的问题会停下来(死锁)
    cout<<"我又活了过来"<<endl;
    return nullptr;
}

int main()
{
    pthread_t tid;
    pthread_create(&tid,nullptr,threadRoutine,nullptr);
    sleep(3);
    cout<<"主线程正在运行"<<endl;
    return 0;
}

首先我们先不让主线程释放锁,看一下死锁的状态:

 我们可以看到本来应该打印“我又活了过来”,结果直接退出了,下面我们让主线程给新线程解锁:

通过运行结果我们可以看到刚刚死锁的进程活过来了,那么就意味着可以一个线程申请锁,另一个线程释放锁。所以要破坏第四个条件直接控制一个线程统一释放锁。

下面我们总结一下:

避免死锁的方法:
1. 破坏死锁的四个必要条件
2. 加锁顺序一致
3. 避免锁未释放的场景
4. 资源一次性分配
下面讲解linux线程同步中的条件变量:
当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。
例如一个线程访问队列时,发现队列为空,它只能等待,直到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。
同步概念与竞态条件:
同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步。
竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解。
下面讲一下条件变量函数初始化接口:
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrictattr);
参数:
cond
:要初始化的条件变量
attr

NULL
销毁条件变量:
int pthread_cond_destroy(pthread_cond_t *cond)
等待条件变量:
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
参数:
cond
:要在这个条件变量上等待
mutex
:互斥量
注意:条件变量带锁的意义是:当我们将某个线程放到条件变量中(可能队列等顺序容器)去等待的时候,条件变量为了防止死锁问题会自动将我们等待的线程的锁给释放掉,也就是说不然某个线程在等待的时候持有锁。

当然也有唤醒条件变量的接口:
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
下面我们用代码演示这些接口如何使用:
const int num = 5;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void *active(void* args)
{
    string name = static_cast<const char*>(args);
    while (true)
    {
        pthread_mutex_lock(&mutex); //先加锁
        //然后放进条件变量去等
        pthread_cond_wait(&cond,&mutex); //调用的时候会自动释放锁
        cout<<name<<" 活动 "<<endl;
        pthread_mutex_unlock(&mutex);
    }
}
int main()
{
    pthread_t tids[num];
    for (int i = 0;i<num;i++)
    {
        char* name = new char[32];
        snprintf(name,32,"thread -> %d ",i+1);
        pthread_create(tids+i,nullptr,active,name);
    }
    sleep(3);
    while (true)
    {
        cout<<"主线程唤醒其他线程......"<<endl;
        //在环境变量中按顺序唤醒一个线程
        pthread_cond_signal(&cond);
        sleep(1);
    }
    for (int i = 0;i<num;i++)
    {
        pthread_join(tids[i],nullptr);
    }
    return 0;
}

上面代码的作用是:首先创建5个线程,这5个线程都要进入active函数,在函数中首先加锁,然后让这个线程去环境变量中去等待,在等待的过程中会自己释放锁,然后3秒后主线程开始唤醒这些环境变量里的线程了,pthread_cond_signal这个接口的作用是每次唤醒一个线程,每次唤醒都是按顺序唤醒的,唤醒后这个线程就会打印“活动”,下面我们运行起来看看:

 可以看到这里唤醒的顺序都是31245,下面我们可以用一下pthread_cond_broadcast这个接口来一次唤醒所有的接口:

 通过运行结果我们可以看到同样是按顺序进行唤醒的,所以条件变量的作用是:允许多线程在cond中队列式等待(队列式等待就是一种顺序)。

下面我们总结一下:

为什么
pthread_cond_wait
需要互斥量
?
条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没有互斥锁就无法安全的获取和修改共享数据。
由于解锁和等待不是原子操作。调用解锁之后,
pthread_cond_wait
之前,如果已经有其他线程获取到互斥量,摒弃条件满足,发送了信号,那么 pthread_cond_wait
将错过这个信号,可能会导致线程永远阻塞在这个 pthread_cond_wait
。所以解锁和等待必须是一个原子操作。
int pthread_cond_wait(pthread_cond_ t *cond,pthread_mutex_ t * mutex)
;
进入该函数后,会去看条件量等于0
不?等于,就把互斥量变成
1
,直到
cond_ wait
返回,把条件量改成
1
,把互斥量恢复
成原样。

 二、生产者消费者模型

下面我们先画张图理解一下生产者消费者模型:

 首先在模型中超市并不是属于生产者,超市只是一个交易场所,供货商会把生产的商品放到超市中,然后消费者直接去超市买。为什么消费者不直接去供货商那里购买呢?因为消费者不知道供货商什么时候生产好了商品,而且消费者的需求非常零散,供货商的生产水平很强,导致这两者之前有着天然的屏障。那么这个模型有什么特点呢?

第一点效率高,第二点忙闲不均。第二点是什么意思呢?意思就是说我们可以把供货商生产的一大批商品全部放在超市中让消费者随时随地来买,在供货商生产商品的时候是忙的,当生产的商品没有被购买或者还有很多商品存放的时候供货商不会生产太多,这个时候就是闲的。现在我们来把模型具体化,消费者实际上就是线程,生产者(供货商)也是线程,而超市这个交易场所实际上是一种特定的缓冲区(队列,链式,哈希等),而上面超市中的商品实际上就是数据。对于这个模型不知道大家能想到什么呢?没错就是管道通信,管道不就是一个进程把数据放在缓冲区中另一个进程去缓冲区中拿吗,我们当时写的一个关闭读端一个关闭写端的代码的例子不知道大家想起来没有。

对于我们刚刚说的缓冲区,这个缓冲区既要被消费者看到,也要被生产者看到,那么就注定了交易场所一定是一个会被多线程并发访问的公共区域,并且注定了多线程一定要保护共享资源的安全,注定了一定在这种情况下,要自己维护线程互斥与同步的关系。

下面我们讲解一下模型中的关系:

1.生产者和生产者:生产者和生产者之间一定是互斥的关系,因为他们都抢着要向缓冲区中存放资源,只有谁生产的快谁先放入缓冲区。

2.消费者和消费者:消费者和消费者之间的关系一定也是互斥的,因为当某个商品只有一个的时候那么两个消费者一定会去抢这个商品,这个时候就体现出互斥的关系了。

3.生产者和消费者:生产者和消费者之间是有同步关系的。因为当商品生产好后只有消费者消费了生产者才会继续生产,否则缓冲区是满的即使生产者再次生产也放不进去缓冲区。第二个生产者和消费者之间是有互斥关系的,因为我们不能让消费者一边在缓冲区拿东西一边让生产者往缓冲区送东西,生产者和消费者之间只能有一个进入缓冲区。

下面我们用一个简单的方法记录一下生产者消费者模型:

3种关系(生产者和生产者,消费者和消费者,生产者和消费者),两个角色(生产者和消费者),一个交易场所(通常是缓冲区)。所以我们以后记生产者消费者模型只需记住321即可。


总结

为何要使用生产者消费者模型
生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的。

本图文内容来源于网友网络收集整理提供,作为学习参考使用,版权属于原作者。
THE END
分享
二维码
< <上一篇
下一篇>>