《C++ concurrency in action》 读书笔记 -- Part 2 第三章 线程间的数据共享
唐风
《C++ concurreny in action》
第三章 sharing data between threads
3.1 线程间共享数据的“问题”
invariants 被破坏(比如说一个读一个写)
3.1.1 race conditions
条件竞争是:
In concurrency, a race condition is anything where the outcome depends on the relative ordering of execution of operations on two or more threads; the threads race to perform their respective operations.
多线程程序的主要问题之一: race condition
避免race condition的方法:
- 设计保持机制保证同一时刻只有一个线程在对数据做修改(以及修改时其它线程不会访问到它) => 使用mutex
- 使用 lock-free 的方式(这个在第7章)
3.2 C++ 标准中的 mutex
mutex(mutual exclusion)
mutex会是C++中最常用的数据保护手段,但它们也不是银弹,重要的是你要组织好你的代码以实现正确的(合适粒度的)数据保护,mutex也有自身的问题:比如死锁,或是保护过多或是过少(粒度不对会出现“同步保护失去意义”,或是系统的整体性能变差)。
mutex的基本操作:lock()锁定,unlock()解锁
为了避免在各种退出中unlock资源,C++提供了std::lock_guard这个模板,用RAII实现了“安全的解锁”
在std::lock_guard的构造函数中会lock,析构函数中会unlock
基本例子:
#include <list> #include <mutex> #include <algorithm> std::list<int> some_list; std::mutex some_mutex; void add_to_list(int new_value) { std::lock_guard<std::mutex> guard(some_mutex); some_list.push_back(new_value); } bool list_contains(int value_to_find) { std::lock_guard<std::mutex> guard(some_mutex); return std::find(some_list.begin(),some_list.end(),value_to_find) != some_list.end(); }
如果把数据封装好(成为private成员),在操作的时候lock,结束操作的时候unlock,那么一般来说数据就得到了比较好的保护。但是,这也还会有其它的问题:
Any code that has access to that pointer or reference can now access (and potentially modify) the protected data without locking the mutex.
要想做好数据保护,就必须仔细地设计API,以使得任何数据在被访问之前都能够锁定。
class some_data { int a; std::string b; public: void do_something(); }; class data_wrapper { private: some_data data; std::mutex m; public: template<typename Function> void process_data(Function func) { std::lock_guard<std::mutex> l(m); func(data); } }; some_data* unprotected; void malicious_function(some_data& protected_data) { unprotected=&protected_data; } data_wrapper x; void foo() { x.process_data(malicious_function); unprotected->do_something(); }
像这样一段代码,就很容易地刺穿了锁地保护。而这一切,C++无法帮到你,只有够程序员自己认真的设计程序的结构。一般来说应该尽量地遵守:
Don’t pass pointers and references to protected data outside the scope of the lock, whether by returning them from a function, storing them in externally visible memory, or passing them as arguments to user-supplied functions.
这个规则
3.2.3 Spotting race conditions inherent in interfaces
接口带来的race condistion。本节举了个stack例作为例子,
stack<int> s; if(!s.empty()) { int const value=s.top(); s.pop(); do_something(value); }
像这样一个简单常用的场景下就有多个线程间的race condition问题。(就算stack内部已经做了很好的保护了)
比如statck中只有一个元素了,两个线程都进行上面代码的操作的话,就有可能A线程调用完empty判断后,B也调用这个函数,结果两个线程都进入了if语句块,而pop两次。
除了这个问题之外,还有别的问题,比如有多个线程正在进行作业,由stack来保存所有的任务,每个线程完成一个工作后就从stack中去取一个新的任务,这样的话,如果出现下面这样的调度结果就会导致两个线程做了同一个任务,并且又丢掉一个任务谁都没有做。
所以对于接口本身存在的这种多线程可能导致的不安全性,必须要进行良好的设计和使用规范,才可能做到线程安全。
本书对pop这个函数的设计改进做了一些举例。
我:每种不同的场景都会有不同的对应方法,没有走遍天的一招鲜,主要还是要考虑:出现异常情况下的数据保护是否OK,连续的API调用对线程同步有什么要求,之类的
3.2.4 死锁(DeadLock)
死锁是多线程编程最大的问题之一。最简单的死锁就是两个线程相互等待对方释放资源,结果谁都得不到资源,僵死在那。
防止死锁最简单的手段就是:多个锁的锁定一定要按相同的顺序来。但这也不是包治百病的神药。Listing3.6做了说明,swap函数如果简单地实现为“按固定顺序对要交换的两个对象进行锁定,然后再交换这两个对象”的话,那么在不同线程中进行这个操作,如果给的参数顺序不一致,还是会引起死锁。C++给出了“同时锁定若干个锁”的函数:std::lock (all or nothing)
(A mutex that does permit multiple locks by the same thread is provided in the form of std::recursive_mutex. See section 3.3.3 for details.) )
class some_big_object; void swap(some_big_object& lhs,some_big_object& rhs); class X { private: some_big_object some_detail; std::mutex m; public: X(some_big_object const& sd):some_detail(sd){} friend void swap(X& lhs, X& rhs) { if(&lhs==&rhs) return; std::lock(lhs.m,rhs.m); std::lock_guard<std::mutex> lock_a(lhs.m,std::adopt_lock); std::lock_guard<std::mutex> lock_b(rhs.m,std::adopt_lock); swap(lhs.some_detail,rhs.some_detail); } };
std::lock(lhs.m,rhs.m); 可以同时锁定两个锁。
std::lock_guard<std::mutex> lock_a(lhs.m,std::adopt_lock); std::adopt_lock这个选项告诉lock_guard在构造的时候不要去锁定mutex,因为可能之前已经锁定了。
3.2.5 进阶的“防死锁”准则
除了mutex会造成死锁外,还有其它的可能性,比如线程相互等待对方结束,总而言之一句话,“不要相互等待”
a) 避免锁嵌套
-> 不要重复锁定一个锁
-> 用std::lock 去“同时”锁定多个锁
b) 避免调用 外部提供的带锁的函数
因为你不知道外部提供的函数是怎么操作锁的,所以很容易出问题,当然,写库或是一些很泛化的代码的时候无法避免地要调用外部提供的函数。这时就要一些制定一些准则进行遵守。
c) 以固定的顺序进行锁定
d) USE A LOCK HIERARCHY,软件上自己设计lock的层次。并以此来管理锁定关系。
3.2.6 std::unique_lock
它也可以在猎取mutex时不进行锁定,但比起前面的std::adopt选项来说,它的差别在于,
- unique_lock可以在之后再进行锁定(lock_guard的adpot之后就不再能去锁定了)??? 但unique_lock可以,所以在锁定时间点上更有弹性
- unique_lock可以传递mutex的所有权。
缺点:它更慢,也更大(因为内部要保存mutex是否已经锁定的状态)
使用例:
class X { private: some_big_object some_detail; std::mutex m; public: X(some_big_object const& sd):some_detail(sd){} friend void swap(X& lhs, X& rhs) { if(&lhs==&rhs) return; std::unique_lock<std::mutex> lock_a(lhs.m,std::defer_lock); std::unique_lock<std::mutex> lock_b(rhs.m,std::defer_lock); std::lock(lock_a,lock_b); swap(lhs.some_detail,rhs.some_detail); } };
3.2.7 传递mutext的所有权(用unique_lock)
这个很简单,例子如下:
std::unique_lock<std::mutex> get_lock() { extern std::mutex some_mutex; std::unique_lock<std::mutex> lk(some_mutex); prepare_data(); return lk; } void process_data() { std::unique_lock<std::mutex> lk(get_lock()); do_something(); }
3.2.8
注意锁的粒度(包括保护的数据的大小和只要必要的时候锁定)
以不必要的时间期间锁定mutex显示会带来性能影响(别的线程在等待)
3.3 其它的同步保护方法
3.3.1 :只在初始化时进行同步保护
有些数据只需要在初始化时候进行保护,初始化完成以后就不再需要同步保护了(比如已经变成只读了),这种情况下,如果不进行保护的话,会出现race condition,如果提供保护呢,其实除了第一次的锁定是必要的之外,之后的访问带来的加锁解锁除了增加了开销没有任何用处。
std::shared_ptr<some_resource> resource_ptr; std::mutex resource_mutex; void foo() { std::unique_lock<std::mutex> lk(resource_mutex); if(!resource_ptr) { resource_ptr.reset(new some_resource); } lk.unlock(); resource_ptr->do_something(); }
为了尽量的减少开销,人们还想了类似“double-check lock”的手法
void undefined_behaviour_with_double_checked_locking() { if(!resource_ptr) // (1) { std::lock_guard<std::mutex> lk(resource_mutex); if(!resource_ptr) { resource_ptr.reset(new some_resource); (2) } } resource_ptr->do_something(); // (3) }
但这也有问题,因为(1)和第(3)步之间没有同步保护的机制,所以可能在一个线程运行到第2)步使得resource_ptr不为空,但又没有完成初始化时,另一个线程过了(1)而直接调用了(3),但由于resource_ptr没有完全初始化成功,所以(3)的调用结果是未定义的,很可能是个错误值。
为了解决这两难的问题,C++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::once_flag可以保护只需要做一次的操作受到同步保护,而且在做完之后的其它情况下的访问不会有像mutex的lock那样“重的开销”,因为带来了平衡。
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(); } };
这个例子则表示了怎么用std::call_once和std::once_flag怎么对类成员进行保护。
另外:
class my_class; my_class& get_my_class_instance() { static my_class instance; return instance; }
这种用法,在C++11中可以保护my_class在第一个线程第一次调用时,完成初始化(而且是可以同步保护的!!在完成初始化之前其它的线程如果调用这个函数,会卡在这里)。这在C++11之前是不能保护线程安全的。
3.3.2 Protecting rarely updated data structures
还有一种数据,对这种数据极少进行写操作,大部分时间都是读操作,显然,在读操作的时候是不需要进行同步保护的,只有在有写操作时才需要进行锁定,避免多个写或是写时其它线程读出“不完整的值”。对于这种rarely updated的数据,如果用一般的mutex来进行保护,则比较“费”,因为读时的锁定保护是没有什么用处的。如果有一种锁,可以在“各线程都只进行了读操作的情况”下,不进行锁定,但一旦有一个线程进行写操作,则这个锁会保证“一旦我Lock了,就意味着我独占了数据,只到我unlock为止,谁也没办法再通过这个lock”的话,那么我们就又一次在“安全和高性能上”取得了平衡。
可惜的是,这个锁的提案在C++11中被否决了。
幸运的是,boost里有这个锁。
例子:
#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_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); entries[domain]=dns_details; } };
3.3.3 Recursive locking
mutex是不可以重复进行锁定的,而且一般来说一个好的设计也不会出现对锁进行重复lock的情况,但是,凡事有万一,万一你真的需要,那么就用:std::recursive_mutex,当然一定要记得,lock了几次就得unloc几次,否则别的线程就无法去占有资源了。
当然,这个std::recursive_mutex也是可以和mutex一样,放到
std::lock_guard<std::recursive_mutex>and std::unique_lock<std::recursive_mutex>中,利用RAII来进行解锁的。