3.3 保护共享数据的方式

互斥量是一种通用的机制,但其并非保护共享数据的唯一方式。有很多方式可以在特定情况下,对共享数据提供合适的保护。

一个特别极端的情况就是,共享数据在并发访问和初始化时(都需要保护),需要进行隐式同步。这可能是因为数据作为只读方式创建,所以没有同步问题,或者因为必要的保护作为对数据操作的一部分。任何情况下,数据初始化后锁住一个互斥量,纯粹是为了保护其初始化过程,并且会给性能带来不必要的影响。

出于以上的原因,C++标准提供了一种纯粹保护共享数据初始化过程的机制。

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

假设有一个共享源,构建代价很昂贵,它可能会打开一个数据库连接或分配出很多的内存。

延迟初始化(Lazy initialization)在单线程代码很常见——每一个操作都需要先对源进行检查,为了了解数据是否被初始化,然后在其使用前决定,数据是否需要初始化:

  1. std::shared_ptr<some_resource> resource_ptr;
  2. void foo()
  3. {
  4. if(!resource_ptr)
  5. {
  6. resource_ptr.reset(new some_resource); // 1
  7. }
  8. resource_ptr->do_something();
  9. }

转为多线程代码时,只有①处需要保护,这样共享数据对于并发访问就是安全的。但是下面天真的转换会使得线程资源产生不必要的序列化,为了确定数据源已经初始化,每个线程必须等待互斥量。

代码3.11 使用延迟初始化(线程安全)的过程

  1. std::shared_ptr<some_resource> resource_ptr;
  2. std::mutex resource_mutex;
  3. void foo()
  4. {
  5. std::unique_lock<std::mutex> lk(resource_mutex); // 所有线程在此序列化
  6. if(!resource_ptr)
  7. {
  8. resource_ptr.reset(new some_resource); // 只有初始化过程需要保护
  9. }
  10. lk.unlock();
  11. resource_ptr->do_something();
  12. }

这段代码相当常见了,也足够表现出没必要的线程化问题,很多人能想出更好的一些的办法来做这件事,包括声名狼藉的“双重检查锁模式”:

  1. void undefined_behaviour_with_double_checked_locking()
  2. {
  3. if(!resource_ptr) // 1
  4. {
  5. std::lock_guard<std::mutex> lk(resource_mutex);
  6. if(!resource_ptr) // 2
  7. {
  8. resource_ptr.reset(new some_resource); // 3
  9. }
  10. }
  11. resource_ptr->do_something(); // 4
  12. }

指针第一次读取数据不需要获取锁①,并且只有在指针为空时才需要获取锁。然后,当获取锁之后,会再检查一次指针② (这就是双重检查的部分),避免另一线程在第一次检查后再做初始化,并且让当前线程获取锁。

这个模式为什么声名狼藉呢?因为有潜在的条件竞争。未被锁保护的读取操作①没有与其他线程里被锁保护的写入操作③进行同步,因此就会产生条件竞争,这个条件竞争不仅覆盖指针本身,还会影响到其指向的对象;即使一个线程知道另一个线程完成对指针进行写入,它可能没有看到新创建的some_resource实例,然后调用do_something()④后,得到不正确的结果。这个例子是在一种典型的条件竞争——数据竞争,C++标准中指定为“未定义行为”,这种竞争是可以避免的。阅读第5章时,那里有更多对内存模型的讨论,也包括数据竞争的构成。(译者注:著名的《C++和双重检查锁定模式(DCLP)的风险》可以作为补充材料供大家参考 英文版 中文版)

C++标准委员会也认为条件竞争的处理很重要,所以C++标准库提供了std::once_flagstd::call_once来处理这种情况。比起锁住互斥量并显式的检查指针,每个线程只需要使用std::call_once就可以,在std::call_once的结束时,就能安全的知晓指针已经被其他的线程初始化了。使用std::call_once比显式使用互斥量消耗的资源更少,特别是当初始化完成后。下面的例子展示了与代码3.11中的同样的操作,这里使用了std::call_once。这种情况下,初始化通过调用函数完成,这样的操作使用类中的函数操作符来实现同样很简单。如同大多数在标准库中的函数一样,或作为函数被调用,或作为参数被传递,std::call_once可以和任何函数或可调用对象一起使用。

  1. std::shared_ptr<some_resource> resource_ptr;
  2. std::once_flag resource_flag; // 1
  3. void init_resource()
  4. {
  5. resource_ptr.reset(new some_resource);
  6. }
  7. void foo()
  8. {
  9. std::call_once(resource_flag,init_resource); // 可以完整的进行一次初始化
  10. resource_ptr->do_something();
  11. }

