17.5 C++并发与多线程-互斥量的概念、用法、死锁演示与解决详解

17.1 C++并发与多线程-基础概念与实现
17.2 C++并发与多线程-线程启动、结束与创建线程写法
17.3 C++并发与多线程-线程传参详解、detach坑与成员函数作为线程函数
17.4 C++并发与多线程-创建多个线程、数据共享问题分析与案例代码
17.5 C++并发与多线程-互斥量的概念、用法、死锁演示与解决详解
17.6 C++并发与多线程-unique_lock详解
17.7 C++并发与多线程-单例设计模式共享数据分析、解决与call_once
17.8 C++并发与多线程-condition_variable、wait、notify_one与notify_all
17.9 C++并发与多线程-async、future、packaged_task与promise
17.10 C++并发与多线程-future其他成员函数、shared_future与atomic
17.11 C++并发与多线程-Windows临界区与其他各种mutex互斥量
17.12 C++并发与多线程-补充知识、线程池浅谈、数量谈与总结

5.互斥量的概念、用法、死锁演示与解决详解

  5.1 互斥量的基本概念

    互斥量,翻译成英文是mutex,互斥量实际是一个类,可以理解为一把锁。在同一时间,多个线程都可以调用lock成员函数尝试给这把锁头加锁,但是只有一个线程可以加锁成功,其他没加锁成功的线程,执行流程就会卡在lock语句行这里不断地尝试去加锁这把锁头,一直到加锁成功,执行流程才会继续走下去。
    例如上一节范例中的inMsgRecvQueue线程和outMsgRecvQueue线程都尝试去加锁这把锁头,但是inMsgRecvQueue加锁成功,那它就可以去执行“共享数据的操作”代码段,这些代码段执行完后,inMsgRecvQueue线程再把这把锁头解锁,那么outMsgRecvQueue这个正卡在lock这里不断尝试加锁这把锁头的线程就会成功加锁这把锁头,那么此时outMsgRecvQueue就可以执行“共享数据的操作”代码段。同理,执行完这个代码段后,outMsgRecvQueue也要负责把这个锁头解锁。
    互斥量需要小心使用,原则就是保护需要保护的数据,不要多也不要少,保护的数据少了(例如明明有两行代码都是操作共享数据的,却只保护了一行代码),没达到保护效果,程序执行可能还出现异常,保护的数据多了,就会影响程序运行效率,因为操作这段被保护的数据时,别人(其他线程)都在那里等着,所以操作完之后要尽快把锁头解锁,别人才能去操作这段共享数据。

  5.2 互斥量的用法

#include <mutex>
std::mutex my_mutex; //创建互斥量

(1)lock与unlock

    进一步改造一下类A的代码,改造的方向就是给访问共享数据的代码段加上锁,操作完共享数据后还得解锁,这就需要用到类mutex的两个成员函数lock和unlock。
    lock和unlock的使用规则:成对使用,有lock必然要有unlock,每调用一次lock,必然要调用一次unlock,不应该也不允许调用1次lock却调用了2次unlock,也不允许调用2次lock却调用1次unlock,否则都会使代码不稳定甚至崩溃。

for (int i = 0; i < 100000; i++)
{
    cout << "inmsgrecvqueue()执行,插入一个元素" << i << endl;
    {
        my_mutex.lock(); //要操作共享数据,所以,先加锁
        msgrecvqueue.push_back(i); //假设这个数字就是我收到的命令,我直接放到消息队列里来
        my_mutex.unlock(); //共享数据操作完毕,解锁
    }//执行到这里sbguard的析构函数就会调用mutex的unlock
}
bool outMsgLULProc(int& command)
{
    my_mutex.lock();
    if (!msgRecvQueue.empty())
    {
        command = msgRecvQueue.front();//返回第一个元素但不检查元素存在与否
        msgRecvQueue.pop_front();
        my_mutex.unlock();
        return true;
    }
    my_mutex.unlock();
    return false;
}
void outMsgRecvQueue()
{
    int command = 0;
    for (int i = 0; i < 100000; i++)
    {
        bool result = outMsgLULProc(command);
        if (result == true)
        {
            cout << "outMsgRecvQueue()执行了,从容器中取出一个元素" << command << endl;
            //这里可以考虑处理数据
            //......			
        }
        else
        {
            cout << "outMsgRecvQueue()执行了,但目前收消息队列中是空元素" << i << endl;
        }
    }
    cout << "end" << endl;
}

