内容概要:
- 线程间共享数据的问题
- 利用互斥保护共享数据
- 利用其他工具保护共享数据
1 线程间共享数据的问题
多线程共享数据的问题多由数据改动引发。
为了帮助分析,“不变量”(invariant)被广泛使用,它是一个针对某一特定数据的断言,该断言总是成立的,例如"这个变量的值即为链表元素的数目"。数据更新往往会破坏这些不变量,尤其是涉及的数据结构稍微复杂,或者数据更新需要改动的值不止一个时。
考察双向链表,其每个节点都持有两个指针,分别指向自身前方(previous)和后方(next)的节点。故此,存在一个不变量:若我们从节点A的next指针到达另一个节点B,则B的previous指针应该指向节点A。为了从双向列表中删除某个节点,它两侧相邻节点的指针都要更新,指向彼此。只要其中一个相邻节点先行更新,不变量即别被破坏,直到另一个节点也更新。更新的全部操作完成后,不变量又重新成立。
从双向链表删除一个节点的步骤:
- 步骤a:识别需要删除的节点,称之为N。
- 步骤b:更新前驱节点中本来指向N的指针,令其改为指向N的后继节点。
- 步骤c:更新后继节点中本来指向N的指针,令其改为指向N的前驱节点。
- 步骤d:删除节点N。
在步骤b和步骤c之间,正向指针和逆向指针不一致,不变量被破坏。
改动线程间的共享数据,可能导致的最简单的问题是破坏不变量。
如某线程正在读取双向链表,另一线程同时在删除节点,而我们没有采取专门的措施保护不变量,那么执行读取操作的线程很可能遇见未被完全删除的节点,不变量遂被破坏。
1.1 条件竞争
在并发编程中,操作由两个或多个线程负责,它们争先让线程执行各自的操作,而结果取决于它们执行的相对次序,所有这种情况都是条件竞争。很多时候,这是良性行为,因为全部可能的结果都可以接受,即便线程变换了相对次序。例如,两个线程向队列添加数据项以待处理,只要维持住系统的不变量,先添加哪个数据项通常并不重要。
C++标准还定义了术语"数据竞争"(data race):并发改动单个对象而形成的特定的条件竞争。
诱发恶性条件竞争的典型场景是,要完成一项操作,却需改动两份或多份不同的数据。因为操作涉及两份独立的数据,而它们只能用单独的指令改动,当其中一份数据完成改动时,别的线程有可能不期而妨。因为满足条件的时间窗口短小,所以条件竞争往往既难以察觉又难复现。
若改动操作是由连续不间断的CPU指令完成的,就不太有机会在任何的单次运行中引发问题,即使其他线程正在并发访问数据。只有按某些次序执行指令才可能引发问题,随着系统负载加重及执行操作的次数增多,这种次序出现的机会也将增加。
1.2 防止恶性条件竞争
防止恶性条件竞争的方法有多种:
-
最简单的就是采取保护错误包装数据结构,确保不变量被破坏时,中间状态只对执行改动的线程可见。在其他访问同一数据结构的线程的视角中,这种改动要么尚未开始。
-
还有就是,修改数据结构的设计及其不变量,由一连串不可拆分的改动完成数据变更,每个改动都维持不变量不被破坏。
这通常被称为无锁编程,难以正确编写。如过从事这一层面的开发工作,就要探究内存模型的微妙细节,以及区分每个线程都可以看到什么数据集,两者都可能很复杂。
-
将修改数据结构当作事务(transaction)来处理,类似于数据库在一个事务内完成更新:
把需要执行的数据读写操作视为一个完整序列,先用事务日志存储记录,再把序列当成单一步骤提交运行。
若别的线程改动了数据而令提交无法完整执行,则事务重新开始。这称为软件事务内存(Software Transactional Memory,STM)。
2 用互斥保护共享数据
访问一个数据结构前,先锁住与数据相关的互斥;访问结束后,再解锁互斥。C++线程库保证了,一旦有线程锁住了某个互斥,若其他线程试图再给它加锁,则须等待,直至最初成功加锁的线程把该互斥解锁。这确保了全部线程所见到的共享数据是自洽的(self-consistent),不变量没有被破坏。
互斥是C++最通用的共享数据保护措施之一,但非万能的灵丹妙药。务必妥善地组织和编排代码,从而正确地保护共享数据,同时避免接口固有的条件竞争。
互斥本身也有问题,表现形式是:
- 死锁
- 对数据的过保护或欠保护
2.1 在C++中使用互斥
通过构造std::mutex的实例来创建互斥,调用成员函数lock()对其加锁,调用unlock()解锁。但是若直接调用成员函数的做法,那我们就必须记住,在函数以外的每条代码路径上都要调用unlock(),包括由于异常导致退出的路径。
C++标准库提供了类模板std::lock_guard<>,针对互斥类融合实现了RAII手法:在构造时给互斥加锁,在析构时解锁,从而保证互斥总被正确解锁。
如下代码,演示了用互斥保护链表
#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();
}
上述代码,有一个独立的全局变量(1),由对应的std::mutex实例保护(另一个全局变量)(2)。函数add_to_list() (3)和函数list_contains() (4)内部都使用了std::lock_guard<std::mutex>,其意义是使这两个函数对链表的访问互斥:假定add_to_list()正在改动链表,若操作进行到一半,则list_contians()绝对看不见该状态。
C++17引入了一个新特性,名为类模板参数推导(class template argument deduction),对于std::lock_guard<>这种简单的类模板,模板参数列表可以忽略。如果编译器支持C++17标准,则
std::lock_guard<std::mutex> guard(some_mutex);
能简化为
std::lock_guard guard(some_mutex);
对于大多数场景下的普遍做法是,将互斥与受保护的数据组成一个类。这是面向对象设计准则的典型运用:将两者放在同一个类里,我们清楚表明它们互相联系,还能封装函数以增加保护。
但需要注意:
如果成员函数返回指针或引用,指向受保护的共享数据,那么即便成员函数全部按良好、有序的方式锁定互斥,也无济于事,因为保护已经被打破,出现了大漏洞。只要存在任何能访问该指针和引用的代码,它就可以访问受保护的共享数据(也可以修改),而无须锁定互斥。
所以,若利用互斥保护共享数据,则需谨慎设计程序接口,从而确保互斥已先行确定,再对受保护的共享数据进行访问,并保证不留后门。
2.2 组合和编排代码以保护共享数据
危险情况:
- 成员函数通过各种"形式"——无论是返回值,还是输出参数(out parameter)——向调用者返回指针或引用,指向受保护的共享数据,就会危及共享数据安全。
- 成员函数在自身内部调用了别的函数,而这些函数却不受我们掌控,那么,也不得向它们传递这些指针或引用。这些函数有可能将指针或引用暂存别处,等到以后脱离了互斥保护再投入使用。
如下代码,演示了意外地向外传递引用,指向受保护共享数据
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(); //⑶ 以无保护方式访问本应受保护的共享数据
}
在本例中,process_data()函数内的代码显然没有问题,由std::lock_guard很好地保护,然而,其参数func是使用者提供的函数(1)。换言之,foo()中的代码可以传入malicious_function (2),接着,就能绕过保护,调用do_something(),而没有锁住互斥(3)。
无奈,C++线程库对这个问题无能为了;只有靠我们自己——程序员——正确地锁定互斥,借此保护共享数据。
但只要遵循下列指引即可应对上述情形:不得向锁所在的作用域之外传递指针和引用,指向受保护的共享数据,无论是通过函数返回值将它们保存到对外可见的内存,还是将它们作为参数传递给使用者提供的函数。
2.3 发现接口固有的条件竞争
std::stack有5种操作:
- 用
push()压入新元素 - 用
pop()弹出栈顶元素 - 用
top()读取栈顶元素 - 用
empty()检测栈是否为空 - 用
size()读取栈内元素的数量
容器接口如下:
template<typename T,typename Container=std::deque<T>>
class stack
{
public:
explicit stack(const Container&);
explicit stack(Container&& = Container());
template <class Alloc> explicit stack(const Alloc&);
template <class Alloc> stack(const Container&,const Alloc&);
template <class Alloc> stack(Container&&,const Alloc&);
template <class Alloc> stack(stack&&,const Alloc&);
bool empty() const;
size_t size() const;
T& top();
T const& top() const;
void push(T const&);
void push(T&&);
void pop();
void swap(stack&&);
template <class... Args> void emplace(Args&&.. args); //⑴C++14的新特性
};
这里的问题是,empty()和size()的结果不可信。尽管,在某线程调用empty()或size()时,返回值可能是正确的。然而,一旦函数返回,其他线程就不再受限,从而能自由的访问栈容器,可能马上有新元素入栈,或者,现有的元素立刻出栈,令前面的线程得到的结果失效而无法使用。
如:
stack<int> s;
if(!s.empty())
{
int const value = s.top();
s.pop();
do_something(value);
}
对单线程而言,它既安全,又符合预期。可是,只要涉及共享,这一连串调用便不再安全。因为,在empty()和top()之间,可能有另一个线程调用pop(),弹出栈顶元素。毫无疑问,这正是典型的条件竞争。它的根本原因在于函数接口,即使在内部使用互斥保护栈容器中的元素,也无法防范。
这要求从根本上更改接口设计,其中一种改法是把top()和pop()组成一个成员函数,再采取互斥保护。对于栈容器中存储的对象,若其拷贝构造函数抛出异常,则可能导致该合成的成员函数出现问题。
我们可以假设pop()函数的定义是:返回栈顶元素的值,并从栈上将其移除。隐患由此而来:只有在栈被改动之后,弹出的元素才返回给调用者,然而在向调用者赋值数据的过程中,有可能抛出异常。万一弹出的元素已从栈上移除,但复制却不成功,就会令数据丢失!
2.3.1 消除条件竞争的方法
-
方法1:传入引用
借一个外部变量接收栈容器弹出的元素,将指涉它的引用通过参数传入
pop()调用。std::vector<int> result; some_stack.pop(result);这在许多情况下行之有效,但还是有明显的短处:
- 要调用
pop(),则须先依据栈容器中的元素型别构造一个实例,将其充当接收目标传入函数内。对于某些型别,构建实例的时间代价高昂或耗费资源过多,所以不太实用。 - 一些型别的栈元素,其构造函数不一定带有参数,故此法也并不总是可行。
- 还要求栈容器存储的型别是可赋值的(assignable)。该限制不可忽略:许多用户定义的型别并不支持赋值,尽管它们支持移动构造甚至拷贝构造(准许pop()按值返回)。
- 要调用
-
方法2:提供不抛出异常的拷贝构造函数,或不抛出异常的移动构造函数
假设
pop()按值返回,若它抛出异常,则牵涉异常安全的问题只是会在这里出现。许多型别都含有不抛出异常的拷贝构造函数。另外,随着C++标准新近引入的右值引用得到支持,更多型别将具备不抛出异常的移动构造函数(即便它们的拷贝构造函数会抛出异常)。
这虽安全,效果却不理想。尽管我们只要利用
std::is_nothrow_copy_constructible和std::is_nothrow_move_constructible这两个型别特征(type trait),便可在编译期对某个型别进行判断,确定它是否含有不抛出异常的拷贝构造函数,以及是否含有不抛出异常的移动构造函数。然而栈容器的用途还是会很受限,就用户定义的型别而言,其中一些含有会抛出异常的拷贝构造函数,却不含移动构造函数,另一些则含有拷贝构造函数或移动构造函数,而且不抛出异常。相比之下,前者数量更多。
-
方法3:返回指针,指向弹出的元素
返回指向元素的指针,而不是返回它的值。
优点是:指针可以自由地复制,不会抛出异常。
缺点是:在返回的指针所指向的内存中,分配的目标千差万别,既可能是复杂的对象,也可能是简单的型别,如int,这就要求我们采取措施管理内存,但这便构成了额外的负担,也许会产生超过按值返回相应型别的开销。
若函数接口采用此方法,则指针型别
std::shared_ptr是不错的选择:首先,它避免了内存泄漏,因为只要该指针的最后一份副本被销毁,被指向的对象也随之被释放;其次,内存分配策略由C++标准库全权管控,调用者不必亲自操作new和delete操作符,否则我们只能用new操作符,为栈上每个元素逐一分配内存。 -
方法4:结合方法1和方法2,或结合方法1和方法3
代码绝不能丧失灵活性,泛型代码更应如此。若我们选定了方法2或方法3,那么再一并提供方法1便相对容易。
类定义实例,线程安全的栈容器类,消除了接口的条件竞争,其成员函数pop()具有两份重载,分别实现了方法1和方法3:一份接收引用参数,指向某外部变量的地址,存储弹出的值;另一份返回std::shared_ptr<>。
#include <exception>
#include <memory>
struct empty_stack:std::exception
{
const char* what() const throw();
}
template<typename T>
class threadsafe_stack
{
private:
std::stack<T> data;
mutable std::mutex m;
public:
threadsafe_stack(){}
threadsafe_stack(const threadsafe_stack& other)
{
std::lock_guard<std::mutex> lock(other.m);
data=other.data;
}
threadsafe_stack& operator=(const threadsafe_stack&) = delete;
void push(T new_value)
{
std::lock_guard<std::mutex> lock(m);
data.push(std::move(new_value));
}
std::shared_ptr<T> pop()
{
std::lock_guard<std::mutex> lock(m);
if(data.empty()) throw empty_stack();
std::shared_ptr<T> const res(std::make_shared<T>(data.top()));
data.pop();
return res;
}
void pop(T& value)
{
std::lock_guard<std::mutex> lock(m);
if(data.empty) throw empty_stack();
value = data.top();
data.pop();
}
bool empty() const
{
std::lock_guard<std::mutex> lock(m);
return data.empty();
}
};
简化接口换来了最大的安全保证,甚至还能限制栈容器的整体操作。
- 由于赋值运算符被删除,因此栈容器本身不可赋值。
- 它也不具备swap()函数,但它可被复制(前提是所装载的元素能被复制)。
- 在空栈上调用
pop()会抛出empty_stack异常。所以,即使栈容器在调用empty()之后被改动,一切仍能正常工作。
这个栈容器的实现是可复制的,拷贝构造函数先在源对象上锁住互斥,再复制内部的std::stack。没有采用成员初始化列表(member initializer list),而是在构造函数的函数体内进行复制操作,从而保证互斥的锁定会横跨整个复制过程。
2.3.2 锁定颗粒度问题
锁的粒度太大,互斥也可能出现问题,极端的情形是,用单一的全局互斥保护所有共享。在共享大量数据的系统中,这么做会消除并发带来的任何性能优势,原因是多线程系统由此受到强制限定,任意时刻都只准许运行其中一个线程,即便它们访问不同的数据。
锁的粒度太小,需要被保护的操作没有被完全覆盖,因此出现接口的恶性条件竞争。
精细粒度的加锁策略也存在问题。为了保护同一个操作涉及的所有数据,我们有时需要锁住多个互斥。在某些场景下的正确做法是,在互斥的保护范围内增大数据粒度,从而只需锁定一个互斥。然而这种处理方式也可能不合时宜,例如,若要保护同一个类的多个独立的实例,则应该分别使用多个互斥。
假如我们终须对某项操作锁住多个互斥,那就让一个问题藏匿起来,伺机进行扰乱:死锁,两个线程同时互相等待,停滞不前。
2.4 死锁:问题和解决方法
有两个线程,都需要同时锁住两个互斥,才可以进行某项操作,但它们分别都只锁住了一个互斥,都等着再给一个互斥加锁。于是,双方毫无进展,因为它们同在苦苦等待对方解锁互斥。上述情形称为死锁(deadlock)。
防范死锁的建议通常是,始终按相同顺序对两个互斥加锁。若我们总是先锁定互斥A再锁互斥B,则永远也不会发生死锁。
有时候,这直观、易懂,因为诸多互斥的用途各异,也会出现棘手的状况,例如,运用多个互斥分别保护多个独立的实例,这些实例属于同一个类。考虑一个函数,其操作同一个类的两个实例,互相交换它们的内部数据。为了保证互换正确完成,免受并发改动的不良影响,两个实例上的互斥都必须加锁。
可是,如果选用了固定的次序(两个对象通过参数传入,我们总是先给第一个实例的互斥加锁,再轮到第二个实例的互斥),前面的建议就适得其反:针对两个相同的实例,若两个线程都通过该函数在它们之间互换数据,只是两次调用的参数顺序相反,会导致它们陷入死锁!
即线程甲调用swap(A,B),线程乙调用swap(B,A)。请注意,这里重复进行互换操作,并不符合逻辑,因为会使两个实例最终保持原样。
2.4.1 std::lock()
C++标准库提供了std::lock()函数,可以同时锁住多个互斥,而没有发生死锁的风险。
class some_big_object;
void swap(some_big_object& lhs,some_big_object& rhs);
class
{
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::mutex对象上获取锁,那么再次试图从该互斥获取锁将导致未定义行为(std::recursive_mutex类型的互斥准许统一线程重复加锁)。
接着,代码调用std::lock()锁定两个互斥(1),并依据它们分别构造std::lock_guard实例(2)(3)。用互斥充当这两个实例的构造参数,还额外提供了std::adopt_lock对象,以指明互斥已经被锁住,即互斥上有锁存在,std::lock_guard实例应当据此接收锁的归属权,不得在构造函数内试图另行加锁。
无论函数是正常返回,还是因受保护的操作抛出异常而导致退出,std::lock_guard都保证了互斥全部正确解锁。
std::lock在其内部对lhs.m或rhs.m加锁,这一函数调用可能导致抛出异常,这样,异常便会从std::lock()向外传播。假如std::lock()函数在其中一个互斥上成功获取了锁,但它试图在另一个互斥上获取锁时却有异常抛出,那么第一个锁就会自动释放:若加锁操作涉及多个互斥,则std::lock()函数的语义是"全员共同成败"(all-or-nothing,或全部成功锁定,或没获取任何锁并抛出异常)。
2.4.2 std::scoped_lock
C++17还进一步提供了新的RAII类模板std::scoped_lock<>。std::scoped_lock<>和std::lock_guard<>完全等价,只不过前者是可变参数模板(variadic template),接收各种互斥型别作为模板参数列表,还以多个互斥对象作为构造函数的参数列表。
当构造函数完成时,传入的锁就都被锁定,而后,在析构函数内一起被解锁。
void swap(X& lhs,X& rhs)
{
if(&lhs == &rhs)
return;
std::scoped_lock guard(lhs.m,rhs.m);
swap(lhs.some_detail,rhs.some_detail);
}
上例利用了C++17加入的另一个特性:类模板参数推导。
std::scoped_lock guard(lhs.m,rhs.m);
等价于
std::scoped_lock<std::mutex,std::mutex> guard(lhs.m,rhs.m);
假定我们需要同时获取多个锁,那么std::lock()函数和std::scoped_lock<>模板即可帮助防范死锁;但若代码分别获取各个锁,它们就鞭长莫及了。
2.5 防范死锁的补充准则
虽然死锁的最常见诱因之一是锁操作,但即使没有牵涉锁,也会发生死锁现象。假定有两个线程,各自关联了std::thread实例,若它们同时在对方的std::thread实例上调用join(),就能制造出死锁线程却不涉及锁操作。
防范死锁的准则最终可归纳成一个思想:只要另一线程有可能正在等待当前线程,那么当前线程千万不要反过来等待它。
下列准则的细分条目给出了各种方法,用于判别和排除其他线程是否正在等待当前线程。
2.5.1 避免嵌套锁
第一条准则最简单:假如已经持有锁,就不要试图获取第二个锁。若能恪守这点,每个线程便最多只能持有唯一一个锁,仅锁的使用本身不可能导致死锁。
但是还存在其他肯可能引起死锁的场景(譬如,多个线程彼此等待),而操作多个互斥锁很可能就是最常见的死锁诱因。万一确有需要获取多个锁,我们应该采用std::lock()函数,借单独的调用动作一次获取全部锁来避免死锁。
2.5.2 一旦持锁,就须避免调用由用户提供的程序接口
若程序接口由用户自行实现,则我们无从得知它到底会做什么,它可能会随意操作,包括试图获取锁。
一旦我们已经持锁,若再调用由用户提供的程序接口,而它恰好也要获取锁,那便违反了避免嵌套锁的准则,可能发生死锁。
不过,有时这实在难以避免,对于类似上述栈容器的泛型代码,只要其操作与模板参数的型别相关,它就不得不依赖用户提供的程序接口。因此我们需要另一条新的准则。
2.5.3 依从固定顺序获取锁
如果多个锁是绝对必要的,却无法通过std::lock()在一步操作中全部获取,我们只能退而求其次,在每个线程内部都依从固定顺序获取这些锁;事先规定好加锁顺序,令所有线程都依从。
-
以栈容器为例,互斥在栈容器的实例内部发挥作用,但假如操作涉及栈容器存放的元素,就须调用由用户提供的接口。然而也可以约束栈容器存放的元素:任何针对元素所执行的操作,均不得牵扯到栈容器本身。该限制给栈容器的使用者造成了负担。不过,栈容器中存储的元素极少会访问栈容器本身,万一发生访问,我们也能明显察觉,故这种负担并非难以承受。
-
以互换操作为例,虽然这种方式不一定总是可行,但在该情况下,我们至少可以同时对两个互斥加锁。
-
回顾双向链表,可发现一种可行的方法:给每个节点都配备互斥来保护链表。
因此,线程为了访问链表,须对涉及的每个节点加锁。就执行删除操作的线程而言,它必须在3个节点上获取锁,即要被删除的目标节点和两侧的相邻节点,因为它们全都会在不同程度上被改动。
类似地,若要遍历链表,线程必须持有当前节点的锁,同时在后续节点上获取锁,从而确保前向指针不被改动。一旦获取了后续节点上的锁,当前节点的锁便再无必要,遂可释放。
按照这种方式,链表容许多个线程一起访问,前提是它们不会同时访问同一个节点。不过,节点必须依从相同的锁定顺序以预防死锁:假如两个线程从相反方向遍历链表,并采用交替前进的加锁方式,它们就会在途中互相死锁。
假定A和B是链表中的两个相邻节点,正向遍历的线程会先尝试持有节点A的锁,接着再从节点B上获取锁。逆向遍历的线程则持有节点B的锁,并试图从节点A上获取锁,这会导致出现经典场景,发生死锁!
此处有一个方法可防范死锁,规定遍历的方向。从而令线程总是必须先锁住A,再锁住B,最后锁住C。
2.5.4 按层级加锁
锁的层级划分就是按特定方式规定加锁次序,在运行期据此查验加锁操作是否遵从预设规则。按照构思,我们把应用程序分层,并且明确每个互斥位于哪个层级。若某线程已对低层级互斥加锁,则不准它再对高层级互斥加锁。
具体做法是将层级的编号赋予 对应 层级 应用程序上的互斥,并记录各线程分别锁定了哪些互斥。这种模式虽然常见,但C++标准库未提供直接支持,故我们需自行编写定制的互斥型别hierarchical_mutex。
hierarchical_mutex high_level_mutex(10000); //(1)
hierarchical_mutex low_level_mutex(5000); //(2)
hierarchical_mutex other_mutex(6000); //(3)
int do_low_level_stuff();
int low_level_func()
{
std::lock_guard<hierarchical_mutex> lk(low_level_mutex); //(4)
return do_low_level_stuff();
}
void high_level_stuff(int some_param);
void high_level_func()
{
std::lock_guard<hierarchical_mutex> lk(high_level_mutex); //(6)
high_level_stuff(low_level_func()); //(5)
}
void thread_a() //(7)
{
high_level_func();
}
void do_other_stuff();
void other_stuff()
{
high_level_func(); //(10)
do_other_stuff();
}
void thread_b() //(8)
{
std::lock_guard<hierarchical_mutex> lk(other_mutex); //(9)
other_stuff();
}
3个hierarchical_mutex实例(1)(2)(3),它们依据相应的层级编号而构造(层级越低编号越小)。这套机制旨在设定加锁的规则,如果我们已在某hierarchical_mutex互斥上持有锁,那么只能由相对低层级的hierarchical_mutex互斥获取锁,从而限制代码的行为。
我们在这里假定,do_low_level_stuff()函数没有锁住任何互斥,而low_level_func()函数处于最低层级(4),且锁住了互斥low_level_mutex。high_level_func()函数先对互斥high_level_mutex加锁(6),随后调用low_level_func() (5),这两步操作符合加锁规则,因为互斥high_level_mutex (1)所在的层级(10000)高于low_level_mutex (2)所在的层级(5000)。
thread_a()同样遵循规则(7),所以运行无碍。
相反地,thread_b()无视规则(8),因此在运行期出错。它先对互斥other_mutex加锁(9),其层级编号是6000(3),说明该互斥位于中间层级。在other_stuff()函数调用high_level_func() (10)之间,就违反了层级规则:high_level_func()试图在互斥high_level_mutex上获取锁,但其层级编号却是10000,远高于当前层级编号6000。结果,互斥hierarchical_mutex会报错,可能抛出异常,也可能中止程序。
层级互斥之间不可能发生死锁,因为互斥自身已经被强制限定了加锁次序。只要两个层级锁都位于相同层级,我们便无法一并持有。
若将层级锁应用于前文交替前进的加锁策略,那么链表中每个互斥的层级须低于其前驱节点互斥的层级。可是,这种方式在某些情况下并不可行。
为了存储当前层级编号,hierarchical_mutex的实现使用了线程专属的局部变量。所有互斥的实例都能读取该变量,但它的值因不同线程而异。这使代码可以独立检测各线程的行为,各互斥都能判断是否允许当前线程对其加锁。
class hierarchical_mutex
{
std::mutex internal_mutex;
unsigned long const hierarchy_value;
unsigned long previous_hierarchy_value;
static thread_local unsigned long this_thread_hierarchy_value; //(1)
void check_for_hierarchy_violation()
{
if(this_thread_hierarchy_value <= hierarchy_value) //(2)
{
throw std::logic_error("mutex hierarchy violated");
}
}
void update_hierarchy_value()
{
previous_hierarchy_value = this_thread_hierarchy_value; //(3)
this_thread_hierarchy_value = hierarchy_value;
}
public:
explicit hierarchical_mutex(unsigned long value):
hierarchy_value(value),
previous_hierarchy_value(0)
{}
void lock()
{
check_for_hierarchy_violation();
internal_mutex.lock(); //(4)
update_hierarchy_value(); //(5)
}
void unlock()
{
if(this_thread_hierarchy_value != hierarchy_value)
throw std::logic_error("mutex hierarchy violated"); //(9)
this_thread_hierarchy_value = previous_hierarchy_value; //(6)
internal_mutex.unlock();
}
bool try_lock()
{
check_for_hierarchy_violation();
if(!internal_mutex.try_lock()) //(7)
return false;
update_hierarchy_value();
return true;
}
};
//(8)
thread_local unsigned long hierarchical_mutex::this_thread_hierarchy_value(ULONG_MAX);
使用线程专属的变量(名为 this_thread_hierarchy_value,以关键字 thread_local 修饰)表示当前线程的层级编号(1)。它初始化为unsigned long可表示的最大值(8)。
线程自身不属于任何层级。
this_thread_hierarchy_value的准确意义是,当前线程最后一次加锁操作所涉及的层级编号。
-
任意
hierarchical_mutex互斥都能被加锁。因为声明由thread_local修饰,每个线程都具有自己的this_thread_hierarchical_value副本,所以该变量在某线程上的值与另一线程上的值完全无关。 -
某线程第一次锁住
hierachical_mutex的某个实例时,变量this_thread_hierarchy_value的值是ULONG_MAX。根据定义,它大于任何其他值,因而check_for_hierarchy_violation()的检查顺利通过(2)。检查完成后,lock()委托内部的互斥加锁(4)。只要成功锁定,层级编号即能更新(5)。 -
假设我们已持有互斥
hierarchical_mutex的锁,那么this_thread_hierarchy_value的值反映出前者所在层级的编号,若要再对另一互斥加锁,后面互斥的层级必须低于前面已被锁定的互斥的层级,才可以通过检查(2)。 -
记录当前线程的层级编号,将其保存为"上一次加锁的层级"
(3)。及后,执行unlock()时,线程的层级按保存的值复原(6);否则,我们就无法重新锁定层级较高的互斥,即使当前线程不再持有任何锁。
只有锁定内部互斥
internal_mutex之后,我们才着手保存"上一次加锁的层级"(3),而在内部互斥解锁之前,复原已经完成(6),因此内部互斥的保护很到位,它可以安全地保存在hierarchical_mutex内部。为了避免乱序解锁而引发层级混淆,如果我们解锁某个层级的互斥,却发现它不是最后一个被加锁的,就抛出异常
(9)。 -
try_lock()与lock()的工作原理相同,差别在于,若调用try_lock(),它对内部互斥internal_mutex加锁失败(7),也就是说,我们没有成功获取锁。因此,当前线程的层级编号不进行更新,并且函数返回false而不是true。
2.5.5 将准则推广到锁操作以外
死锁线程并不单单因加锁操作而发生,任何同步机制导致的循环等待都会导致死锁出现。因此也值得为那些情况推广上述准则。
譬如,我们应尽可能避免获取嵌套锁;若当前线程持有某个锁,却有同时等待别的线程,这便是坏的情况,因为万一后者恰好也需获取锁,反而只能等该锁被释放才能继续运行。
类似地,如果要等待线程,那就值得针对线程规定层级,使得每个线程仅等待层级更低的线程。有一种简单方法可实现这种机制:让同一个函数启动全部线程,且汇合工作也由之负责。
2.6 运用 std::unique_lock<> 灵活加锁
std::unique_lock对象不一定始终占有与之关联的互斥。首先,其构造函数接收第二个参数:
- 可以传入
std::adopt_lock实例,借此指明std::unique_lock对象管理互斥上的锁; - 也可以传入
std::defer_lock实例,从而使互斥在完成构造时处于无锁状态,等以后有需要时才在std::unqiue_lock对象(不是互斥对象)上调用lock()而获取锁,或把std::unique_lock对象交给std::lock()函数加把锁。
只要把std::lock_guard和std::adopt_lock分别改成std::unique_lock和std::defer_lock,即可轻松重写交换函数的代码。两种方式功能等效,仅有一处小差别:std::unique_lock占用更多的空间,也比std::lock_guard略慢。但std::unique_lock对象可以不占有关联的互斥,具备这份灵活性需要付出代价:需要存储并且更新互斥信息。
如下代码,运用std::lock()函数和std::unique_lock<>类模板在对象间互换内部数据
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::defer_lock将互斥保留为无锁状态
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);
}
};
std::unique_lock类具有成员函数lock()、try_lock()和unlock(),所以它的实例得以传给std::lock()函数。std::unique_lock实例在底层与目标互斥关联,此互斥也具备这3个同名的成员函数,因此上述函数调用转由它们实际执行。
std::unique_lock实例还包含一个内部标志,亦随着这些函数的执行而更新,以表明关联的互斥目前是否被该类的实例占据。这一标志必须存在,作用是保证析构函数正确调用unlock();若不然,实例并未占据互斥,便绝不能调用unlock()。此标志可以通过调用成员函数owns_lock()查询。
不过,若条件允许,最好还是采用C++17所提供的变参模板类std::scoped_lock,除非我们必须采用std::unique_lock类进行某些操作,如转移锁的归属权。
上述标志需要占用存储空间。故std::unique_lock对象的"体积"往往大于std::lock_guard对象的。并且,由于该标志必须适时更新或检查,因此若采用std::unique_lock替换std::lock_guard,便会导致轻微的性能损失。
若std::lock_guard已经能满足所需,建议优先采用。对于一些情形需要更灵活的处理方式,则std::unique_lock类更为合适。延时加锁即属此列;另一种情形是,需要从作用域转移锁的归属权到其他作用域。
2.7 在不同作用域之间转移互斥归属权
由于std::unique_lock实例不占有与之关联的互斥,所以随着其实例的转移,互斥的归属权可以在多个std::unique_lock实例之间转移。转移会在某些情况下自动发生,譬如从函数返回实例时,但须针对别的情形显示调用std::move()。
本质上,这取决于移动的数据的来源到底是左值还是右值:
- 若是左值(
lvalue,实实在在的变量或者指向真实变量的引用),则必须显式转移,以免归属权意外地转移到别处; - 如果是右值(
rvalue,某种形式的临时变量),归属权转移便会自动发生。
std::unique_lock属于可移动却不可复制的型别。
转移有一种用途:准许函数锁定互斥,然后把互斥的归属权转移给函数调用者,好让他在同一个锁的保护下执行其他操作。
下面的代码片段就此做了示范:get_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; //(1)
}
void process_data()
{
std::unique_lock<std::mutex> lk(get_lock()); //(2)
do_something();
}
由于锁lk是get_lock()函数中声明的std::unique_lock局部变量,因此代码无须调用std::move()就能把它直接返回(1), 编译器会妥善调用移动构造函数。在process_data()的函数定义中,也给出了std::unique_lock实例,而该函数身为get_lock()的调用者,能够直接接受锁的归属权的转移(2)。
至此,前期准备已经完成,数据正确、可靠,可以提供给do_something()进一步操作,而且别的线程无法再do_something()调用期间改动数据。
上述模式通常会在两种情形中使用:
- 互斥加锁的时机取决于程序的当前状态
- 某函数负责执行加锁操作并返回
std::unique_lock对象,而互斥加锁时机则由传入的参数决定。
**通道(gate way)**类是一种利用锁转移的具体形式,锁的角色是其数据成员,用于保证只有正确加锁才能够访问受保护数据,而不再充当函数的返回值。
-
所有数据必须通过通道(gate way)类访问:若想访问数据,则需先取得通道(gate way)类的实例(由函数调用返回,如上例中的
get_lock()),再借它执行加锁操作,然后通过通道(gate way)对象的成员函数才得以访问数据。 -
我们在访问完成后销毁通道(gate way)对象,锁便随之释放,别的线程遂可以重新访问受保护的数据。这类通道(gate way)对象几乎是可移动的(只有这样,函数才有可能向外转移归属权),因此锁对象作为其数据成员也必须是可移动的。
std::unique_lock类十分灵活,允许它的实例在被销毁前解锁。其成员函数unlock()负责解锁操作,这与互斥一致。它与互斥都具有一套基本的成员函数用于加锁和解锁,因此该类可以结合泛型函数来使用,如std::lock()。
std::unique_lock的实例可以在被销毁前解锁,意味着,在执行流程的任何特定分支上,若某个锁显然没有必要继续持有,则可以解锁。这对应用程序的性能颇为重要:在所需范围之外持锁将使性能下降,因为如果有其他线程需要加锁,就会被迫毫无必要地延长等待时间,导致运行受阻。
2.8 按合适的粒度加锁
锁粒度,该术语描述了一个锁所保护的数据量,但它没有严格的实质定义。粒度精细的锁保护少量数据,而粒度粗大的锁保护大量数据。
锁操作有两个要点:
- 一是选择足够粗大的锁粒度,确保目标数据受到保护;
- 二是限制范围,务求只在必要的操作过程中持锁。
假定多个线程正等待使用同一个资源,如果任何线程在必要范围以外持锁,就会增加等待所耗费的总时间。只要条件允许,仅仅在访问共享数据期间才锁住互斥,让数据处理尽可能不用锁保护。
请特别注意,持锁期间应避免任何耗时的操作,如读写文件。同样是读写总量相等的数据,文件操作通常比内存慢几百倍甚至几千倍。除非锁的本意正是保护文件访问,否则,为I/O操作加锁将毫无必要地阻塞其他线程(它们因等待获取锁而被阻塞),即使运用了多线程也无法提升性能。
这种情况可用std::unique_lock处理:假如代码不再需要访问共享数据,那我们就调用unlock()解锁;若以后需重新访问,则调用lock()加锁。
void get_and_process_data()
{
std::unique_lock<std::mutex> my_lock(the_mutex);
some_class data_to_process = get_next_data_chunk();
my_lock.unlock(); //(1)假定在调用`process()`期间,互斥无须加锁
result_type result = process(data_to_process);
my_lock.lock(); //(2)重新锁住互斥,以写出结果
write_result(data_to_process,result);
}
process()函数若全程无须为互斥加锁,那应该在调用前手动解锁(1),其后重新锁定(2)。
希望读者可以清楚下面的事实:若只用单独一个互斥保护整个数据结构,不但很可能加剧锁的争夺,还将难以缩短持锁时间。假设某项操作需对同一个互斥全程加锁,当中步骤越多,则持锁时间越久。这是一种双重损失,恰恰加倍促使我们尽可能改用粒度精细的锁。
除非绝对必要,否则不得在持锁期间进行耗时的操作,如等待I/O完成或获取另一个锁(即便我们知道不会死锁)。
数据互换操作中,显然必须并发访问两个对象,该操作需锁定两个互斥。设想原本的场景变成:我们试图比较两个简单的数据成员,其型别是C++的原生int。新、旧场景存在区别吗?存在,因为int的值复制起来开销甚低,所以程序可以锁住比较运算的目标对象,从中轻松地复制出各自相关数据,再用复制的值进行比较运算。这样做的意义是,我们并非先持有一个锁再锁定另一个互斥,而是分别对两个互斥加锁,使得持锁时间最短。
如下示例,在比较运算的过程中,每次只锁住一个互斥
class Y
{
private:
int some_detail;
mutable std::mutex m;
int get_detail() const
{
std::lock_guard<std::mutex> lock_a(m); //(1)
return some_detail;
}
public:
Y(int sd):some_detail(sd){}
friend bool operator==(Y const& lhs,Y const& rhs)
{
if(&lhs == &rhs)
return ture;
int const lhs_value = lhs.get_detail(); //(2)
int const rhs_value = rhs.get_detail(); //(3)
return lhs_value==rhs_value; //(4)
}
};
本例中,比较运算符首先调用成员函数get_detail(),以获得需要比较的值(2)(3)。该函数先加锁保护数据(1),再取值。接着,比较运算符对比取得的值(4)。新场景下,为了缩短持锁定的时间,一次只持有一个锁(这也排除了死锁的可能),而原本的场景则将两个对象一起锁定。
请注意,新场景中隐秘地篡改了比较运算的原有语义。在上述代码中,若比较运算返回true,则其意义是:lhs.some_detail在某时刻的值等于rhs.some_detail在另一时刻的值。在两次获取之间(2)(3),它们的值有可能已经以任意形式发生变化,令比较运算拾取意义。
因而做这种改动时必须保持谨慎,免得运算语义遭到篡改而产生问题:若我们未能持有必要的锁以完全保护整个运算,便会将自己置身于条件竞争的危险中。
3 保护共享数据的其他工具
一种格外极端却特别常见的情况是,为了并发访问,共享数据仅需在初始化过程中收到保护,之后再也无须进行显式的同步操作。这可能是因为共享数据一旦创建就处于只读状态,原本可能发生的同步问题遂不复存在;也可能是因为后续操作已为共享数据施加了必要的隐式保护。
无论那种情况中,若加锁是单纯为了保护共享数据初始化,但完成初始化后却继续锁住互斥,那就成了画蛇添足,还会造成不必要的性能损失。
3.1 在初始化过程中保护共享数据
假设我们需要某个共享数据,而它创建起来开销不菲。因为创建它可能需要建立数据库连接或分配大量内存,所以等到必要时才真正着手创建。这种方式称为延迟初始化(lazy initialization),常见于单线程代码。
对于需要利用共享资源的每一项操作,要先在执行前判别该数据是否已经初始化,若没有,则及时初始化,然后方可使用。
std::shared_ptr<some_resource> resource_ptr;
void foo()
{
if(!resource_ptr)
{
resource_ptr.reset(new some_resource); //(1)
}
resource_ptr->do_something();
}
假定共享数据本身就能安全地并发访问,若将上面的代码转换成多线程形式,则仅有初始化过程需要保护(1)。如下代码采用精简方式改写,不过,如果数据为多线程所用,那么它们便无法被并发访问,线程只能毫无必要地循序运行,因为每个线程都必须在互斥上轮候,等待查验数据是否已经完成初始化。
std::shared_ptr<some_resource> resource_ptr;
std::mutex resource_mutex;
void foo()
{
std::unique_lock<std::mutex> lk(resource_mutex); //(1)此处,全部线程都被迫循环等待运行
if(!resource_ptr)
{
resource_ptr.reset(new some_resource); //(2)仅有初始化,需要保护
}
lk.unlock();
resource_ptr->do_something();
}
上面的代码模式司空见惯,但它毫无必要地迫使多个线程循序运行,很有问题。为此许多人尝试进行改进,包括实现受诟病的双重检验锁定模式(double-checked locking pattern)。首先,在无锁条件下读取指针(1),只有读到空指针才能获取锁。其次,当前线程先判别空指针(1),随机加锁。两步操作之间存在空隙,其他线程或许正好借机完成初始化。我们需再次检验空指针(2)(双重检验),以防范这种情形发生:
void undefined_behaviour_with_double_checked_locking()
{
if(!resource_ptr) //(1)
{
std::lock_guard<std::mutex> lk(resource_mutex);
if(!resource_ptr) //(2)
{
resource_ptr.reset(new some_resource); //(3)
}
}
resource_ptr->do_something(); //(4)
}
很遗憾,这种新的模式饱受诟病,因为它有可能诱发恶性条件竞争,问题的根源是:
当前线程在锁保护范围外读取指针(1),而对方线程却可能先获取锁,顺利进入锁保护范围内执行写操作(3),因此读写操作没有同步,产生了条件竞争,既涉及指针本身,还涉及其指向的对象。尽管当前线程能够看见其他线程写入指针,却有可能无视新实例some_resource的创建注,结果do_something()的调用就会对不正确的值进行操作(4)。
具体过程如下:
- 在
undefined_behaviour_with_double_checked_locking()函数的代码中,最初指针为空,当前线程和对方线程均进入第一层if分支(1)。- 接着对方线程夺得锁
lk,顺利进入第二层if分支(2);当前线程则被阻塞,在(1)(2)之间等待。- 然后对方线程一气呵成执行完余下全部代码:创建新实例并令指针指向它
(3),继而离开if分支,锁lk随之被销毁,最终调用do_something()操作新实例,更改其初始值(4)。- 当前线程后来终于获得锁,便继续运行:这时它发现指针非空
(2),遂转去执行do_something()(4),但实例已被改动过而当前线程却不知情,于是仍按其初值操作,错误发生。
C++标准将此例定义为数据竞争(data race),是条件竞争的一种,其将导致未定义行为,所以我们肯定要防范。
C++标准委员会相当重视以上情况,在C++标准库中提供了std::once_flag类和std::call_once()函数,以专门处理该情况。
上述代码先锁住互斥,再显示检查指针,导致问题出现。我们对症下药,令所有线程共同调用std::call_once()函数,从而确保在该调用返回时,指针初始化由其中某线程安全且唯一地完成(通过适合的同步机制)。必要的同步数据则由std::once_flag实例存储,每个std::once_flag实例对应一次不同的初始化。相比显示地使用互斥,std::call_once()函数的额外开销往往更低,特别是在初始化已经完成的情况下,所以如果功能符合需求就应优先使用。
运用std::call_once()重写代码,即得出下面的代码,两者均实现了相同的操作。下面的代码中,初始化通过函数调用完成,不过只要一个类具备函数调用操作符,则该类的实例也可以轻松地通过这种方式进行初始化。
标准库中的一些函数接收函数或断言注作为参数,std::call_once()与它们当中的大多数相似,能与任何函数或可调用对象配合工作:
断言即predicate,又称"谓词"。在c++语境下,它是函数或可调用对象,返回布尔值,专门用于判断某条件是否成立。
std::shared_ptr<some_resource> resource_ptr;
std::once_flag resource_flag; //(1)
void init_resource()
{
resource_ptr.reset(new some_resource);
}
void foo()
{
std::call_once(resource_flag,init_resource); //(2)初始化函数准确地被唯一一次调用
resource_ptr->do_something();
}
上例包含两个对象,需要初始化的数据(该数据的类型为some_resource,有共享指针resource_ptr指向)和std::once_flag对象(1),两者的作用域都完整涵盖它们所属的名字空间。
不过,就算是某个类的数据成员,依然能方便地实施延迟初始化。
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) //(1)
{
std::call_once(connection_init_flag,&X::open_connection,this); //(2)
connection.send_data(data);
}
data_packet receive_data() //(3)
{
std::call_once(connection_init_flag,&X::open_connection,this); //(2)
return connection.receive_data();
}
};
代码中的初始化或在send_data()的首次调用中进行(1),或在receive_data()的第一次调用中进行(3)。它们都借助成员函数open_connection()初始化数据,而该函数必须用到this指针,所以要向其传入this指针。
标准库的某些函数接收可调用对象,如std::thread的构造函数和std::bind()函数,std::call_once()同样如此,故本例代码也向它传递this指针作为附加参数(2)。
std::once_flag的实例既不可复制也不可移动,这与std::mutex类似。若像上例那样让它充当类的数据成员,而涉及初始化的两个成员函数功能特殊,则这些数据经由它们调用call_once()才可以进行操作,所以这两个函数必须显式定义。
如果把局部变量声明成静态数据,那样便有可能让初始化过程出现条件竞争。
根据C++标准规定,只要控制流程第一次遇到静态数据的声明语句,变量即进行初始化。若多个线程同时调用同一函数,而它含有静态数据,则任意线程均可能首先到达其声明处,这就形成了条件竞争的隐患。
C++11标准发布之前,许多编译器都未能在实践中正确处理该条件竞争。其原因有可能是众多线程均认定自己是第一个,都试图初始化变量;也有可能是某线程上正在进行变量的初始化,但尚未完成,而别的线程却试图使用它。
C++11解决了这个问题,规定初始化只会在某一线程上单独发生,在初始化完成之前,其他线程不会越过静态数据的声明而继续运行。于是,这使得条件竞争原来导致的问题变为,初始化应当由哪个线程具体执行。某些类的代码只需用到唯一一个全局实例,这种情形可用以下方法代替std::call_once():
class my_class;
my_class& get_my_class_instance() //(1)线程安全的初始化,C++11标准保证其正确性
{
static my_class instance;
return instance;
}
多个线程可以安全地调用get_my_class_instance(),而无须担忧初始化的条件竞争。
3.2 保护甚少更新的数据结构
若一种数据结构,线程对其进行更新操作,则并发访问从开始到结束完全排他,及至更新完成,数据结构方可重新被多线程并发访问。
所以,若采用std::mutex保护数据结构,则过于严苛,原因是即便没有发生改动,它照样会禁止并发访问。我们在这里采用新类型的互斥。由于新的互斥具有两种不同的使用方式,因此通常被称为读写互斥:允许单独一个"写线程"进行完全排他的访问,也允许多个"读线程"共享数据或并发访问。
C++17 标准库提供了两种新的互斥:std::shared_mutex和std::shared_timed_mutex。C++14标准库只有std::shared_timed_mutex,而C++11标准库都没有。
这种互斥并非"灵丹妙药",因为程序性能能由下列因素共同决定:处理器的数目,还有读、写线程上的相对工作负荷。多线程令复杂度增加,为了确保性能可以同样随之提升,一个重要的方法是在目标系统上对代码进行性能剖析。
- 更新操作可用
std::lock_guard<std::shared_mutex>和std::unique_lock<std::shared_mutex>锁定,代替对应的std::mutex特化。它们与std::mutex一样,都保证了访问的排他性质。 - 对于那些无须更新数据结构的线程,可以另行改用共享锁
std::shared_lock<std::shared_mutex>实现共享访问。C++14引入了共享锁的类模板,其工作原理是RAII过程,使用方式则与std::unique_lock相同,只不过多个线程能够同时锁住同一个std:;shared_mutex。
共享锁仅有一个限制,假设它已被某些线程所持有,若别的线程试图获取排他锁,就会发生阻塞,直到那些线程全都释放该共享锁。反之,如果任一线程持有排他锁,那么其他线程全都无法获取共享锁或排他锁,直到持锁线程将排他锁释放为止。
#include <map>
#include <string>
#include <mutex>
#include <shared_mutex>
class dns_entry;
class dns_cache
{
std::map<std::string,dns_entry> entries;
mutable std::shared_mutex entry_mutex;
public:
dns_entry find_entry(std::string const& domain) const
{
std::shared_lock<std::shared_mutex> lk(entry_mutex); //(1)
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<std::shared_mutex> lk(entry_mutex); //(2)
entries[domain] = dns_details;
}
};
find_entry采用std::shared_lock<>实例保护共享的、只读的访问,所以多个线程得以同时调用find_entry()。- 同时,当缓存表需要更新时,
update_or_add_entry()采用std::lock_guard<>实例进行排他访问;如果其他线程同时调用update_or_add_entry(),那么它们的更新操作将被阻塞,而且调用find_entry()的线程也会被阻塞。
3.3 递归加锁
C++标准库提供了std::recursive_mutex,其工作方式与std::mutex相似,不同之处是,其允许同一线程对某互斥的同一实例多次加锁。我们必须先释放全部的锁,才可以让另一个线程锁住该互斥。
只要正确地使用std::lock_guard<std::recursive_mutex>和std::unique_lock<std::recursive_mutex>,它们便会处理好递归锁的余下细节。
若要设计一个类以支持多线程并发访问,他就需包含互斥来保护数据成员,递归互斥常常用于这种情形。
每个公有函数都需先锁住互斥,然后才进行操作,最后解锁互斥。但有时在某些操作过程中,公有函数都需要调用另一公有函数。在这种情况下,后者将同样视图锁住互斥,如果采用std::mutex便会导致未定义行为。有一种"快刀斩乱麻"的解决方法:用递归互斥代替普通互斥。这容许第二个公有函数成功地对递归互斥加锁,因此函数可以顺利地执行下去。
但是上述方法,可能放纵思维而导致拙劣的设计。具体而言,当以上类型持有锁的时候,其不变量往往会被破坏。也就是说,即便不变量被破坏,只要第二个成员函数被调用,它依然必须工作。
更好的方法是:根据这两个公有函数的公共部分,提取出一个新的私有函数,新函数由这个两个公有函数调用,而它假定互斥已经被锁住,遂无需重复加锁;具体改动步骤为,将该类的内部互斥从std::recursive_mutex类型改为std::mutex类型,而原来的两个公有函数之间不发生任何调用,都转为调用新提取出的私有函数,并在调用前各自对std::mutex成员加锁。
1044

被折叠的 条评论
为什么被折叠?



