由于对于线程的操作严重依赖于操作系统,所以STL提供与系统无关的接口是非常明智的,当然STL也会提供线程间的同步操作。这样就不仅是能够启动和停止线程,使用STL库也能完成线程的同步操作。
本节中,我们将了解到STL中的互斥量和RAII锁。我们使用这些工具对线程进行同步时,也会了解STL中更多同步辅助的方式。
我们将使用std::shared_mutex
在独占(exclusive)和共享(shared)模式下来完成一段程序,并且也会了解到这两种方式意味着什么。另外,我们将不会对手动的对程序进行上锁和解锁的操作,这些操作都交给RAII辅助函数来完成:
-
包含必要的头文件,并声明所使用的命名空间:
#include <iostream> #include <shared_mutex> #include <thread> #include <vector> using namespace std; using namespace chrono_literals;
-
整个程序都会围绕共享互斥量展开,为了简单,我们定义了一个全局实例:
shared_mutex shared_mut;
-
接下来,我们将会使用
std::shared_lock
和std::unique_lock
这两个RAII辅助者。为了让其类型看起来没那么复杂,这里进行别名操作:using shrd_lck = shared_lock<shared_mutex>; using uniq_lck = unique_lock<shared_mutex>;
-
开始写主函数之前,先使用互斥锁的独占模式来实现两个辅助函数。下面的函数中,我们将使用
unique_lock
实例来作为共享互斥锁。其构造函数的第二个参数defer_lock
会告诉对象让锁处于解锁的状态。否则,构造函数会尝试对互斥量上锁阻塞程序,直至成功为止。然后,会对exclusive_lock
的成员函数try_lock
进行调用。该函数会立即返回,并且返回相应的布尔值,布尔值表示互斥量是否已经上锁,或是在其他地方已经锁住:static void print_exclusive() { uniq_lck l {shared_mut, defer_lock}; if (l.try_lock()) { cout << "Got exclusive lock.\n"; } else { cout << "Unable to lock exclusively.\n"; } }
-
另一个函数也差不多。其会将程序阻塞,直至其获取相应的锁。然后,会使用抛出异常的方式来模拟发生错误的情况(只会返回一个整数,而非一个非常复杂的异常对象)。虽然,其会立即退出,并且在上下文中我们获取了一个锁住的互斥量,但是这个互斥量也可以被释放。这是因为
unique_lock
的析构函数在任何情况下都会将对应的锁进行释放:static void exclusive_throw() { uniq_lck l {shared_mut}; throw 123; }
-
现在,让我们来写主函数。首先,先开一个新的代码段,并且实例化一个
shared_lock
实例。其构造函数将会立即对共享模式下的互斥量进行上锁。我们将会在下一步了解到这一动作的意义:int main() { { shrd_lck sl1 {shared_mut}; cout << "shared lock once.\n";
-
现在我们开启另一个代码段,并使用同一个互斥量实例化第二个
shared_lock
实例。现在具有两个shared_lock
实例,并且都具有同一个互斥量的共享锁。实际上,可以使用同一个互斥量实例化很多的shared_lock
实例。然后,调用print_exclusive
,其会尝试使用互斥量的独占模式对互斥量进行上锁。这样的调用当然不会成功,因为互斥量已经在共享模式下锁住了:{ shrd_lck sl2 {shared_mut}; cout << "shared lock twice.\n"; print_exclusive(); }
-
离开这个代码段后,
shared_locks12
的析构函数将会释放互斥量的共享锁。print_exclusive
函数还是失败,这是因为互斥量依旧处于共享锁模式:cout << "shared lock once again.\n"; print_exclusive(); } cout << "lock is free.\n";
-
离开这个代码段时,所有
shared_lock
对象就都被销毁了,并且互斥量再次处于解锁状态,现在我们可以在独占模式下对互斥量进行上锁了。调用exclusive_throw
,然后调用print_exclusive
。不过因为unique_lock
是一个RAII对象,所以是异常安全的,也就是无论exclusive_throw
返回了什么,互斥量最后都会再次解锁。这样即便是互斥量处于锁定状态,print_exclusive
也不会被错误的状态所阻塞:try { exclusive_throw(); } catch (int e) { cout << "Got exception " << e << '\n'; } print_exclusive(); }
-
编译并运行这段代码则会得到如下的输出。前两行展示的是两个共享锁实例。然后,
print_exclusive
函数无法使用独占模式对互斥量上锁。在离开内部代码段后,第二个共享锁解锁,print_exclusive
函数依旧会失败。在离开这个代码段后,将会对互斥量所持有的锁进行释放,这样exclusive_throw
和print_exclusive
最终才能对互斥量进行上锁:
$ ./shared_lock
shared lock once.
shared lock twice.
Unable to lock exclusively.
shared lock once again.
Unable to lock exclusively.
lock is free.
Got exception 123
Got exclusive lock.
查阅C++文档时,我们会对不同的互斥量和RAII辅助锁有些困惑。在我们回看这节的代码之前,让我们来对STL的这两个部分进行总结。
互斥量
其为mutual exclusion的缩写。并发时不同的线程对于相关的共享数据同时进行修改时,可能会造成结果错误,我们在这里就可以使用互斥量对象来避免这种情况的发生,STL提供了不同特性的互斥量。不过,这些互斥量的共同点就是具有lock
和unlock
两个成员函数。
一个互斥量在解锁状态下时,当有线程对其使用lock()
时,这个线程就获取了互斥量,并对互斥量进行上锁。这样,但其他线程要对这互斥量进行上锁时,就会处于阻塞状态,知道第一个线程对该互斥量进行释放。std::mutex
就可以做到。
这里将STL一些不同的互斥量进行对比:
类型名 | 描述 |
---|---|
mutex | 具有lock 和unlock 成员函数的标准互斥量。并提供非阻塞函数try_lock 成员函数。 |
timed_mutex | 与互斥量相同,并提供try_lock_for 和try_lock_until 成员函数,其能在某个时间段内对程序进行阻塞。 |
recursive_mutex | 与互斥量相同,不过当一个线程对实例进行上锁,其可以对同一个互斥量对象多次调用lock 而不产生阻塞。持有线程可以多次调用unlock ,不过需要和lock 调用的次数匹配时,线程才不再拥有这个互斥量。 |
recursive_timed_mutex | 提供与timed_mutex 和recursive_mutex 的特性。 |
shared_mutex | 这个互斥量在这方面比较特殊,它可以被锁定为独占模式或共享模式。独占模式时,其与标准互斥量的行为一样。共享模式时,其他线程也可能在共享模式下对其进行上锁。其会在最后一个处于共享模式下的锁拥有者进行解锁时,整个互斥量才会解锁。其行为有些类似于shared_ptr ,只不过互斥量不对内存进行管理,而是对锁的所有权进行管理。 |
shared_timed_mutex | 同时具有shared_mutex 和timed_mutex 两种互斥量独占模式和共享模式的特性。 |
锁
线程对互斥量上锁之后,很多事情都变的非常简单,我们只需要上锁、访问、解锁三步就能完成我们想要做的工作。不过对于有些比较健忘的开发者来说,在上锁之后,很容易忘记对其进行解锁,或是互斥量在上锁状态下抛出一个异常,如果要对这个异常进行处理,那么代码就会变得很难看。最优的方式就是程序能够自动来处理这种事情。这种问题很类似与内存泄漏,开发者在分配内存之后,忘记使用delete
操作进行内存释放。
内存管理部分,我们有unique_ptr
,shared_ptr
和weak_ptr
。这些辅助类可以很完美帮我们避免内存泄漏。互斥量也有类似的帮手,最简单的一个就是std::lock_guard
。使用方式如下:
void critical_function()
{
lock_guard<mutex> l {some_mutex};
// critical section
}
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_guard
和scoped_lock
只拥有构造和析构函数,unique_lock
和shared_lock
就比较复杂了,但也更为通用。我们将在本章的后续章节中了解到,这些类型如何用于更加复杂的情况。
现在我们来回看一下本节的代码。虽然,只在单线程的上下文中运行程序,但是我们可以了解到如何对辅助锁进行使用。shrd_lck
类型为shared_lock<shared_mutex>
的缩写,并且其允许我们在共享模式下对一个实例多次上锁。当sl1
和sl2
存在的情况下,print_exclusive
无法使用独占模式对互斥量进行上锁。
现在来看看处于独占模式的上锁函数:
int main()
{
{
shrd_lck sl1 {shared_mut};
{
shrd_lck sl2 {shared_mut};
print_exclusive();
}
print_exclusive();
}
try {
exclusive_throw();
} catch (int e) {
cout << "Got exception " << e << '\n';
}
print_exclusive();
}
exclusive_throw
的返回也比较重要,即便是抛出异常退出,exclusive_throw
函数依旧会让互斥量再度锁上。
因为print_exclusive
使用了一个奇怪的构造函数,我们就再来看一下这个函数:
void print_exclusive()
{
uniq_lck l {shared_mut, defer_lock};
if (l.try_lock()) {
// ...
}
}
这里我们不仅提供了shared_mut
,还有defer_lock
作为unique_lock
构造函数的参数。defer_lock
是一个空的全局对象,其不会对互斥量立即上锁,所以我们可以通过这个参数对unique_lock
不同的构造函数进行选择。这样做之后,我们可以调用l.try_lock()
,其会告诉我们有没有上锁。在互斥量上锁的情况下,就可以做些别的事情了。如果的确有机会获取锁,依旧需要析构函数对互斥量进行清理。