打造异常安全的共享锁——std::unique_lock和std::shared_lock

由于对于线程的操作严重依赖于操作系统,所以STL提供与系统无关的接口是非常明智的,当然STL也会提供线程间的同步操作。这样就不仅是能够启动和停止线程,使用STL库也能完成线程的同步操作。

本节中,我们将了解到STL中的互斥量和RAII锁。我们使用这些工具对线程进行同步时,也会了解STL中更多同步辅助的方式。

How to do it…

我们将使用std::shared_mutex在独占(exclusive)和共享(shared)模式下来完成一段程序,并且也会了解到这两种方式意味着什么。另外,我们将不会对手动的对程序进行上锁和解锁的操作,这些操作都交给RAII辅助函数来完成:

  1. 包含必要的头文件,并声明所使用的命名空间:

    1. #include <iostream>
    2. #include <shared_mutex>
    3. #include <thread>
    4. #include <vector>
    5. using namespace std;
    6. using namespace chrono_literals;
  2. 整个程序都会围绕共享互斥量展开,为了简单,我们定义了一个全局实例:

    1. shared_mutex shared_mut;
  3. 接下来,我们将会使用std::shared_lockstd::unique_lock这两个RAII辅助者。为了让其类型看起来没那么复杂,这里进行别名操作:

    1. using shrd_lck = shared_lock<shared_mutex>;
    2. using uniq_lck = unique_lock<shared_mutex>;
  4. 开始写主函数之前,先使用互斥锁的独占模式来实现两个辅助函数。下面的函数中,我们将使用unique_lock实例来作为共享互斥锁。其构造函数的第二个参数defer_lock会告诉对象让锁处于解锁的状态。否则,构造函数会尝试对互斥量上锁阻塞程序,直至成功为止。然后,会对exclusive_lock的成员函数try_lock进行调用。该函数会立即返回,并且返回相应的布尔值,布尔值表示互斥量是否已经上锁,或是在其他地方已经锁住:

    1. static void print_exclusive()
    2. {
    3. uniq_lck l {shared_mut, defer_lock};
    4. if (l.try_lock()) {
    5. cout << "Got exclusive lock.\n";
    6. } else {
    7. cout << "Unable to lock exclusively.\n";
    8. }
    9. }
  5. 另一个函数也差不多。其会将程序阻塞,直至其获取相应的锁。然后,会使用抛出异常的方式来模拟发生错误的情况(只会返回一个整数,而非一个非常复杂的异常对象)。虽然,其会立即退出,并且在上下文中我们获取了一个锁住的互斥量,但是这个互斥量也可以被释放。这是因为unique_lock的析构函数在任何情况下都会将对应的锁进行释放:

    1. static void exclusive_throw()
    2. {
    3. uniq_lck l {shared_mut};
    4. throw 123;
    5. }
  6. 现在,让我们来写主函数。首先,先开一个新的代码段,并且实例化一个shared_lock实例。其构造函数将会立即对共享模式下的互斥量进行上锁。我们将会在下一步了解到这一动作的意义:

    1. int main()
    2. {
    3. {
    4. shrd_lck sl1 {shared_mut};
    5. cout << "shared lock once.\n";
  7. 现在我们开启另一个代码段,并使用同一个互斥量实例化第二个shared_lock实例。现在具有两个shared_lock实例,并且都具有同一个互斥量的共享锁。实际上,可以使用同一个互斥量实例化很多的shared_lock实例。然后,调用print_exclusive,其会尝试使用互斥量的独占模式对互斥量进行上锁。这样的调用当然不会成功,因为互斥量已经在共享模式下锁住了:

    1. {
    2. shrd_lck sl2 {shared_mut};
    3. cout << "shared lock twice.\n";
    4. print_exclusive();
    5. }
  8. 离开这个代码段后,shared_locks12的析构函数将会释放互斥量的共享锁。print_exclusive函数还是失败,这是因为互斥量依旧处于共享锁模式:

    1. cout << "shared lock once again.\n";
    2. print_exclusive();
    3. }
    4. cout << "lock is free.\n";
  9. 离开这个代码段时,所有shared_lock对象就都被销毁了,并且互斥量再次处于解锁状态,现在我们可以在独占模式下对互斥量进行上锁了。调用exclusive_throw,然后调用print_exclusive。不过因为unique_lock是一个RAII对象,所以是异常安全的,也就是无论exclusive_throw返回了什么,互斥量最后都会再次解锁。这样即便是互斥量处于锁定状态,print_exclusive 也不会被错误的状态所阻塞:

    1. try {
    2. exclusive_throw();
    3. } catch (int e) {
    4. cout << "Got exception " << e << '\n';
    5. }
    6. print_exclusive();
    7. }
  10. 编译并运行这段代码则会得到如下的输出。前两行展示的是两个共享锁实例。然后,print_exclusive函数无法使用独占模式对互斥量上锁。在离开内部代码段后,第二个共享锁解锁,print_exclusive函数依旧会失败。在离开这个代码段后,将会对互斥量所持有的锁进行释放,这样exclusive_throwprint_exclusive最终才能对互斥量进行上锁:

    1. $ ./shared_lock
    2. shared lock once.
    3. shared lock twice.
    4. Unable to lock exclusively.
    5. shared lock once again.
    6. Unable to lock exclusively.
    7. lock is free.
    8. Got exception 123
    9. Got exclusive lock.

How it works…

查阅C++文档时,我们会对不同的互斥量和RAII辅助锁有些困惑。在我们回看这节的代码之前,让我们来对STL的这两个部分进行总结。

互斥量