上面代码不难理解:
    (1)两个线程都执行到了lock语句行,只有一个线程lock加锁成功,该线程的执行流程就会继续往下走,而另一个线程lock肯定失败,其执行流程就会卡在lock这行代码并不断尝试获取锁。
    (2)代码从哪里开始lock,到哪里unlock,由程序员决定,所以程序员必须非常明确自己想保护的共享数据所对应的代码段。
    (3)拿到锁的线程执行流程继续从lock语句行往下走,处理完了共享数据,必须调用unlock把锁头解开,这一解开会导致刚才lock失败的线程自动尝试再次lock时成功从而有机会让该线程的执行流程继续往下走。
    (4)两个线程反复,一个拿到锁另外一个就要等,一个线程解开锁就给了另外一个线程拿到锁的机会,但不管怎么说,同一时刻只有一个线程能够拿到锁,这意味着同一时刻只有一个线程能操作这个共享数据,从而不会使共享数据的操作产生混乱(如不会读的中间去写)。这样,整个程序的执行就不会出现异常了。

(2)std::lock_guard类模板

    std::lock_guard类模板直接可以用来取代lock和unlock,请注意,lock_guard是同时取代lock和unlock两个函数,也就是说,使用了lock_guard之后,就再也不需要使用lock和unlock了。改造outMsgLULProc成员函数的代码。改造后的代码如下:

bool outMsgLULProc(int& command)
{
    std::lock_guard<std::mutex> sbguard(my_mutex); //sbguard是随便起的变量名 
    //my_mutex.lock();
    if (!msgRecvQueue.empty())
    {
        command = msgRecvQueue.front();//返回第一个元素但不检查元素存在与否
        msgRecvQueue.pop_front();
        //my_mutex.unlock();
        return true;
    }
    //my_mutex.unlock();
    return false;
}
bool outMsgLULProc(int& command)
{
    std::lock_guard<std::mutex> sbguard(my_mutex); //sbguard是随便起的变量名 
    //my_mutex.lock();
    if (!msgRecvQueue.empty())
    {
        command = msgRecvQueue.front();//返回第一个元素但不检查元素存在与否
        msgRecvQueue.pop_front();
        //my_mutex.unlock();
        return true;
    }
    //my_mutex.unlock();
    return false;
}

    读者可能不明白std::lock_guard<std::mutex>的工作原理,其实它的工作原理很简单,这样理解:在lock_guard类模板的构造函数里,调用了mutex的lock成员函数,而在析构函数里,调用了mutex的unlock成员函数,仅此而已。
    就等于调用了mutex的lock成员函数。当return到outMsgLULProc函数外边去的时候,sbguard(这是一个局部变量)超出了作用域,系统会自动调用它的析构函数,相当于调用了mutex的unlock成员函数。所以从此就不用再担心lock后忘记unlock的问题。
    虽然sbguard(std::lock_guard<std::mutex>类型对象)使用起来很方便,但它不如单独使用mutex灵活,因为如果单独使用mutex,则可以随时通过调用mutex的unlock成员函数来解锁互斥量。而使用sbguard无法做到这一点,仅当sbguard超出作用域或者所在函数返回的时候才会因为std::lock_guard<std::mutex>析构函数的执行而去调用mutex的unlock成员函数。下面再改造一下inMsgRecvQueue成员函数的代码,笔者特意将sbguard用“{}”包起来,当超过这个“{}”所代表的范围/作用域时,sbguard就会调用mutex的unlock成员函数。
    总之,要保证一点,在这些互斥量包裹的位置(互斥量包裹的位置,就是指lock和unlock之间)之外,不要修改msgRecvQueue这种公共数据(共享数据),例如切不可把这种共享数据当作参数传递到其他不受lock和unlock保护的函数中去操作,否则肯定还会出问题。

  5.3 死锁

