内容概要:
- 线程间共享数据的问题
- 利用互斥保护共享数据
- 利用其他工具保护共享数据
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()
,则须先依据栈容器中的元素型别构造一个实例,将其充当接收目标传入函数内。对于某些型别,构建实例的时间代价高昂或耗费资源过多,所以不太实用。 - 一些型别的栈元素,其构造函数不一定带有参数,故此法也并不总是可行。
- 还要求栈容器存储的型别是可赋值的(
- 要调用