线程间共享数据问题
多线程共享数据的问题多由数据改动引发。如果所有共享数据都是只读数据,就不会有问题。因为,若数据被某个线程读取,无论是否存在其他线程也在读取,该数据都不会受到影响。然而,如果多个线程共享数据,只要一个线程开始改动数据,就会带来很多隐患,产生麻烦。鉴于此,我们必须小心、谨慎,保证一切都正确运作。
为了帮助我们分析代码,“不变量”(invariant)被广泛使用,它是一个针对某一特定数据的断言,该断言总是成立的,例如“这个变量的值即为链表元素的数目”。数据更新往往会破坏这些不变量,尤其是涉及的数据结构稍微复杂,或者数据更新需要改动的值不止一个时。
条件竞争
设想你去看某明星演唱会。一般来说,演唱会门票可以在很多平台购买,假如你在购票的同时。别人也在购买,供选择的座位取决于你们谁先下单。如果只剩少量座位,事实上就形成了竞争,谁能买到最后几张票,下单的先后顺序至关重要。以上事例即为条件竞争:你得到什么座位(甚至是否能买到票),取决于购票的相对次序。
在并发编程中,操作由两个或多个线程负责,它们争先让线程执行各自的操作,而结果取决于它们执行的相对次序,所有这种情况都是条件竞争。很多时候,这是良性行为,因为全部可能的结果都可以接受,即便线程变换了相对次序。例如,两个线程向队列添加数据项以待处理,只要维持住系统的不变量,先添加哪个数据项通常并不重要。当条件竞争导致不变量被破坏时,才会产生问题。当论及并发时,条件竞争通常特指恶性条件竞争。
我们有几种方法防止恶性条件竞争。
- 互斥——最简单的就是采取保护措施包装数据结构,确保不变量被破坏时,中间状态只对执行改动的线程可见。在其他访问同一数据结构的线程的视角中,这种改动要么尚未开始,要么已经完成。
- 无锁编程——修改数据结构的设计及其不变量,由一连串不可拆分的改动完成数据变更,每个改动都维持不变量不被破坏。这通常被称为无锁编程,难以正确编写。
- 还有一种防止恶性条件竞争的方法,将修改数据结构当作事务(transaction)来处理,类似于数据库在一个事务内完成更新:把需要执行的数据读写操作视为一个完整序列,先用事务日志存储记录,再把序列当成单一步骤提交运行。若别的线程改动了数据而令提交无法完整执行,则事务重新开始。这称为软件事务内存(Software Transactional Memory,STM)。但是,因为C++没有直接支持STM,(C++标准委员会已公布了一份技术规约,内容正是软件事务内存的C++扩展)。
互斥
假定有一个用于共享的数据结构,我们要保护它,避免条件竞争,并防止不变量被坡坏。如果我们能标记访问该数据结构的所有代码,令各线程在其上相互排斥(mutually exclusive),那么,只要有线程正在运行标记的代码,任何别的线程意图访问同一份数据,则必须等待,直到该线程完事,这岂不妙哉?如此一来,除了正在改动数据的线程自身,任何线程都无法看见不变量被破坏。互斥(mutual exclusion,略作mutex)就能达到我们想要的效果。访问一个数据结构前,先锁住与数据相关的互斥;访问结束后,再解锁互斥。
C++线程库保证了,一旦有线程锁住了某个互斥,若其他线程试图再给它加锁,则须等待,直至最初成功加锁的线程把该互斥解锁。这确保了全部线程所见到的共享数据是自洽的(self-consistent),不变量没有被破坏。
互斥是C++最通用的共享数据保护措施之一,但非万能的灵丹妙药。我们务必妥善地组织和编排代码,从而正确地保护共享数据,同时避免接口固有的条件竞争。互斥本身也有问题,表现形式是死锁、对数据的过保护或欠保护,这将会在后面的文章中一一介绍。
在C++中,我们通过构造std::mutex的实例来创建互斥,调用成员函数lock()对其加锁,调用unlock()解锁,如下代码。但尽量不要使用该方式。原因是,若按此处理,那我们就必须记住,在函数以外的每条代码路径上都要调用unlock(),包括由于异常导致退出的路径。
#include <list>
#include <mutex>
#include <algorithm>
std::list<int> some_list; // ⇽--- ①
std::mutex some_mutex; // ⇽--- ②
void add_to_list(int new_value) {
some_mutex.lock(); // ⇽--- ③
some_list.push_back(new_value);
some_mutex.unlock(); // ⇽--- ④
}
bool list_contains(int value_to_find) {
some_mutex.lock(); // ⇽--- ⑤
bool find = std::find(some_list.begin(),some_list.end(),value_to_find)
!= some_list.end();
some_mutex.unlock(); // ⇽--- ⑥
return find;
}
上述代码中有一个独立的全局变量①,由对应的std::mutex实例保护(另一个全局变量)②。函数add_to_list()③④和函数list_contains()⑤⑥内部都使用了调用了some_mutex的lock和unlock函数,以保证其中一个线程在改或者读取链表的时候,另外一个对其不可见。
取而代之,C++标准库提供了类模板std::lock_guard<>,针对互斥类融合实现了RAII手法:在构造时给互斥加锁,在析构时解锁,从而保证互斥总被正确解锁.如下代码展示了如何用std::mutex类和std::lock_guard类保护链表,使之能同时被多个线程访问。两个类都在头文件里声明。
#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();
}
上述代码中有一个独立的全局变量①,由对应的std::mutex实例保护(另一个全局变量)②。函数add_to_list()③和函数list_contains()④内部都使用了std::lock_guard< std::mutex>,其意义是使这两个函数对链表的访问互斥:假定add_to_list()正在改动链表,若操作进行到一半,则list_contains()绝对看不见该状态。
尽管有些场景适合模仿上述例子使用全局变量,不过大多数场景下的普遍做法是,将互斥与受保护的数据组成一个类。这是面向对象设计准则的典型运用:将两者放在同一个类里,我们清楚表明它们互相联系,还能封装函数以增强保护。
如果成员函数返回指针或引用,指向受保护的共享数据,那么即便成员函数全都按良好、有序的方式锁定互斥,仍然无济于事,因为保护已被打破,出现了大漏洞。只要存在任何能访问该指针和引用的代码,它就可以访问受保护的共享数据(也可以修改),而无须锁定互斥。所以,若利用互斥保护共享数据,则需谨慎设计程序接口,从而确保互斥已先行锁定,再对受保护的共享数据进行访问,并保证不留后门。
如下例子,意外地向外传递引用,指向受保护共享数据
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是使用者提供的函数①。换言之,foo()中的代码可以传入malicious_function②,接着,就能绕过保护,调用do_something(),而没有锁住互斥③。
本质上,以上代码的问题在于,它未能真正实现我们原有的设想:把所有访问共享数据的代码都标记成互斥。上例中,我们遗漏了foo(),调用unprotected->do_something()的代码未被标记。无奈,C++线程库对这个问题无能为力;只有靠我们自己——程序员——正确地锁定互斥,借此保护共享数据。从乐观的角度看,只要遵循下列指引即可应对上述情形:不得向锁所在的作用域之外传递指针和引用,指向受保护的共享数据,无论是通过函数返回值将它们保存到对外可见的内存,还是将它们作为参数传递给使用者提供的函数。
前面文章
2024.3.27记——C++多线程系列文章(一)
2024.3.28记——C++多线程系列文章(二)之向线程函数传递参数
2024.3.29记——C++多线程系列文章(三)线程归属权转移及线程识别