(1)死锁演示

    这里使用lock和unlock来演示死锁问题,以达到更明显的演示效果。
    通过前面的讲解已经知道,死锁问题的产生至少需要两个互斥量(而且至少也需要两个线程同时运行),所以在类A中再定义一个互斥量作为成员变量:
    如下:执行起来会发现某个时刻,程序锁住了,执行不下去了,屏幕上再无任何输出了。这就是典型的程序死锁

void inMsgRecvQueue()
{
    for (int i = 0; i < 100000; i++)
    {
        cout << "inMsgRecvQueue()执行,插入一个元素" << i << endl;
        my_mutex.lock(); //两行lock()代码不一定紧张挨着,可能它们要保护不同的数据共享块
        //......需要保护的一些共享数据
        my_mutex2.lock();
        msgRecvQueue.push_back(i); 
        my_mutex2.unlock();
        my_mutex.unlock();
    }
}
bool outMsgLULProc(int& command)
{
    my_mutex.lock();
    my_mutex2.lock();
    if (!msgRecvQueue.empty())
    {
        command = msgRecvQueue.front();
        msgRecvQueue.pop_front();
        my_mutex2.unlock();
        my_mutex.unlock();
        return true;
    }
    my_mutex2.unlock();
    my_mutex.unlock();
    return false;
}

(2)死锁的一般解决方案

    不难感受到,死锁主要的问题是线程入口函数inMsgRecvQueue中加锁的顺序是先锁my_mutex后锁my_mutex2,而outMsgLULProc中加锁的顺序正好反过来了——先锁my_mutex2而后锁了my_mutex。所以,只要程序员确保这两个互斥量上锁的先后顺序相同就不会死锁。
    所以修改outMsgLULProc代码,把其中的lock语句行的顺序调整一下
    而unlock的顺序则没有太大关系(建议谁后lock,谁就先unlock)。所以两对unlock可以建议调整(上面的lock顺序是必须调整,而这里的unlock顺序是建议调整)成如下顺序

    my_mutex2.lock();
    my_mutex.lock();
    my_mutex2.unlock();
    my_mutex.unlock();

    上面的范例直接使用的是mutex的lock和unlock成员函数,其实使用std::lock_guard类模板也是可以的。改造一下inMsgRecvQueue的代码:

void inMsgRecvQueue()
{
    for (int i = 0; i < 100000; i++)
    {
        cout << "inMsgRecvQueue()执行,插入一个元素" << i << endl;
        std::lock_guard<std::mutex> sbguard1(my_mutex);
        std::lock_guard<std::mutex> sbguard2(my_mutex2);
        msgRecvQueue.push_back(i); 
    }
}
bool outMsgLULProc(int& command)
{
    std::lock_guard<std::mutex> sbguard1(my_mutex);
    std::lock_guard<std::mutex> sbguard2(my_mutex2);
    if (!msgRecvQueue.empty())
    {
        command = msgRecvQueue.front();
        msgRecvQueue.pop_front();
        return true;
    }
    return false;
}

(3)std::lock函数模板

    std::lock函数模板能一次锁住两个或者两个以上的互斥量(互斥量数量是2个到多个,不可以是1个),它不存在多个线程中因为锁的顺序问题导致死锁的风险。
