线程间共享数据——保护共享数据的替代设施

前言

 
保护共享数据并非只有互斥量一种机制(尽管它是最通用的),有很多替代方案可以在特定情况下提供比互斥量更合适的保护。


保护共享数据的初始化过程

 
一个极端却很常见的使用场景:共享数据仅在初始化过程中需要得到保护,之后便不再需要显式同步。

造成这种场景的原因很多,如数据一旦创建完成后即具备只读属性(因此不再存在同步问题),又或者共享数据的日常操作过程已经隐式地包含了保护(因此在初始化完毕后不再需要显式保护)。

不管如何,出于保护共享数据初始化过程的目的,而在数据初始化完毕后锁住互斥量是一种不必要且影响性能的操作。因此,C++标准库提供了一种专门用于保护共享数据初始化过程的机制。

问题实例

假设当前存在一个构造代价十分昂贵的共享数据(该对象需要配置大量内存或者打开数据库连接)。在单线程环境中,lazy initialization是一种常规操作:任何需要该数据的操作都必须首先判断该源是否已经初始化完毕,若未完毕则执行初始化:

1
2
3
4
5
6
7
std::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
11
std::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
9
void 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_flagstd::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
11
std::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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class X { 
private:
connection_info connection_details;
connection_handle connection;
std::once_flag connection_init_flag;
void open_connection() {
connection=connection_manager.open(connection_details);
}
public:
X(connection_info const& connection_details_):
connection_details(connection_details_) {}
void send_data(data_packet const& data) {
std::call_once(connection_init_flag,&X::open_connection,this);
connection.send_data(data);
}
data_packet receive_data() {
std::call_once(connection_init_flag,&X::open_connection,this);
return connection.receive_data();
}
};

在上述代码中,仅有第一次在调用send_datareceive_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
4
class 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_mutexstd::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
#include <map>
#include <string>
#include <mutex>
#include <shared_mutex>
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能够保证此类程序成功运行。当然这种做法非常草率,常规写法是将被调用成员函数设为私有,并在其内部不执行上锁操作(但需要检查是否已经上锁),然后仔细斟酌该函数被调用时的上下文以及可能的数据状态。