这个例子中,std::once_flag①和初始化好的数据都是命名空间区域的对象,但std::call_once()可仅作为延迟初始化的类型成员,如同下面的例子一样:

代码3.12 使用std::call_once作为类成员的延迟初始化(线程安全)

  1. class X
  2. {
  3. private:
  4. connection_info connection_details;
  5. connection_handle connection;
  6. std::once_flag connection_init_flag;
  7. void open_connection()
  8. {
  9. connection=connection_manager.open(connection_details);
  10. }
  11. public:
  12. X(connection_info const& connection_details_):
  13. connection_details(connection_details_)
  14. {}
  15. void send_data(data_packet const& data) // 1
  16. {
  17. std::call_once(connection_init_flag,&X::open_connection,this); // 2
  18. connection.send_data(data);
  19. }
  20. data_packet receive_data() // 3
  21. {
  22. std::call_once(connection_init_flag,&X::open_connection,this); // 2
  23. return connection.receive_data();
  24. }
  25. };

例子中第一次调用send_data()①或receive_data()③的线程完成初始化过程。使用成员函数open_connection()去初始化数据,也需要将this指针传进去。和标准库中的函数一样,接受可调用对象,比如std::thread的构造函数和std::bind(),通过向std::call_once()②传递一个额外的参数来完成这个操作。

值得注意的是,std::mutexstd::once_flag的实例不能拷贝和移动,需要通过显式定义相应的成员函数,对这些类成员进行操作。

还有一种初始化过程中潜存着条件竞争:其中一个局部变量为static类型,这种变量的在声明后就已经完成初始化。对于多线程调用的函数,这就意味着这里有条件竞争——抢着去定义这个变量。很多在不支持C++11标准的编译器上,在实践过程中,这样的条件竞争是确实存在的,因为在多线程中,每个线程都认为他们是第一个初始化这个变量线程,或一个线程对变量进行初始化,而另外一个线程要使用这个变量时,初始化过程还没完成。在C++11标准中,这些问题都被解决了:初始化及定义完全在一个线程中发生,并且没有其他线程可在初始化完成前对其进行处理,条件竞争终止于初始化阶段,这样比在之后再去处理好的多。在只需要一个全局实例情况下,这里提供一个std::call_once的替代方案

  1. class my_class;
  2. my_class& get_my_class_instance()
  3. {
  4. static my_class instance; // 线程安全的初始化过程
  5. return instance;
  6. }

多线程可以安全的调用get_my_class_instance()①函数,不用为数据竞争而担心。

对于很少有更新的数据结构来说,只在初始化时保护数据。大多数情况下,这种数据结构是只读的,并且多线程对其并发的读取也是很愉快的,不过一旦数据结构需要更新就会产生竞争。

3.3.2 保护不常更新的数据结构

试想为了将域名解析为其相关IP地址,在缓存中的存放了一张DNS入口表。通常,给定DNS数目在很长的时间内保持不变。虽然,用户访问不同网站时,新的入口可能会被添加到表中,但是这些数据可能在其生命周期内保持不变。所以定期检查缓存中入口的有效性就变的十分重要。但也需要一次更新,也许这次更新只是对一些细节做了改动。

虽然更新频度很低,但也有可能发生,并且当缓存多个线程访问时,这个缓存就需要保护更新时状态的状态,也是为了确保每个线程读到都是有效数据。

没有使用专用数据结构时,这种方式是符合预期的,并为并发更新和读取进行了特别设计(更多的例子在第6和第7章中介绍)。这样的更新要求线程独占数据结构的访问权,直到更新操作完成。当完成更新时,数据结构对于并发多线程的访问又会是安全的。使用std::mutex来保护数据结构,感觉有些反应过度(因为在没有发生修改时,它将削减并发读取数据的可能性)。这里需要另一种不同的互斥量,这种互斥量常被称为“读者-作者锁”,因为其允许两种不同的使用方式:一个“作者”线程独占访问和共享访问,让多个“读者”线程并发访问。