如果这些互斥量中有一个没锁住,就要卡在std::lock那里等着,等所有互斥量都锁住,std::lock才能返回,程序执行流程才能继续往下走。
    可以想象一下std::lock的工作步骤。例如它先锁第一个互斥量,成功锁住,但锁第二个互斥量的时候如果锁定失败,此时它会把第一个锁住的互斥量解锁(不然别的用到这个锁的线程就会卡死),同时等在那里,等着两个互斥量都能锁定。所以std::lock锁定两个mutex的特点是:要么两个mutex(互斥量)都锁住,要么两个mutex都没锁住,此时std::lock卡在那里不断地尝试锁这两个互斥量
    所以,std::lock是要处理多个互斥量的时候才出场的。
    这里用std::lock改造一下上面的inMsgRecvQueue函数:

void inMsgRecvQueue()
{
    for (int i = 0; i < 100000; i++)
    {
        cout << "inMsgRecvQueue()执行,插入一个元素" << i << endl;
        std::lock(my_mutex, my_mutex2); //相当于每个互斥量都调用了lock
        msgRecvQueue.push_back(i);
        my_mutex2.unlock(); //前面锁住2个,后面就得解锁2个
        my_mutex.unlock();
    }
}
bool outMsgLULProc(int& command)
{
    std::lock(my_mutex2, my_mutex); //两个顺序谁在前谁在后无所谓		
    if (!msgRecvQueue.empty())
    {
        command = msgRecvQueue.front();
        msgRecvQueue.pop_front();
        my_mutex.unlock(); //先unlock谁后unlock谁并没关系
        my_mutex2.unlock();
        return true;
    }
    my_mutex2.unlock();
    my_mutex.unlock();
    return false;
}

    执行起来,整个程序的运行没有什么问题。
    上面的代码还是有略微遗憾的,因为还要程序员自己操心unlock的事,能不能继续借助std::lock_guard来帮助程序员unlock呢?能!这就需要再次修改inMsgRecvQueue代码(见下面的修改)。
    std::lock这种一次锁住多个互斥量的函数模板,要谨慎使用(对于互斥量,还是建议一个一个地锁)。因为一般来讲,用到两个或者两个以上互斥量的线程,每个互斥量都应该是保护不同的代码段,也就是说,两个互斥量的lock应该是有先有后,两个互斥量同时(在同一行代码中或者叫同一个时刻)锁住的情况不多见。

(4)std::lock_guard的std::adopt_lock参数

void inMsgRecvQueue()
{
    for (int i = 0; i < 100000; i++)
    {
        cout << "inMsgRecvQueue()执行,插入一个元素" << i << endl;
        std::lock(my_mutex, my_mutex2);
        std::lock_guard<std::mutex> sbguard1(my_mutex, std::adopt_lock);
        std::lock_guard<std::mutex> sbguard2(my_mutex2, std::adopt_lock);			
        msgRecvQueue.push_back(i);
    }
}
bool outMsgLULProc(int& command)
{
    std::lock_guard<std::mutex> sbguard1(my_mutex, std::adopt_lock);
    std::lock_guard<std::mutex> sbguard2(my_mutex2, std::adopt_lock);
    if (!msgRecvQueue.empty())
    {
        command = msgRecvQueue.front();
        msgRecvQueue.pop_front();
        return true;
    }
    return false;
}

    可以注意到,在生成std::lock_guard<std::mutex>对象的时候,第二个参数是std::adopt_lock,原来没有这个参数,现在有了。
    前面讲解std::lock_guard<std::mutex>对象时谈到,在该对象的构造函数中会调用互斥量的lock函数,在析构函数中会调用互斥量的unlock函数。现在的情况是已经调用std::lock把这两个互斥量都lock上了,就不需要再通过std::lock_guard来lock一次了,所以这里给出了std::lock_guard<std::mutex>对象的第二个参数std::adopt_lock,std::adopt_lock其实是一个结构体对象,这里就是起一个标记作用,不用深究。这个标记起的作用就是通知系统其中的互斥量已经被lock过了,不需要std::lock_guard<std::mutex>对象在构造函数中再次lock,只需要在析构函数中unlock这个互斥量就可以了。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值