其为mutual exclusion的缩写。并发时不同的线程对于相关的共享数据同时进行修改时,可能会造成结果错误,我们在这里就可以使用互斥量对象来避免这种情况的发生,STL提供了不同特性的互斥量。不过,这些互斥量的共同点就是具有lockunlock两个成员函数。

一个互斥量在解锁状态下时,当有线程对其使用lock()时,这个线程就获取了互斥量,并对互斥量进行上锁。这样,但其他线程要对这互斥量进行上锁时,就会处于阻塞状态,知道第一个线程对该互斥量进行释放。std::mutex就可以做到。

这里将STL一些不同的互斥量进行对比:

类型名 描述
mutex 具有lockunlock成员函数的标准互斥量。并提供非阻塞函数try_lock成员函数。
timed_mutex 与互斥量相同,并提供try_lock_fortry_lock_until成员函数,其能在某个时间段内对程序进行阻塞。
recursive_mutex 与互斥量相同,不过当一个线程对实例进行上锁,其可以对同一个互斥量对象多次调用lock而不产生阻塞。持有线程可以多次调用unlock,不过需要和lock调用的次数匹配时,线程才不再拥有这个互斥量。
recursive_timed_mutex 提供与timed_mutexrecursive_mutex的特性。
shared_mutex 这个互斥量在这方面比较特殊,它可以被锁定为独占模式或共享模式。独占模式时,其与标准互斥量的行为一样。共享模式时,其他线程也可能在共享模式下对其进行上锁。其会在最后一个处于共享模式下的锁拥有者进行解锁时,整个互斥量才会解锁。其行为有些类似于shared_ptr,只不过互斥量不对内存进行管理,而是对锁的所有权进行管理。
shared_timed_mutex 同时具有shared_mutextimed_mutex两种互斥量独占模式和共享模式的特性。

线程对互斥量上锁之后,很多事情都变的非常简单,我们只需要上锁、访问、解锁三步就能完成我们想要做的工作。不过对于有些比较健忘的开发者来说,在上锁之后,很容易忘记对其进行解锁,或是互斥量在上锁状态下抛出一个异常,如果要对这个异常进行处理,那么代码就会变得很难看。最优的方式就是程序能够自动来处理这种事情。这种问题很类似与内存泄漏,开发者在分配内存之后,忘记使用delete操作进行内存释放。

内存管理部分,我们有unique_ptrshared_ptrweak_ptr。这些辅助类可以很完美帮我们避免内存泄漏。互斥量也有类似的帮手,最简单的一个就是std::lock_guard。使用方式如下:

  1. void critical_function()
  2. {
  3. lock_guard<mutex> l {some_mutex};
  4. // critical section
  5. }

lock_guard的构造函数能接受一个互斥量,其会立即自动调用lock,构造函数会直到获取互斥锁为止。当实例进行销毁时,其会对互斥量再次进行解锁。这样互斥量就很难陷入到lock/unlock循环错误中。

C++17 STL提供了如下的RAII辅助锁。其都能接受一个模板参数,其与互斥量的类型相同(在C++17中,编译器可以自动推断出相应的类型):

名称 描述
lock_guard 这个类没有什么其他的,构造函数中调用lock,析构函数中调用unlock
scoped_lock lock_guard类似,构造函数支持多个互斥量。析构函数中会以相反的顺序进行解锁。
unique_lock 使用独占模式对互斥量进行上锁。构造函数也能接受一个参数用于表示超时到的时间,并不会让锁一直处于上锁的状态。其也可能不对互斥量进行上锁,或是假设互斥量已经锁定,或是尝试对互斥量进行上锁。另外,函数可以在unique_lock锁的声明周期中,对互斥量进行上锁或解锁。
shared_lock unique_lock类似,不过所有操作都是在互斥量的共享模式下进行操作。

lock_guardscoped_lock只拥有构造和析构函数,unique_lockshared_lock就比较复杂了,但也更为通用。我们将在本章的后续章节中了解到,这些类型如何用于更加复杂的情况。

现在我们来回看一下本节的代码。虽然,只在单线程的上下文中运行程序,但是我们可以了解到如何对辅助锁进行使用。shrd_lck类型为shared_lock<shared_mutex>的缩写,并且其允许我们在共享模式下对一个实例多次上锁。当sl1sl2存在的情况下,print_exclusive无法使用独占模式对互斥量进行上锁。

现在来看看处于独占模式的上锁函数:

  1. int main()
  2. {
  3. {
  4. shrd_lck sl1 {shared_mut};
  5. {
  6. shrd_lck sl2 {shared_mut};
  7. print_exclusive();
  8. }
  9. print_exclusive();
  10. }
  11. try {
  12. exclusive_throw();
  13. } catch (int e) {
  14. cout << "Got exception " << e << '\n';
  15. }
  16. print_exclusive();
  17. }

exclusive_throw的返回也比较重要,即便是抛出异常退出,exclusive_throw函数依旧会让互斥量再度锁上。

因为print_exclusive使用了一个奇怪的构造函数,我们就再来看一下这个函数:

  1. void print_exclusive()
  2. {
  3. uniq_lck l {shared_mut, defer_lock};
  4. if (l.try_lock()) {
  5. // ...
  6. }
  7. }

这里我们不仅提供了shared_mut,还有defer_lock作为unique_lock构造函数的参数。defer_lock是一个空的全局对象,其不会对互斥量立即上锁,所以我们可以通过这个参数对unique_lock不同的构造函数进行选择。这样做之后,我们可以调用l.try_lock(),其会告诉我们有没有上锁。在互斥量上锁的情况下,就可以做些别的事情了。如果的确有机会获取锁,依旧需要析构函数对互斥量进行清理。