前言
保护共享数据并非只有互斥量一种机制(尽管它是最通用的),有很多替代方案可以在特定情况下提供比互斥量更合适的保护。
保护共享数据的初始化过程
一个极端却很常见的使用场景:共享数据仅在初始化过程中需要得到保护,之后便不再需要显式同步。
造成这种场景的原因很多,如数据一旦创建完成后即具备只读属性(因此不再存在同步问题),又或者共享数据的日常操作过程已经隐式地包含了保护(因此在初始化完毕后不再需要显式保护)。
不管如何,出于保护共享数据初始化过程的目的,而在数据初始化完毕后锁住互斥量是一种不必要且影响性能的操作。因此,C++标准库提供了一种专门用于保护共享数据初始化过程的机制。
问题实例
假设当前存在一个构造代价十分昂贵的共享数据(该对象需要配置大量内存或者打开数据库连接)。在单线程环境中,lazy initialization
是一种常规操作:任何需要该数据的操作都必须首先判断该源是否已经初始化完毕,若未完毕则执行初始化:1
2
3
4
5
6
7std::shared_ptr<some_resource> resource_ptr;
void foo() {
if(!resource_ptr) {
resource_ptr.reset(new some_resource); // 在并发环境下需要被保护
}
resource_ptr->do_something();
}
解决方案1
如果共享数据本身在并发环境下具备安全性,那么在将该函数转为多线程代码时需要保护的仅有初始化部分,那么某些开发人员可能会将其改为:1
2
3
4
5
6
7
8
9
10
11std::shared_ptr<some_resource> resource_ptr;
std::mutex resource_mutex;
void foo() {
std::unique_lock<std::mutex> lk(resource_mutex); // 时间点1
if(!resource_ptr) {
resource_ptr.reset(new some_resource);
}
lk.unlock();
resource_ptr->do_something();
}
上述程序显然造成了性能损耗:大多数线程被迫停在时间点1处排队,即使线程已经完成了初始化(即根本无需进入if语句)。
双重检查锁
部分开发者不满于解决方案1的低效,于是写出了声名狼藉的双重检查锁模式(double-checked locking pattern
):1
2
3
4
5
6
7
8
9void undefined_behaviour_with_double_checked_locking() {
if(!resource_ptr) { // 时间点1
std::lock_guard<std::mutex> lk(resource_mutex); // 时间点2
if(!resource_ptr) {
resource_ptr.reset(new some_resource); // 时间点3
}
}
resource_ptr->do_something();
}
在双重检查锁模式下,仅仅在资源指针为nullptr时才执行上锁操作,并在上锁后再次检查当前指针(以防止存在另一个线程在时间点1与时间点2之间完成了初始化操作)。
之所以说该模式声名狼藉,是因为它依旧存在触发条件竞争的风险。未被锁保护的读取操作(时间点1)并没有与数据写入操作(时间点3)同步,这将导致条件竞争,并且该条件竞争不仅涉及指针,还涉及指针指向的对象。即使某个线程明确了解当前指针正在被另一个线程执行写入操作,它也不一定明确了解刚刚创建的some_resource实例,从而造成do_something()
在一个不正确的数据上执行操作。(我理解即对数据的初始化行为尚未完成,但锁外的进程已经检测到指针非空,从而执行do_something()
)。C++ 标准将这种情形定义为将造成未定义行为的数据竞争。
最终方案
C++标准委员会提供了std::once_flag
与std::call_once
来处理这种情况。比起锁住互斥量后显式地检查指针,线程只需要调用std::call_once
即可安全可靠地了解到资源是否已被初始化。所有必需的同步数据均被存储于std::once_flag
内,,每一个std::once_flag
实例均与一个初始化相关联。std::call_once
的使用成本要小于互斥量(尤其是在初始化操作已经完成后),因此我们应当在条件适宜的情况下优先使用前者。下述代码将展示如何用std::call_once
实现lazy initialization
:1
2
3
4
5
6
7
8
9
10
11std::shared_ptr<some_resource> resource_ptr;
std::once_flag resource_flag;
void init_resource() {
resource_ptr.reset(new some_resource);
}
void foo() {
std::call_once(resource_flag,init_resource);
resource_ptr->do_something();
}std::call_once
既可以接受一个函数也可以接受一个可调用对象,此外,std::call_once
也可用于类内成员的lazy initialization
:
1 | class X { |
在上述代码中,仅有第一次在调用send_data
或receive_data
时才会执行初始化操作。此外还有老生常谈的一点:std::once
调用成员函数时需要传入this
指针,就像std::thread
的构造函数或std::bind
一样。
与std::mutex
不同,std::once_flag
是一个move only object
,因此持有它的类需要保证unCopyable。
static成员
除去lazy initialization
以外,类内含有static成员时也将引发条件竞争。众所周知,static成员在声明后即完成初始化操作,在并发编程环境下这意味着条件竞争——存在多个线程抢着定义该变量。在一些旧版本编译器(不支持C++11)中这种条件竞争确实存在,不过C++11标准一劳永逸地解决了这个难题:初始化操作永远只在一个线程内完成,在初始化结束之前不会有任何线程能够继续执行。在需要单个全局实例的应用场景中,static对象可以作为std::call_once
的一种替代:1
2
3
4class my_class; my_class& get_my_class_instance() {
static my_class instance;
return instance;
}
保护很少更新的数据结构
仅在初始化期间需要保护的数据结构只是一个特例,本节将重点讨论如何保护很少更新的数据结构。
假设存在这样一个应用场景:为了将域名解析为ip地址,我们在缓存中存放了一张DNS入口表。显然DNS入口表很少会发生变动,但它存在更新的可能性(当用户访问一个新网站时)。因此,我们需要在该数据结构更新时执行保护,以确保所有线程均可读取正确数据。
如果我们没有为此情况设计特殊的数据结构,那么势必需要执行更新的线程独占数据结构读取权限,直到更新操作完成,在此情况下使用互斥量无疑造成了性能上的浪费。我们需要的是一种新的名为“读写锁”(read-write mutex
)的互斥量,该互斥量允许两种不同的操作:writer
具备对数据的并发读写权限,而reader
仅有对数据的读取权限。
C++ 17 标准库提供了两个现成的互斥锁std::shared_mutex
和std::shared_timed_mutex
(二者的区别将会在后文中体现)。C++14 只提供了 std::shared_timed_mutex
,虽然 C++11这两种互斥锁都未提供,但开发者可以从Boost中获取boost::shared_mutex
。
在此类使用环境下,以std::lock_guard<std::shared_mutex>
和std::unique_lock<std::shared_mutex>
完成上锁操作,即可保证仅有当前线程可对数据执行修改,而其他线程可通过std::shared_lock<std::shared_mutex>
获得读取访问权。这个 RAII 类模板是在 C++14 中添加的,使用方法与std::unique_lock
相同,只是多个线程可以在同一个std::shared_mutex
上同时拥有一个共享锁。
下述程序展示了如何使用std::shared_mutex
完成DNS入口表:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class dns_entry;
class dns_cache {
std::map<std::string,dns_entry> entries;
mutable std::shared_mutex entry_mutex;
public:
dns_entry find_entry(std::string const& domain) const {
std::shared_lock<std::shared_mutex> lk(entry_mutex);
std::map<std::string,dns_entry>::const_iterator const it = entries.find(domain);
return (it == entries.end()) ? dns_entry() : it->second;
}
void update_or_add_entry(std::string const& domain,dns_entry const& dns_details) {
std::lock_guard<std::shared_mutex> lk(entry_mutex);
entries[domain] = dns_details;
}
};
find_entry()
使用std::shared_lock<>
来保证了多线同时调用find_entry()
的正确性。而update_or_add_entry()
使用了std::lock_guard<>
,在其调用过程中,不仅其他线程被阻止进行更新,而且调用 find_entry()
的线程也会被阻塞。
嵌套锁
在一般情况下,mutex
被锁住后仍对其反复执行上锁操作将产生未定义行为,但某些情况我们不得不这么做,于是标准库提供了std::recursive_mutex
。它的行为与std::mutex
非常类似,需要注意的是调用3次lock
后必然需要调用3次unlock
,当然,合理地使用std::lock_guard<std::recursive_mutex>
和 std::unique_lock<std::recursive_mutex>
可以规避许多此类烦恼。
嵌套锁的使用并不常见,如果你觉得当前存在使用的必要,那么很有可能表明你的数据结构需要重新设计。嵌套锁一般应用于某种并发数据结构内,由于每个成员函数在执行前都执行了上锁操作,并且成员函数内调用了另一个成员函数,此时不得不进行多次上锁,使用std::recursive_mutex
能够保证此类程序成功运行。当然这种做法非常草率,常规写法是将被调用成员函数设为私有,并在其内部不执行上锁操作(但需要检查是否已经上锁),然后仔细斟酌该函数被调用时的上下文以及可能的数据状态。