C++17标准库提供了两种非常好的互斥量——std::shared_mutexstd::shared_timed_mutex。C++14只提供了std::shared_timed_mutex,并且在C++11中并未提供任何互斥量类型。如果还在用支持C++14标准之前的编译器,可以使用Boost库中的互斥量。std::shared_mutexstd::shared_timed_mutex的不同点在于,std::shared_timed_mutex支持更多的操作方式(参考4.3节),std::shared_mutex有更高的性能优势,但支持的操作较少。

第8章中会看到,这种锁的也不能包治百病,其性能依赖于参与其中的处理器数量,同样也与读者和作者线程的负载有关。为了确保增加复杂度后还能获得性能收益,目标系统上的代码性能就很重要。

比起使用std::mutex实例进行同步,不如使用std::shared_mutex来做同步。对于更新操作,可以使用std::lock_guard<std::shared_mutex>std::unique_lock<std::shared_mutex>上锁。作为std::mutex的替代方案,与std::mutex所做的一样,这就能保证更新线程的独占访问。那些无需修改数据结构的线程,可以使用std::shared_lock<std::shared_mutex>获取访问权。这种RAII类型模板是在C++14中的新特性,这与使用std::unique_lock一样,除了多线程可以同时获取同一个std::shared_mutex的共享锁。唯一的限制:当有线程拥有共享锁时,尝试获取独占锁的线程会被阻塞,直到所有其他线程放弃锁。当任一线程拥有一个独占锁时,其他线程就无法获得共享锁或独占锁,直到第一个线程放弃其拥有的锁。

如同之前描述的那样,下面的代码清单展示了一个简单的DNS缓存,使用std::map持有缓存数据,使用std::shared_mutex进行保护。

代码3.13 使用std::shared_mutex对数据结构进行保护

  1. #include <map>
  2. #include <string>
  3. #include <mutex>
  4. #include <shared_mutex>
  5. class dns_entry;
  6. class dns_cache
  7. {
  8. std::map<std::string,dns_entry> entries;
  9. mutable std::shared_mutex entry_mutex;
  10. public:
  11. dns_entry find_entry(std::string const& domain) const
  12. {
  13. std::shared_lock<std::shared_mutex> lk(entry_mutex); // 1
  14. std::map<std::string,dns_entry>::const_iterator const it=
  15. entries.find(domain);
  16. return (it==entries.end())?dns_entry():it->second;
  17. }
  18. void update_or_add_entry(std::string const& domain,
  19. dns_entry const& dns_details)
  20. {
  21. std::lock_guard<std::shared_mutex> lk(entry_mutex); // 2
  22. entries[domain]=dns_details;
  23. }
  24. };

代码3.13中,find_entry()使用std::shared_lock<>来保护共享和只读权限①。这就使得多线程可以同时调用find_entry(),且不会出错。另一方面,update_or_add_entry()使用std::lock_guard<>实例,当表格需要更新时②,为其提供独占访问权限。update_or_add_entry()函数调用时,独占锁会阻止其他线程对数据结构进行修改,并且阻止线程调用find_entry()。

3.3.3 嵌套锁

线程对已经获取的std::mutex(已经上锁)再次上锁是错误的,尝试这样做会导致未定义行为。在某些情况下,一个线程会尝试在释放一个互斥量前多次获取。因此,C++标准库提供了std::recursive_mutex类。除了可以在同一线程的单个实例上多次上锁,其他功能与std::mutex相同。其他线程对互斥量上锁前,当前线程必须释放拥有的所有锁,所以如果你调用lock()三次,也必须调用unlock()三次。正确使用std::lock_guard<std::recursive_mutex>std::unique_lock<std::recursive_mutex>可以帮你处理这些问题。

使用嵌套锁时,要对代码设计进行改动。嵌套锁一般用在可并发访问的类上,所以使用互斥量保护其成员数据。每个公共成员函数都会对互斥量上锁,然后完成对应的操作后再解锁互斥量。不过,有时成员函数会调用另一个成员函数,这种情况下,第二个成员函数也会试图锁住互斥量,这就会导致未定义行为的发生。“变通的”解决方案会将互斥量转为嵌套锁,第二个成员函数就能成功的进行上锁,并且函数能继续执行。

但是这种方式过于草率和不合理,所以不推荐这样的使用方式。特别是,对应类的不变量通常会被破坏。这意味着,当不变量被破坏时,第二个成员函数还需要继续执行。一个比较好的方式是,从中提取出一个函数作为类的私有成员,这个私有成员函数不会对互斥量进行上锁(调用前必须获得锁)。然后,需要仔细考虑一下,这种情况调用新函数时数据的状态。