C++线程:对于线程死锁的认识和理解


死锁的理解

通俗的解释

争抢资源,在死锁中则争抢的是锁

用自己的话通俗的解释是,多个线程竞争一个锁,从而对共享的数据进行同步操作,在竞争中,由于锁的存在,需要每个线程排队,占有该锁,才能进行后续操作。因此,每个线程为了完成任务,第一步就是需要占据锁。

而死锁发生在这种情况下:

  1. 线程A首先占据了锁,并执行操作,在这个过程中,线程A没有unlock;
  2. 此时,线程B也希望占据锁,从而执行任务,但是因为线程A没有unlock,自己只能等待;
  3. 而线程A如果要unlock,需要满足一定的条件,这个条件需要通过线程B执行完成才能实现。

据此,出现,线程A等待线程B执行完才能unlock,而线程B需要线程A先unlock,才能继续执行,确定死锁,其核心是线程A和线程B相互等待对方先执行,来满足自己。这种当不同线程之间的执行存在依赖关系时,以至于每个线程必须满足一定条件才能执行,但是又不释放对锁的控制权,以至于其他线程无法得到锁的控制权,以便实现之前线程unlock需要的条件,从而发生线程A等待线程B完成以满足条件。

例子1:不用同步量也会发生死锁

死锁的发生不一定要用mutex等互斥量时才会发生,如下,当两个线程之间互相等待就有可能发生。下述代码中每个线程都在等待对象先满足条件,如果不满足条件,对应的线程就继续等下去。至于输出,则依赖系统对这两个线程的调度。

int g_i = 0;

void thread_task1(){
    cout << "thread task1" << endl;
    while(g_i != 2){
        continue;
    }
    g_i = 1;
}

void thread_task2(){
    cout << "thread task2" << endl;
    while(g_i != 1){
        continue;
    }
    g_i = 2;
}

int main(){
    thread t1(thread_task1);
    thread t2(thread_task2);
    t1.join();
    t1.join();
    return 0;
}

例子2:使用一个同步量时

当死锁出现时,明显的症状是发生了阻塞行为。如果多个thread之间没有依赖关系,则只会乱序。只有需要同步时才会出现死锁,而同步的实现可以是任何方式,如上述的循环等待,或者加上了mutex信号量。因此死锁是同步出现问题的表现,不是线程本身的问题。

另一个使用一个mutex同步时引起死锁的简单例子(虽说简单,也设计了一会,其实对于理解死锁还是有帮助的)如下。

vector<int> v = {0, 0};

mutex m;
mutex m1;
mutex m2;

void thread_task1(vector<int> &v){
    m.lock();
    cout << "lock_guard get m from thread 1" << endl;
    v[0] = 1;
    if (v[1] == 2) {
        cout << "from thread 1: " << v[0] << endl;
        m.unlock();
    }
}

void thread_task2(vector<int> &v){
    m.lock();
    cout << "lock_guard get m from thread 2" << endl;
    v[1] = 2;
    if (v[0] == 1) {
        cout << "from thread 2: " << v[1] << endl;
        m.unlock();
    }
}

int main(){
    thread t1(thread_task1, ref(v));
    thread t2(thread_task2, ref(v));

    t1.join();
    t2.join();
}
/** output
lock_guard get m from thread 1
之后一直等待......
*/

多个线程竞争一个锁,注意是一个锁,也就对应一个mutex,这样不同的线程需要排队占有这个锁,才能执行对应的操作。如上代码中,有两个线程t1和t2,分别执行相应的操作,通过mutex m实现同步,在线程t1执行操作时,需要满足v[1] == 2,但是这个写入操作需要线程t2完成,但是此时线程t1占据m,所以线程t2没有机会修改条件,因此发生死锁。分析如下,这里我想到受精卵形成的一个机制——Cortical reaction,用来形容这种情况很是贴切。

此时感受到各种知识的相似之美。

多个thread竞争一个mutex

每个mutex对象可以对resource进行lock和unlock,且同一时刻只能允许一个thread占据该mutex,其他的需要排队,多像受精卵排斥多个精子入内的操作。当一个线程执行时,需要满足一个条件,而这个条件需要还未占据mutex的其他线程实现,因此占据mutex的线程就要等待,但是不释放控制权,其他线程无法入内,因此就不能达成条件。

在实验中,我们也想验证如下的场景:

void thread_task1(vector<int> &v){
    m1.lock();
    cout << "lock_guard get m from thread 1" << endl;
    v[0] = 1;
    if (v[1] == 2) {
        cout << "from thread 1: " << v[0] << endl;
        m1.unlock();
    }
}

