2024.4.9记——C++多线程系列文章(五)之死锁

本文探讨了C++中死锁的产生原因,通过实例分析了如何通过std::lock()和std::lock_guard来避免死锁,以及std::scoped_lock的使用。强调了遵循固定顺序获取锁和避免嵌套锁的重要性。
摘要由CSDN通过智能技术生成

引言

如果用单一的全局互斥保护所有共享数据,也即锁的粒度过大,例如在共享大量数据的系统中,这么做会消除并发带来的任何性能优势,原因是多线程系统由此受到强制限定,任意时刻都只准许运行其中一个线程,即便它们访问不同的数据。

另一方面如果锁的粒度太小,需要被保护的操作没有被完整覆盖,就会出现接口的恶性条件竞争。精细粒度的加锁策略也存在问题。为了保护同一个操作涉及的所有数据,我们有时候需要锁住多个互斥。假如我们终须针对某项操作锁住多个互斥,那就会让一个问题藏匿起来,伺机进行扰乱:死锁。条件竞争是两个线程同时抢先运行,死锁则差不多是其反面:两个线程同时互相等待,停滞不前。

产生的原因

假设一件玩具由两部分组成,需要同时配合才能玩,譬如玩具鼓和鼓槌;又假设有两个小孩都喜欢这件玩具。倘若其中一个孩子拿到了玩具鼓和鼓槌,那他就能尽兴地一直敲鼓,敲烦了才停止。如果另一个孩子也想玩,便须等待,即使感到难受也没办法。再想象一下,玩具鼓和鼓槌分散在玩具箱里,两个小孩同时都想玩。于是,他们翻遍玩具箱,其中一人找到了玩具鼓,另一人找到了鼓槌,除非当中一位割爱让对方先玩,否则,他们只会僵持不下,各自都紧抓手中的部件不放,还要求对方“缴械”,结果都玩不成。

现在进行类比,我们面对的并非小孩争抢玩具,而是线程在互斥上争抢锁:有两个线程,都需要同时锁住两个互斥,才可以进行某项操作,但它们分别都只锁住了一个互斥,都等着再给另一个互斥加锁。于是,双方毫无进展,因为它们同在苦苦等待对方解锁互斥。上述情形称为死锁(deadlock)。为了进行某项操作而对多个互斥加锁,由此诱发的最大的问题之一正是死锁。

解决方法

防范死锁的建议通常是,始终按相同顺序对两个互斥加锁。若我们总是先锁互斥A再锁互斥B,则永远不会发生死锁。有时候,这直观、易懂,因为诸多互斥的用途各异。但也会出现棘手的状况,例如,运用多个互斥分别保护多个独立的实例,这些实例属于同一个类。考虑一个函数,其操作同一个类的两个实例,互相交换它们的内部数据。为了保证互换正确完成,免受并发改动的不良影响,两个实例上的互斥都必须加锁。可是,如果选用了固定的次序(两个对象通过参数传入,我们总是先给第一个实例的互斥加锁,再轮到第二个实例的互斥),前面的建议就适得其反:针对两个相同的实例,若两个线程都通过该函数在它们之间互换数据,只是两次调用的参数顺序相反,会导致它们陷入死锁!

所幸,C++标准库提供了 std::lock() 函数,专门解决这一问题。它可以同时锁住多个互斥,而没有发生死锁的风险。以下代码给出了示范,在简单的内部数据互换操作中运用std::lock()函数。

class some_big_object;
void swap(some_big_object& lhs, some_big_object& rhs);
class X
{
private:
    some_big_object some_detail;
    std::mutex m;
public:
    X(some_big_object const& sd):some_detail(sd){}
    friend void swap(X& lhs, X& rhs)
    {
        if(&lhs==&rhs)
            return;
        std::lock(lhs.m,rhs.m);    // ⇽---  ①
        std::lock_guard<std::mutex> lock_a(lhs.m, std::adopt_lock);   // ⇽---  ②
        std::lock_guard<std::mutex> lock_b(rhs.m, std::adopt_lock);   // ⇽---  ③
        swap(lhs.some_detail,rhs.some_detail);
    }
};

