3.3 保护共享数据的其他方式

    一个特别极端(但十分常见)的情况就是,共享数据在并发访问和初始化时(都需要保护),但是之后需要进行隐式同步。这可能是因为数据作为只读方式创建,所以没有同步问题;或者因为必要的保护作为对数据操作的一部分。任何情况下,数据初始化后锁住一个互斥量,纯粹是为了保护其初始化过程(这是没有必要的),并且会给性能带来不必要的冲击。出于以上的原因,C++标准提供了一种纯粹保护共享数据初始化过程的机制。

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

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

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

    清单 3.11 使用一个互斥量的延迟初始化(线程安全)过程

    1. std::mutex resource_mutex;
    2. void foo()
    3. {
    4. std::unique_lock<std::mutex> lk(resource_mutex); // 所有线程在此序列化
    5. if(!resource_ptr)
    6. {
    7. }
    8. lk.unlock();
    9. resource_ptr->do_something();
    10. }

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

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

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

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

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

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

    例子中第一次调用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()①函数,不用为数据竞争而担心。

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

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

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

    C++17标准库提供了两种非常好的互斥量——std::shared_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对数据结构进行保护

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

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

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