《C++并发实例》3.3 保护共享数据的其他措施
尽管互斥锁是最通用的机制,但在保护共享数据方面并不是唯一的方法。有一些替代方案可以在特定情况下提供更适当的保护。
一种特别极端(但非常常见)的情况是,共享数据仅在初始化时需要保护免受并发访问,但之后不需要显式同步。这可能是因为数据一旦创建就是只读的,因此不存在同步问题,或者可能是因为必要的保护是作为数据操作的一部分隐式执行的。无论哪种情况,在数据初始化后锁定互斥锁(纯粹是为了保护初始化)都是不必要的,并且会对性能产生不必要的影响。正是由于这个原因,C++ 标准提供了一种纯粹用于在初始化期间保护共享数据的机制。
3.3.1 初始化期间保护共享数据
假设您有一个构造成本非常昂贵的共享资源,您只想在必要时才这样做;也许它打开了数据库连接或分配了大量内存。像这样的延迟初始化(lazy initialization)在单线程代码中很常见——每个需要该资源的操作都需需要先检查它是否已初始化,如果没有,则在使用之前对其进行初始化:
std::shared_ptr<some_resource> resource_ptr;
void foo()
{
if(!resource_ptr)
{
resource_ptr.reset(new some_resource); -[1]
}
resource_ptr->do_something();
}
如果共享资源本身对于并发访问是安全的,在将其转换为多线程代码时唯一需要保护的部分是初始化[1],但是下面列表中的简单转化会导致使用该资源的线程不必要的序列化。因为每个线程必须等待互斥锁才能检查资源是否已初始化。
Listing 3.11 Thread-safe lazy initialization using a mutex
std::shared_ptr<some_resource> resource_ptr;
std::mutex resource_mutex;
void foo()
{
std::unique_lock<std::mutex> lk(resource_mutex); - All threads are serialized here
if(!resource_ptr)
{
resource_ptr.reset(new some_resource); - Only the initialization needs protection
}
lk.unlock();
resource_ptr->do_something();
}
这段代码很常见,而且不必要的序列化也有很多问题,,因此许多人试图想出更好的方法来完成,包括臭名昭著的双重检查锁定模式(Double-Checked Locking pattern):首先读取指针而不获取锁1,并且只有当指针为NULL时才获取锁。一旦获得锁,就会再次检查指针[2](因此是双重检查)以防另一个线程在第一次检查和该线程获取锁之间完成了初始化:
void undefined_behaviour_with_double_checked_locking()
{
if(!resource_ptr) -[1]
{
std::lock_guard<std::mutex> lk(resource_mutex);
if(!resource_ptr) -[2]
{
resource_ptr.reset(new some_resource); -[3]
}
}
resource_ptr->do_something(); -[4]
}
不幸的是,这种模式臭名昭著是有原因的:它有可能出现令人讨厌的竞争场景,因为锁外部的读取[1]和另一个线程在锁内的写入[3]不同步。因此,这会产生一个竞争场景,该竞争场景不仅包括指针本身,还包括所指向的对象;即使一个线程看到了指针被另一个线程写入,它也可能看不到新创建的 some_resource 实例,导致调用 do_something() [4] 操作错误的值。这个例子是竞争场景中 C++ 标准定义的数据竞争(data race),并被指明为未定义行为(undefined behavior)。因此,这绝对是应该避免的事情。有关内存模型的详细讨论,包括数据竞争的组成,请参阅第 5 章。
C++ 标准委员会也看到这是一个重要的场景,因此 C++ 标准库提供了 std::once_flag 和 std::call_once 来处理这种情况。除开锁定互斥锁并显式检查指针,每个线程都可以只使用 std::call_once,并确信在std::call_once 返回时指针会被某一个线程(以正确同步的方式)初始化。使用 std::call_once 通常比显式使用互斥锁具有更低的开销,特别是当初始化已经完成时,因此在功能匹配的情况下应优先使用它。下面的示例显示了与listing 3.11 相同的操作,使用 std::call_once重写。在这个例子中,初始化是通过调用函数来完成的,但它也可以通过带有函数运算符的类实例轻松完成。与标准库中大多数的函数或作为参数的函数一样, std::call_once 适用于任何函数或可调用对象。
std::shared_ptr<some_resource> resource_ptr;
std::once_flag resource_flag; -[1]
void init_resource()
{
resource_ptr.reset(new some_resource);
}
void foo() {
std::call_once(resource_flag,init_resource); - Initialization is called exactly once
resource_ptr->do_something();
}
在该示例中,std::once_flag [1] 和正在初始化的数据都是名称空间范围对象,但 std::call_once() 可以轻松地用于类成员的延迟初始化,如下面的列表所示。
Listing 3.12 Thread-safe lazy initialization of a class member using std::call_once
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) -[1]
{
std::call_once(connection_init_flag,&X::open_connection,this); -[2]
connection.send_data(data);
}
data_packet receive_data() -[3]
{
std::call_once(connection_init_flag,&X::open_connection,this); -[4]
return connection.receive_data();
}
};
在该示例中,初始化是通过第一次调用 send_data() [1] 或第一次调用 receive_data() [3] 完成的。使用成员函数open_connection()来初始化数据也需要传入this指针。像标准库中接受可调用对象的其他函数一样,例如std::thread和std::bind(),向 std::call_once() 传入附加函数一样可以实现。
值得注意的是,与 std::mutex 一样,std::once_flag 实例无法复制或移动,因此如果要将他们作为这样的类成员,需要显示定义特殊成员。
初始化时存在潜在竞争场景的一种情况是用 static 声明的局部变量。这种变量的初始化在首次定义声明时传入;;对于调用该函数的多个线程,这意味着可能首次定义可能出现竞争场景。在许多 C++11 之前的编译器上,这种竞争场景在实践中是有问题的,因为多个线程可能认为它们是第一个并尝试初始化该变量,或者线程希望使用时它可能在另一个线程上开始初始化但并没有完成。在 C++11 中,这个问题得到了解决:初始化被定义为仅在一个线程上发生,并且在初始化完成之前没有其他线程将继续执行,因此竞争场景只是在哪个线程进行初始化而不是更大的问题。对于需要单个全局实例的情况,可以用作 std::call_once 的替代方案:
class my_class;
my_class& get_my_class_instance()
{
static my_class instance; -[1] Initialization guaranteed to be thread-safe
return instance;
}
然后,多个线程可以安全地调用 get_my_class_instance() [1],而不必担心初始化时的竞争场景。
仅在初始化时保护数据是更一般场景的特例:很少更新数据结构。大多数时候,这样的数据结构是只读的,因此可以由多个线程同时读取,但有时数据结构可能需要更新,这种情况下需要保护机制。
3.3.2 保护少量更新的数据结构
考虑一个存储 DNS 条目缓存的表,用于将域名解析为其相应的 IP 地址。通常,给定的 DNS 条目将在很长一段时间内保持不变——在许多情况下,DNS 条目多年来都保持不变。尽管当用户访问不同的网站时,可能会不时地将新条目添加到表中,但该数据在其整个生命周期中将基本保持不变。定期检查缓存条目的有效性很重要,但只有在详细信息实际发生更改时才需要更新。
尽管更新很少见,但仍然可能发生,并且如果要从多个线程访问此缓存,则需要在更新期间对其进行适当的保护,以确保读取缓存的线程不会看到损坏的数据结构。
在缺乏专门为并发更新和读取而设计的专用数据结构(例如第 6 章和第 7 章中的数据结构)的情况下,此类更新要求执行更新的线程具有对数据访问有排他性,直到完成操作。一旦更改完成,数据结构就再次可以安全地供多个线程同时访问。因此,使用 std::mutex 来保护数据结构过于悲观,因为它将消除了在数据结构未进行修改时读取数据结构时的并发性;我们需要的是一种不同类型的互斥锁。这种新型互斥体通常称为读写互斥锁(reader-writer mutex),因为它允许两种不同的使用方式:单个“写入”线程的排他访问和多个“读取”线程的共享并发访问。
新的 C++ 标准库并没有提供这样一个开箱即用的互斥锁,尽管已向标准委员会提出了一个互斥锁 由于该提议未被接受,本节中的示例使用 Boost 库提供的实现,该实现是基于该提案。正如您将在第 8 章中看到的,使用这种互斥锁并不是万能的,其性能取决于所关联的处理器数量以及读取和更新线程的相对工作负载。因此,分析目标系统上代码的性能非常重要,以确保额外的复杂性确实有好处。
不用 std::mutex 实例进行同步,而是使用 boost::shared_mutex 实例。对于更新操作,std::lock_guard < boost::shared_mutex> 和 std::unique_lock< boost::shared_mutex> 可用于锁定,专门代替相应的 std::mutex。这些确保排他访问,就像 std::mutex 一样。那些不需要更新数据结构的线程可以使用 boost::shared_lock< boost::shared_mutex> 来获取共享访问权限。它的使用方式与 std::unique_lock 相同,只是多个线程可能同时在同一个 boost::shared_mutex 上拥有共享锁。唯一的限制是,如果任何线程拥有共享锁(shared lock),则尝试获取排它锁(exclusive lock)的线程将被阻塞,直到所有其他线程放弃锁,同样,如果任何线程拥有排它锁,则任何其他线程都无法获取共享锁或排他锁,直到第一个线程放弃锁。
下面的列表显示了一个简单的 DNS 缓存,就像刚刚描述的那样,使用 std::map 来保存缓存数据,并使用 boost::shared_mutex 进行保护。
Listing 3.13 Protecting a data structure with a boost::shared_mutex
#include <map>
#include <string>
#include <mutex>
#include <boost/thread/shared_mutex.hpp>
class dns_entry;
class dns_cache
{
std::map<std::string,dns_entry> entries;
mutable boost::shared_mutex entry_mutex;
public:
dns_entry find_entry(std::string const& domain) const
{
boost::shared_lock<boost::shared_mutex> lk(entry_mutex); -[1]
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<boost::shared_mutex> lk(entry_mutex); -[2]
entries[domain]=dns_details;
}
};
在listing 3.13 中,find_entry() 使用 boost::shared_lock<> 的实例来保护它的共享、只读访问 [1];因此多个线程可以同时调用 find_entry() 而不会出现问题。另一方面,update_or_add_entry() 使用 std::lock_guard<> 实例在表更新时提供排他访问 [2];不仅其他线程无法在调用 update_ or_add_entry() 中进行更新,而且调用 find_entry() 的线程也会被阻止。
3.3.3 递归锁定(recursive locking)
对于 std::mutex,线程尝试锁定已经锁定的互斥锁是错误的,并且尝试这样做将导致未定义的行为(undefined behavior)。然而,在某些情况下,线程可能需要多次
获取相同的互斥锁而无需首先释放。为此,C++ 标准库提供了 std::recursive_mutex。它的工作原理与 std::mutex 类似,只不过您可以从同一线程在单个实例上获取多次锁。必须在您释放所有后互斥锁才能被另一个线程锁定,因此如果您调用 lock() 三次,则还必须调用unlock() 三次。正确使用 std::lock_guard < std::recursive_mutex> 和 std::unique_lock< std::recursive_mutex> 将为您处理这个问题。
大多数情况下,如果您认为需要递归互斥体,可能需要更改的是您的设计。递归互斥体的一个常见用途是,一个类被设计为可以从多个线程同时访问,因此它有一个互斥锁来保护成员数据。每个公有成员函数都会锁定互斥锁,完成工作,然后解锁互斥体。然而,有时一个公有成员函数需要调用另一个作为其操作的一部分。在这种情况下,第二个成员函数也将尝试锁定互斥锁,从而导致未定义的行为。快速而肮脏的解决方案是将互斥锁更改为递归互斥锁。这将允许第二个成员函数中的互斥锁成功获取并且函数继续进行。
但是,不建议这样使用,因为它可能导致草率的思维和糟糕的设计。特别是,类的不变量通常在锁定时被破坏,这意味着即使在不变量被破坏的情况下调用第二个成员函数也会继续执行。通常最好抽象一个私有成员函数给两个公有成员函数调用,该函数不会锁定互斥体(它期望它已经被锁定)。然后,您可以仔细考虑在什么情况下可以调用该新函数以及在这些情况下数据的状态。
3.4 总结
在本章中,我讨论了在线程之间共享数据时,有问题的竞争场景可能会造成灾难性的后果,以及如何使用 std::mutex 和仔细的接口设计来避免它们。您已经看到,互斥锁并不是万能的,并且确实存在死锁的问题,虽然 C++ 标准库提供了 std::lock() 工具帮助避免这种情况。然后,您了解了一些避免死锁的进一步技术,然后简要介绍了锁所有权的转移以及有关选择适当的锁定粒度的问题。最后,我介绍了为特定场景提供的替代数据保护工具,例如 std::call_once() 和 boost::shared_mutex。
然而,我至今没有讨论的一件事是等待其他线程的输入。我们的线程安全栈只会在空栈时抛出异常,因此如果一个线程正在等待另一个线程将值压入栈(毕竟,这是线程安全栈的主要用途之一) ,它会不断尝试取出一个值,如果抛出异常则重试。这在执行检查时消耗了宝贵的处理时间,并且并没有取得任何进展;事实上,不断的检查可能会阻止系统中其他线程的运行并阻碍进程。线程需要某种方法来等待另一个线程完成任务,而不消耗进程中的 CPU 时间。第 4 章建立在我讨论过的用于保护共享数据的设施的基础上,并介绍了在 C++ 中同步线程之间操作的各种机制;第 6 章展示了如何使用它们来构建更大的可重用数据结构。
浙公网安备 33010602011771号