本例的函数一开始就对比两个参数,以确定它们指向不同实例。此项判断必不可少,原因是,若我们已经在某个std::mutex对象上获取锁,那么再次试图从该互斥获取锁将导致未定义行为(std::recursive_mutex类型的互斥准许同一线程重复加锁,后续介绍)。接着,代码调用std::lock()锁定两个互斥①,并依据它们分别构造std::lock_guard实例②③。我们除了用互斥充当这两个实例的构造参数,还额外提供了 std::adopt_lock 对象,以指明互斥已被锁住,即互斥上有锁存在,std::lock_guard实例应当据此接收锁的归属权,不得在构造函数内试图另行加锁。

无论函数是正常返回,还是因受保护的操作抛出异常而导致退出,std::lock_guard都保证了互斥全都正确解锁。另外,值得注意的是,std::lock()在其内部对lhs.m或rhs.m加锁,这一函数调用可能导致抛出异常,这样,异常便会从std::lock()向外传播。假如std::lock()函数在其中一个互斥上成功获取了锁,但它试图在另一个互斥上获取锁时却有异常抛出,那么第一个锁就会自动释放:若加锁操作涉及多个互斥,则std::lock()函数的语义是“全员共同成败”(all-or-nothing,或全部成功锁定,或没获取任何锁并抛出异常)。

针对上述场景,C++17还进一步提供了新的RAII类模板std::scoped_lock<>。std:: scoped_lock<>和std::lock_guard<>完全等价,只不过前者是可变参数模板(variadic template),接收各种互斥型别作为模板参数列表,还以多个互斥对象作为构造函数的参数列表。下列代码中,传入构造函数的两个互斥都被加锁,机制与std::lock()函数相同,因此,当构造函数完成时,它们就都被锁定,而后,在析构函数内一起被解锁。我们可以重写上述代码中的swap()函数,其内部操作代码如下:

void swap(X& lhs, X& rhs)
{
   if(&lhs==&rhs)
       return;
   std::scoped_lock guard(lhs.m,rhs.m);    // ⇽---  ①
   swap(lhs.some_detail,rhs.some_detail);
}

上例利用了C++17加入的另一个新特性:类模板参数推导。假使读者的编译器支持C++17标准(通过查验能否使用std::scoped_lock即可判断,因为它是C++17程序库工具),C++17具有隐式类模板参数推导(implicit class template parameter deduction)机制,依据传入构造函数的参数对象自动匹配①,选择正确的互斥型别。①处的语句等价于下面完整写明的版本:

std::scoped_lock<std::mutex,std::mutex> guard(lhs.m,rhs.m);

在C++17之前,我们采用std::lock()编写代码。现在有了std::scoped_lock,于是那些代码绝大多数可以改用这个编写,从而降低出错的概率。这肯定是件好事!

假定我们需要同时获取多个锁,那么std::lock()函数和std::scoped_lock<>模板即可帮助防范死锁;但若代码分别获取各个锁,它们就鞭长莫及了。在这种情况下,唯有依靠经验力求防范死锁。知易行难,死锁是最棘手的多线程代码问题之一,绝大多数情形中,纵然一切都运作正常,死锁也往往无法预测。尽管如此,我们编写的代码只要服从一些相对简单的规则,便有助于防范死锁。

防范死锁的补充准则

