3.1 线程之间共享数据的问题
线程之间数据共享问题,都是修改数据所导致的。在编写多线程的程序时,我们应该明确一个名词——“不变量”(不变量就是一定为真的的东西。比如文中举例的,队列头一定指向队首元素或者为空。它不可能指向队列中间的某个元素。数据元素包含的指针一定指向队列中的下个元素,或者为空,而不可能指向比如说,下下个元素。然而,程序有时为了方便,可能会临时的破坏这种规定,比如说,往队列中元素A后面插入元素的时候,就需要A指向新元素,然后新元素指向A原先指向的元素,这样,在新元素指向A原先指向的元素之前,这个不变量就被破坏了,因为A不是指向队列中下一个元素。)。在编写多线程程序时,注意“不变量”的概念有时可以帮助我们避免一些问题。
3.2 用互斥元保护共享数据
3.2.1 使用C++中的互斥元
在C++中,通过构造std::mutex的实例创建互斥元,调用成员函数lock()来进行锁定它,调用成员函数unlock()来解锁它。然而,在实际使用时,我们通过手动的来锁定和解锁是一件很麻烦的事情,如:在发生异常时,我们也要在异常代码中加入解锁的操作。所以在实际应用中,我们经常使用另一个类来代替std::mutex的使用——std::lock_guard()类模板,实现了互斥元的RAII管用语法。
#include<mutex> #include<list> #include<algorithm> using namespace std; list<int> some_list; mutex some_mutex; void add_to_list(int new_val){ lock_guard<mutex> some_lock_guard(some_mutex); some_list.push_back(new_val); } void list_contains(int val_to_find){ lock_guard<mutex> some_lock_guard(some_mutex); return find(some_list.begin(), some_list.end(), val_to_find) != some_list.end(); }
使用这种方法,可以将互斥元的锁定和解锁交给局部变量来解决,如果出现异常,我们没有进行相关锁的释放,该类的析构函数会帮我们完成这些操作。
3.2.2 为保护共享数据精心组织代码
有时我们想通过将数据结构封装成支持多线程的形式,在数据结构内部进行相关操作,虽然上面的方法看似解决了并发编程的问题,其中仍然有很多隐藏的问题,如:在使用类方法时通过返回一个共享变量有关的引用或者指向共享变量有关的指针我们很可能不经意间,通过这些共享变量有关的应用或者指向共享变量有关的指针仍然可以绕过设计的数据结构来进行更改该结构中的相关共享数据。
3.2.3 发现接口中固有的竞争条件
通过枚举stack类中的size()/empty()方法来说明这两个动态变换的函数——“非不变量”来说明C++标准库中的部分函数是具有竞争条件的,在使用的时候应该注意相关细节。
3.2.4 死锁:问题和解决方案
有些代码中由于具有多个锁,在使用的时候如果在操作变量A的时候先使用锁lock_A,然后再不释放的情况下使用lock_B,而变量B则是使用顺序相反,先使用lock_B,然后使用lock_A,有时会出现A获得lock_A的同时,B获得lock_B,再继续运行时A等待B释放lock_B,B同时等待着A释放lock_A,这样两者便形成了死锁。针对这个问题C++标准库提供了一种方法,std::lock()来进行同时锁定两个或者更多的互斥元。
class some_big_obj; void swap(some_big_obj& lhs, some_big_obj& rhs); class X{ private: some_big_obj some_detial; std::mutex m; public: X(some_big_obj const& sd): some_detial(sd){} friend void swap(X& lhs, X& rhs){ if(&lhs == &rhs) return; std::lock(lhs.m, rhs.m); lock_guard<mutex>(lhs.m, std::adopt_lock); lock_guard<mutex>(rhs.m, std::adopt_lock); swap(lhs.some_detial, rhs.some_detial); } };
使用这种方法可以避免一些死锁的出现。
3.2.5 避免死锁的进一步指南
1. 避免嵌套锁
在拥有一个所得前提下,尽量不要去申请另一个锁。
2.在持有锁时,应该避免调用用户提供的代码
在持有所得情况系,如果贸然调用用户提供的代码,很可能发生与第一条相违背的现象,出现嵌套锁。
3.以固定顺序获取锁
在需要获得多个锁的时候,我们可以设计好该线程中获得锁的先后顺序,即按照:A,B,C的先后顺序来获取锁,这样便可避免程序A程序获得lock_A,接着要获取lock_B,而同时线程B获取lock_B,接着要获取lock_A,这样线程A等待线程B释放lock_B,线程B等待线程A释放lock_A的死锁。
4.使用锁层次
可以为获取的锁添加一个层次,只有在高层次才能够获得低层次的锁,当有持有低层次的锁要获取高层次的锁的时候,便不能获取。
5.将这些设计扩展到锁之外
有的时候死锁可能会出现在多个线程的循环中等待数据的同步时出现。
3.2.6 使用std::unique_lock灵活锁定
在使用只是出于加锁的情况时,我感觉尽量使用lock_guard,因为std::unique_lock由于占用更多的时间相对于lock_guard略慢,std::unique_lock还可以与condition_variable(条件变量)用于其他情况(如线程池等)[详细原因在第4章中介绍]。
3.2.7 在作用域之间转移锁的所有权
由于std::unique_lock实例没有与其相关的互斥元,所以通过四处移动实例可以进行转移互斥元的所有权。
3.2.8 锁定在恰当的粒度
由于在多线程中,由于其中一个获得互斥锁,导致其他线程需要等待该锁的释放,这是一件十分浪费时间的事情,所以在进行加锁之后应该注意在适当的场合及时释放互斥锁。
3.3 用于共享数据的保护的替代工具
3.3.1 在初始化时保护共享数据
在变量初始化时我们可能会用到如下方法来进行
void foo(){ if(ptr == nullptr) ptr = new obj(); ptr->method(); }
这在单线程中是可以安全工作的,但是,当用到多线程时就会出现一些问题,所以我们会很自然的添加一些锁来解决上述问题,如
mutex resource_mutex; void foo(){ unique_lock<mutex> lk(resource_mutex);//每个线程在此处都避免不了产生一个unique_lock的实例化对象。 if(ptr == nullptr) ptr = new obj(); lk.unlock(); ptr->method(); }
这种代码仍然会存在问题,会产生不必要的序列化问题。所以有些人发展出二次检查锁定模式,如
mutex resource_mutex; void foo(){ if(ptr == nullptr){ unique_lock<mutex> lk(resource_mutex); if(ptr == nullptr) ptr = new obj(); } lk.unlock(); ptr->method(); }
上面的代码看上去解决了很大的问题,但是由于可以在锁的外部读取该指针,造成与锁内部有另一线程完成的写入不同步。所以再后来C++标准库提供了std::call_once和std::once_flag来处理这种情况,其中
std::call_once:准确执行一次可调用 (Callable) 对象 f
,即使同时从多个线程调用。
std::once_flag:构造 once_flag
对象。设置内部状态为指示尚未调用函数。
once_flag resource_flag; void init(){ ptr = new obj(); } void foo(){ call_once(resource_flag, init); ptr->method(); }
3.3.2 保护很少更新的数据结构
有时我们的并发程序中出现很长时间不会更新的数据结构,这样采用传统的锁的方式难免产生性能问题,对于要更改数据结构时我们可以使用std::unique<boost::shared_mutex>和std::lock_guard<boost::shared_mutex>来更改,对于只读的在boost库中由boost::unique_lock<boost::shared_mutex>可以实现共享访问,但是当有个共享锁要转化为独占锁时,会等待所有的共享锁都释放之后才会转换成功,当存在一个占有独占锁的线程时,其他线程要获得共享锁或者独占锁,都需要等待该独占锁释放之后才能进行。
3.3.3 递归锁
有时候我们需要在使用相乘多次重新获得同一个互斥元却无需提前释放它,这是我们可以使用C++标准库中的递归锁,std::recursive_mutex。调用多少次递归锁,同样也需要释放多少次。