void thread_task2(vector<int> &v){
    m2.lock();
    cout << "lock_guard get m from thread 2" << endl;
    v[1] = 2;
    if (v[0] == 1) {
        cout << "from thread 2: " << v[1] << endl;
        m2.unlock();
    }
}

每个线程对应不同的mutex对象,实际上这两个线程并不是同步的关系,而是各自独立运行,完全没有依赖。这种情况下不会导致死锁,因为涉及多个mutex,分析如下:

多个sync scope不会死锁

两个线程分别在不同的sync scope中,也根本就没有进行同步,也就谈不上出现死锁了。

例子3:使用多个同步量时

另外,我这里举的例子是涉及一个mutex,对于涉及多个mutex的场景,也可能出现死锁的问题,如文章所示。

std::mutex m1;
std::mutex m2;

auto f1 = [&m1, &m2]()
{
    std::lock_guard<std::mutex> lg1(m1);
    std::this_thread::sleep_for(std::chrono::milliseconds(10));
    std::lock_guard<std::mutex> lg2(m2);
};

auto f2 = [&m1, &m2]()
{
    std::lock_guard<std::mutex> lg1(m2);
    std::this_thread::sleep_for(std::chrono::milliseconds(10));
    std::lock_guard<std::mutex> lg2(m1);
};

std::thread t1([&f1, &f2](){ f1(); });
std::thread t2([&f1, &f2](){ f2(); });

t1.join();
t2.join();

上述代码分析如下:

两个线程之间互相等待

调试死锁的问题向来就是比较困难的,我在写本文的时候,又去熟悉了一下gdb调试死锁的操作,可以参考这篇文章,还可以对已经运行的程序查看是否死锁,如文章

为什么会出现

综合以上的分析,我们总结一下deadlock为啥会出现,这里直接借助学术派的总结,如文章所述。

Four conditions that must hold for a deadlock to be possible:

  • Mutual exclusion: processes require exclusive control of its resources (not sharing).
  • Hold and wait: process may wait for a resource while holding others.
  • No preemption: process will not give up a resource until it is finished with it. Also, processes are irreversible: unable to reset to an earlier state where resources not held.
  • Circular wait: each process in the chain holds a resource requested by another.

其中还有一个图生动的描述了这些条件的含义:

死锁条件的说明

如果上述条件中有一个被打破,死锁就不会发生。对应我们上述的例子,分别分析这4种条件:

  1. Mutual exclusion

    对于互斥量mutex,一次只能被thread占据,如果当前有thread占据该mutex,其他的thread只能等待

  2. Hold and wait

    线程t1需要访问数组中的数据,当满足一定的条件后才会执行,即便当前无法继续前进,也会阻塞在这里,不会释放资源

  3. No preemption

    该线程t1会一直占据该mutex,不会放弃,因此别人无法通过抢占得到,即先占为王,直到满足条件才会unlock。

  4. Circular wait

    基于上述的条件,每个线程对象不可能中途退出,因此多个线程之间存在循环等待,因此只能僵持在这里,即线程t1需要等待t2修改条件,但是却释放资源。

对于例2的解决

对于上述条件中,我们只要能打破一个,就能解决死锁问题。对于本文的死锁例子,针对上述的4个条件中,我们可以打破Hold and wait实现死锁的解决,如下:

void thread_task1(vector<int> &v){
    m.lock();
    cout << "lock_guard get m from thread 1" << endl;
    v[0] = 1;
    if (v[1] == 2) {
        cout << "from thread 1: " << v[0] << endl;
    }
    m.unlock();
}

这样即便不能满足条件,线程t1也会unlock。

参考资料

  1. https://sites.ualberta.ca/~smartynk/Resources/CMPUT%20379/beck%20notes/deadlock.pdf

  2. https://blog.csdn.net/hit_shaoqi/article/details/107193727


文章作者: alex Li
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 alex Li !
 上一篇
《制造消费者》的社会带来的挑战 《制造消费者》的社会带来的挑战
介绍了消费主义的全球发展历史,从商品社会的形成、发展到对于人类生活行为方式的影响。
2023-01-08
下一篇 
我们需要对《算法霸权》的威胁重视起来 我们需要对《算法霸权》的威胁重视起来
基于数学的算法模型对社会的伤害被忽视了,但是因其的不透明、规模化和毁灭性等特点,已经对于社会产生了很大的威胁。
2022-12-04
  目录