虽然死锁的最常见诱因之一是锁操作,但即使没有牵涉锁,也会发生死锁现象。假定有两个线程,各自关联了std::thread实例,若它们同时在对方的std::thread实例上调用join(),就能制造出死锁现象却不涉及锁操作。这种情形与前文的小孩争抢玩具相似,两个线程都因苦等对方结束而停滞不前。如果线程甲正等待线程乙完成某一动作,同时线程乙却在等待线程甲完成某一动作,便会构成简单的循环等待,并且线程数目不限于两个:就算是3个或更多线程,照样会引起死锁。防范死锁的准则最终可归纳成一个思想:只要另一线程有可能正在等待当前线程,那么当前线程千万不能反过来等待它。下列准则的细分条目给出了各种方法,用于判别和排除其他线程是否正在等待当前线程。

1.避免嵌套锁

第一条准则最简单:假如已经持有锁,就不要试图获取第二个锁。若能恪守这点,每个线程便最多只能持有唯一一个锁,仅锁的使用本身不可能导致死锁。但是还存在其他可能引起死锁的场景(譬如,多个线程彼此等待),而操作多个互斥锁很可能就是最常见的死锁诱因。万一确有需要获取多个锁,我们应采用std::lock()函数,借单独的调用动作一次获取全部锁来避免死锁。

2.一旦持锁,就须避免调用由用户提供的程序接口

这是上一条准则的延伸。若程序接口由用户自行实现,则我们无从得知它到底会做什么,它可能会随意操作,包括试图获取锁。一旦我们已经持锁,若再调用由用户提供的程序接口,而它恰好也要获取锁,那便违反了避免嵌套锁的准则,可能发生死锁。不过,有时这实在难以避免。对于类似3.2.3节的栈容器的泛型代码,只要其操作与模板参数的型别有关,它就不得不依赖用户提供的程序接口。因此我们需要另一条新的准则。

3.依从固定顺序获取锁

如果多个锁是绝对必要的,却无法通过std::lock()在一步操作中全部获取,我们只能退而求其次,在每个线程内部都依从固定顺序获取这些锁。若在两个互斥上获取锁,则有办法防范死锁:关键是,事先规定好加锁顺序,令所有线程都依从。这在一些情况下相对简单、易行。虽然这种方式并不一定总是可行,但在该情况下,我们至少可以同时对两个互斥加锁(上面例子)。

4.按层级加锁

锁的层级划分就是按特定方式规定加锁次序,在运行期据此查验加锁操作是否遵从预设规则。按照构思,我们把应用程序分层,并且明确每个互斥位于哪个层级。若某线程已对低层级互斥加锁,则不准它再对高层级互斥加锁。具体做法是将层级的编号赋予对应层级应用程序上的互斥,并记录各线程分别锁定了哪些互斥。这种模式虽然常见,但C++标准库尚未提供直接支持。

5.将准则推广到锁操作以外

之前就提到过,死锁现象并不单单因加锁操作而发生,任何同步机制导致的循环等待都会导致死锁出现。因此也值得为那些情况推广上述准则。譬如,我们应尽可能避免获取嵌套锁;若当前线程持有某个锁,却又同时等待别的线程,这便是坏的情况,因为万一后者恰好也需获取锁,反而只能等该锁被释放才能继续运行。类似地,如果要等待线程,那就值得针对线程规定层级,使得每个线程仅等待层级更低的线程。有一种简单方法可实现这种机制:让同一个函数启动全部线程,且汇合工作也由之负责。只要代码采取了防范死锁的设计,函数std::lock()和类std::lock_guard即可涵盖大多数简单的锁操作,不过我们有时需要更加灵活。标准库针对一些情况提供了std::unique_lock<>模板。它与std::lock_guard<>一样,也是一个依据互斥作为参数的类模板,并且以RAII手法管理锁,不过它更灵活一些,我们将在后续文章一一介绍。

前面文章

2024.3.27记——C++多线程系列文章(一)
2024.3.28记——C++多线程系列文章(二)之向线程函数传递参数
2024.3.29记——C++多线程系列文章(三)线程归属权转移及线程识别
2024.4.1记——C++多线程系列文章(四)之互斥

  • 24
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值