目录
在锁定在恰当的粒度这篇博文里,博主分享了将共享保护数据锁定在恰当的粒度,但是有时候根本就没有一个合适的粒度级别,因为并非所有的对数据结构的访问都要求同样级别的保护,在这种情况下,使用替代机制来替代普通的std::mutex可能才是恰当的。
1、在初始化时保护共享数据
假设你有一个构造起来非常昂贵的共享资源,只有当实际需要时你才会使用。所以每个请求资源的操作首先检查它是否已经初始化(构造),如果没有则初始化之。
std::shared_ptr<some_resource> resource_ptr;
void foo()
{
if(!resource_ptr)
init(resource_ptr);
resource_ptr->do_something();
}
上面的代码显然是没法并发执行的。那是不是可以在检查以及初始化的时候使用互斥元呢?
std::shared_ptr<some_resource> resource_ptr;
std::mutex resource_mutex;
void foo()
{
std::unique_lock<std::mutex> my_lock(resource_mutex);
if(!resource_ptr)
init(resource_ptr);
my_lock.unlock();
resource_ptr->do_something();
}
但是这样的代码会引起一个等待序列化的问题,就是每个线程都必须等待互斥元,用以检查资源是否被初始化。比如第一个线程已经初始化过了资源,但是后续所有线程需要排队一次锁定互斥元来检查,这大大降低了多线程的性能。
下面介绍一种所谓地更好但又臭名昭著的一种方法:二次检查锁定模式(Double-Checked Locking)。
std::shared_ptr<some_resource> resource_ptr;
std::mutex resource_mutex;
void foo()
{
if(!resource_ptr) //①
{
std::unique_lock<std::mutex> my_lock(resource_mutex); //②
if(!resource_ptr)
init(resource_ptr); //③
my_lock.unlock();
}
resource_ptr->do_something();
}
Double-Checked Locking在不获取锁的情况下首次读取指针,当且仅当指针为NULL时才获取锁然后检查并初始化,为什么要再检查,因为别的线程有可能在当前线程执行①和②之间完成了资源初始化。Double-Checked Locking解决了我在上面说的序列化的问题。
但是这种模式会引起新的问题,即有可能在锁外部的读取与锁内部的另一线程的写入不同步,这就创造了一个竞争条件,不仅涵盖指针本身,还涵盖了指向的对象。就算一个线程看到另一个线程写入的指针,也可能无法看见新创建的实例,从而导致do_something()的调用在不正确的值上运行。c++标准称之为数据竞争,被定为未定义行为。
c++标准提供了新的解决办法,使用std::once_flag和std::call_once来处理这种情况。
std::shared_ptr<some_resource> resource_ptr;
std::once_flag resource_flag;
void init_resource()
{
init(resource_ptr);
}
void foo()
{
std::call_once(resource_flag,init_resource);
resource_ptr->do_something();
}
初始化只会调用一次,关于std::once_flag和std::call_once的用法不再赘述。
一个在初始化过程中可能会存在竞争条件的场景是将局部变量声明为static。static变量的初始化在其声明处发生。对于多线程而言,这意味着可能会有针对定义“首次”的竞争条件,多个线程均认为自己是第一个,发生多次初始化的问题,又或者线程可能在初始化已经在另一线程上启动但未完成的时候使用它。在c++11之前这样的确是有问题的,但是在c++11之后,这个问题得到解决,类似于once_flag,static的定义只能发生在一个线程里,竞争条件现在仅仅是哪一个线程去执行初始化。
对于需要单一全局实例的场合,这可以用作std::once_flag的替代品
class my_class;
my_class& get_my_class_instance()
{
static my_class instance;
return instance;
}
多个线程可以继续安全调用get_my_class_instance(),而不必担心初始化时的竞争问题
2、保护更少的更新数据
假设有一个用于存储DNS条目缓存的表,它用来将域名解析为相应的IP地址。通常,一个给定的DNS条目将在很长时间内保持不变,虽然更新很罕见,但它们仍然会发生,并且如果这个缓存可以从多个线程访问,它就需要在更新过程中进行适当的保护。
我们在想,有没有一种互斥元,这种互斥元支持两种不同的用法:由单个“写”线程独占访问,由多个“读”线程并发访问。
新的c++标准没有直接提供这样的互斥元,但是boost库提供了boost::shared_mutex。对于更新操作,std::lock_guard<boost::shared_mutex>和std::unique_lock<boost::shared_mutex>可用于锁定,以取代相应的std::mutex特化,这确保了独占访问(比如更新)。那些不需要更新数据结构的线程能够转而使用boost::share_lock<boost::shared_mutex>来获得共享访问(比如读操作)。如果任意一个线程拥有一个共享锁,试图获取独占锁的线程会被阻塞,直到其他线程全部撤回它们的锁,同样地,如果一个线程具有独占锁,其他线程都不能获取共享锁或独占锁,直到独占线程释放互斥元。
下面简单实现一下上述DNS条目缓存。
#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);
std::map<std::string,dns_entry>::const_iteratoe const it = entries.find(domain);
return (it == entries.end())?dns_entry():it->second;
}
dns_entry update_entry(std::string const& domain,dns_entry const& dns_details)
{
std::lock_guard<boost::shared_mutex> lk(entry_mutex);
entries[domain] = dns_details;
}
}
3、递归锁
在使用std::mutex的情况下,一个线程试图锁定其已经拥有的互斥元是错误的,并且试图这么做将导致未定义行为。然而,在某些情况下,线程多次重新获取同一个互斥元却无需事先释放它是可取的。为了这个目的,c++标准库提供了std::recursive_mutex。它就像std::mutex一样,区别在于你可以在同一线程中的单个实例上获取多个锁。详细介绍和用法参见博主的另一篇博文 std::recursive_mutex。