前言
本章主要内容
- 共享数据带来的问题
- 使用互斥量保护数据
- 数据保护的替代方案
第3章 线程间共享数据
上一章中,我们已经对线程管理有所了解,现在让我们来看一下“共享数据的那些事”。
想象一下,你和你的朋友合租一个公寓,公寓中只有一个厨房和一个卫生间。当你的朋友在卫生间时,你就会不能使用了(除非你们特别好,可以在同时使用一个房间)。
这个问题也会出现在厨房,假如:厨房里有一个组合式烤箱,当在烤香肠的时候,也在做蛋糕,就可能得到我们不想要的食物(香肠味的蛋糕)。此外,在公共空间将一件事做到一半时,发现某些需要的东西被别人借走,或是当离开的一段时间内有些东西被变动了地方,这都会令我们不爽。
同样的问题,也困扰着线程。当线程在访问共享数据的时候,必须定一些规矩,用来限定线程可访问的数据位。还有,一个线程更新了共享数据,需要对其他线程进行通知。从易用性的角度,同一进程中的多个线程进行数据共享,有利有弊。错误的共享数据使用是产生并发bug的一个主要原因,并且后果要比香肠味的蛋糕更加严重。
本章就以在C++中进行安全的数据共享为主题。避免上述及其他潜在问题的发生的同时,将共享数据的优势发挥到最大。
3.1 共享数据带来的问题
涉及到共享数据时,问题就可能是因为共享数据修改所导致。
- 如果共享数据是只读的,那么操作不会影响到数据,更不会涉及对数据的修改,所以所有线程都会获得同样的数据。
- 但是,当一个或多个线程要修改共享数据时,就会产生很多麻烦。这种情况下,就必须小心谨慎,才能确保所有线程都工作正常。
不变量(invariants)
的概念对开发者们编写的程序会有一定的帮助——对于特殊结构体的描述;
比如,“变量包含列表中的项数”
。不变量
通常会在一次更新中被破坏,特别是比较复杂的数据结构,或者一次更新就要改动很大的数据结构。
双链表中每个节点都有一个指针指向列表中下一个节点,还有一个指针指向前一个节点。其中不变量就是节点A中指向“下一个”节点B的指针,还有前向指针。为了从列表中删除一个节点,其两边节点的指针都需要更新。当其中一边更新完成时,不变量就被破坏了,直到另一边也完成更新;在两边都完成更新后,不变量就又稳定了。
从一个列表中删除一个节点的步骤如下(如图3.1)
- [a] 找到要删除的节点N
- [b] 更新前一个节点指向N的指针,让这个指针指向N的下一个节点
- [c] 更新后一个节点指向N的指针,让这个指正指向N的前一个节点
- [d] 删除节点N
图3.1 从一个双链表中删除一个节点
图中b和c在相同的方向上指向和原来已经不一致了,这就破坏了不变量。
线程间的问题在于修改共享数据,致使不变量遭到破坏。
当在删除过程中不确定是否有其他线程能够进行访问的话,可能就有线程访问到刚刚删除一边的节点;这样的话,线程就读取到要删除节点的数据(因为只有一边的连接被修改,如图3.1(b)),这样不变量就被破坏了。
破坏不变量的后果是不确定的,当其他线程按从左往右的顺序来访问列表时,它将跳过被删除的节点。
在一方面,如有第二个线程尝试删除图中右边的节点,那么可能会让数据结构产生永久性的损坏,使程序崩溃。
无论结果如何,这都是并行中常见错误:
- 条件竞争(racecondition)。
3.1.1 条件竞争
假如你去电影院买电影票。如果去的是一家大电影院,有很多收银台,很多人就可以在同一时间买电影票。当另一个收银台也在卖你想看的这场电影的电影票,那么你的座位选择范围就取决于在之前已预定的座位。当只有少量的座位剩下,这就意味着,这可能是一场抢票比赛,看谁能抢到最后一张票。
这就是一个条件竞争的例子:
- 你的座位(或者你的电影票)都取决于两种购买方式的相对顺序。
并发中竞争条件的形成,取决于一个以上线程的相对执行顺序,每个线程都抢着完成自己的任务。大多数情况下,即使改变执行顺序,也是良性竞争,其结果可以接受。例如,有两个线程同时向一个处理队列中添加任务,因为系统提供的不变量保持不变,所以谁先谁后都不会有什么影响。当不变量遭到破坏时,才会产生条件竞争,比如双向链表的例子。并发中对数据的条件竞争通常表示为恶性条件竞争,我们对不产生问题的良性条件竞争不感兴趣。
C++ 标准中也定义了数据竞争这个术语,一种特殊的条件竞争:
- 并发的去修改一个独立对象(参见5.1.2节),数据竞争是(可怕的)未定义行为的起因。
恶性条件竞争
通常发生于完成对多于一个的数据块的修改时,例如:对两个连接指针的修改
(如图3.1)。因为操作要访问两个独立的数据块,独立的指令将会对数据块将进行修改,并且其中一个线程可能正在进行时,另一个线程就对数据块进行了访问。因为出现的概率太低,条件竞争很难查找,也很难复现。如CPU指令连续修改完成后,即使数据结构可以让其他并发线程访问,问题再次复现的几率也相当低。当系统负载增加时,随着执行数量的增加,执行序列的问题复现的概率也在增加,这样的问题只可能会出现在负载比较大的情况下。条件竞争通常是时间敏感的,所以程序以调试模式运行时,它们常会完全消失,因为调试模式会影响程序的执行时间(即使影响不多)。
当你以写多线程程序为生,条件竞争就会成为你的梦魇;编写软件时,我们会使用大量复杂的操作,用来避免恶性条件竞争。
3.1.2 避免恶性条件竞争
这里提供一些方法来解决恶性条件竞争,最简单的办法就是:
- 对数据结构采用某种保护机制,确保只有进行修改的线程才能看到不变量被破坏时的中间状态。从其他访问线程的角度来看,修改不是已经完成了,就是还没开始。
C++ 标准库提供很多类似的机制,下面会逐一介绍。
另一个选择是:
- 对数据结构和不变量的设计进行修改,修改完的结构必须能完成一系列不可分割的变化,也就是保证每个不变量保持稳定的状态,
这就是所谓的无锁编程
。
不过,这种方式很难得到正确的结果。如果到这个级别,无论是内存模型上的细微差异,还是线程访问数据的能力,都会让工作量变的很大。内存模型将在第5章讨论,无锁编程将在第7章讨论。
另一种处理条件竞争的方式是:
- 使用
事务的方式
去处理数据结构的更新(这里的"处理"就如同对数据库进行更新一样)。
所需的一些数据和读取都存储在事务日志中,然后将之前的操作合为一步,再进行提交。当数据结构被另一个线程修改后,或处理已经重启的情况下,提交就会无法进行,这称作为“软件事务内存”(software transactional memory (STM))。
理论研究中,这是一个很热门的研究领域。这个概念将不会在本书中再进行介绍,因为在 C++ 中没有对STM进行直接支持(尽管C++有事务性内存扩展的技术规范[1])。但是,基本思想会在后面提及。
保护共享数据结构的最基本的方式,是使用C++标准库提供的互斥量。
[1] SO/IEC TS 19841:2015—Technical Specification for C++ Extensions for Transactional
Memory http://www.iso.org/iso/home/store/catalogue_tc/catalogue_detail.htm?
csnumber=66343 .
3.2 使用互斥量保护共享数据
当程序中有共享数据时,你肯定不想让程序其陷入条件竞争,或是出现不变量被破坏的情况。
将所有访问共享数据结构的代码都标记为互斥是否是一种更好的办法呢?
这样,任何一个线程在执行时,其他线程试图访问共享数据时,就必须进行等待。除非该线程就在修改共享数据,否则任何线程都不可能会看到被破坏的不变量。
当访问共享数据前,将数据锁住,在访问结束后,再将数据解锁。线程库需要保证,当一个线程使用特定互斥量锁住共享数据时,其他的线程想要访问锁住的数据,都必须等到之前那个线程对数据进行解锁后,才能进行访问。这就保证了所有线程都能看到共享数据,并而不破坏不变量。
互斥量一种数据保护通用机制,但它不是什么“银弹”;需要编排代码来保护数据的正确性(见3.2.2节),并避免接口间的竞争条件(见3.2.3节)也非常重要。不过,互斥量自身也有问题,也会造成死锁(见3.2.4节),或对数据保护的太多(或太少)(见3.2.8节)。
3.2.1 C++中使用互斥量
C++中通过实例化 std::mutex 创建互斥量实例,通过成员函数lock()对互斥量上锁,unlock()进行解锁。不过,实践中不推荐直接去调用成员函数,调用成员函数就意味着,必须在每个函数出口都要去调用unlock(),也包括异常的情况。C++标准库为互斥量提供了一个RAII语法的模板类 std::lock_guard ,在构造时就能提供已锁的互斥量,并在析构的时候进行解锁,从而保证了一个已锁互斥量能被正确解锁。下面的程序清单中,展示了如何在多线程应用中,使用 std::mutex 构造的 std::lock_guard 实例,对一个列表进行访问保护。 std::mutex 和 std::lock_guard 都在 头文件中声明。
清单3.1 使用互斥量保护列表
#include <list> #include <mutex> #include <algorithm> std::list<int> some_list; // 1 std::mutex some_mutex; // 2 void add_to_list(int new_value) { std::lock_guard<std::mutex> guard(some_mutex); // 3 some_list.push_back(new_value); } bool list_contains(int value_to_find) { std::lock_guard<std::mutex> guard(some_mutex); // 4 return std::find(some_list.begin(),some_list.end(),value_to_find) != some_list.end(); }
清单3.1中有一个全局变量①,这个全局变量被一个全局的互斥量保护②。add_to_list()③和list_contains()④函数中使用 std::lock_guardstd::mutex ,使得这两个函数中对数据的访问是互斥的:list_contains()不可能看到正在被add_to_list()修改的列表。
C++17中添加了一个新特性,称为模板类参数推导,这样类似 std::locak_guard 这样简单的模板类型的模板参数列表可以省略。③和④的代码可以简化成:
std::lock_guard guard(some_mutex);
具体的模板参数类型推导则交给C++17的编译器完成。3.2.4节中,会介绍C++17中的一种加强版数据保护机制—— std::scoped_lock ,所以在C++17的环境下,上面的这行代码也可以写成:
std::scoped_lock guard(some_mutex);
为了让代码更加清晰,并且兼容只支持之C++11标准的编译器,我将会继续使用 std::lock_guard ,并在代码清代中写明模板参数的类型。
某些情况下使用全局变量没问题,但在大多数情况下,互斥量通常会与需要保护的数据放在同一类中,而不是定义成全局变量。这是面向对象设计的准则:将其放在一个类中,就可让他们联系在一起,也可对类的功能进行封装,并进行数据保护。这种情况下,函数add_to_list
和list_contains
可以作为这个类的成员函数。互斥量和需要保护的数据,在类中都定义为private成员
,这会让访问数据的代码更清晰,并且容易看出在什么时候对互斥量上锁。当所有成员函数都会在调用时对数据上锁,结束时对数据解锁,这就保证了访问时数据不变量不被破坏。
当然,事情也不是总是那么理想,聪明的你一定注意到了:当其中一个成员函数返回的是保护数据的指针或引用时,会破坏数据。具有访问能力的指针或引用可以访问(并可能修改)被保护的数据,而不会被互斥锁限制。这就需要对接口有相当谨慎的设计,要确保互斥量能锁住数据的访问,并且不留后门。
3.2.2 用代码来保护共享数据
使用互斥量来保护数据,并不是仅仅在每一个成员函数中都加入一个 std::lock_guard 对象那么简单;一个指针或引用,也会让这种保护形同虚设。不过,检查指针或引用很容易,只要没有成员函数通过返回值或者输出参数的形式,向其调用者返回指向受保护数据的指针或引用,数据就是安全的。如果你还想深究,就没这么简单了。确保成员函数不会传出指针或引用的同时,检查成员函数是否通过指针或引用的方式来调用也是很重要的(尤其是这个操作不在你的控制下时)。函数可能没在互斥量保护的区域内,存储着指针或者引用,这样就很危险。更危险的是:将保护数据作为一个运行时参数,如同下面清单中所示那样。
清单3.2 无意中传递了保护数据的引用
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); // 1 传递“保护”数据给用户函数 } }; some_data* unprotected; void malicious_function(some_data& protected_data) { unprotected=&protected_data; } data_wrapper x; void foo() { x.process_data(malicious_function); // 2 传递一个恶意函数 unprotected->do_something(); // 3 在无保护的情况下访问保护数据 }
例子中process_data看起来没有任何问题, std::lock_guard 对数据做了很好的保护,但调用用户提供的函数func①,就意味着foo能够绕过保护机制将函数 malicious_function 传递进去②,在没有锁定互斥量的情况下调用 do_something() 。
这段代码的问题在于根本没有保护,只是将所有可访问的数据结构代码标记为互斥。函
数 foo() 中调用 unprotected->do_something() 的代码未能被标记为互斥。
这种情况下,C++线程库无法提供任何帮助,只能由开发者使用正确的互斥锁来保护数据。从乐观的角度上看,还是有方法可循的:切勿将受保护数据的指针或引用传递到互斥锁作用域之外,无论是函数返回值,还是存储在外部可见内存,亦或是以参数的形式传递到用户提供的函数去。
虽然这是在使用互斥量保护共享数据时常犯的错误,但绝不仅仅是一个潜在的陷阱而已。下一节中,你将会看到,即便是使用了互斥量对数据进行了保护,条件竞争依旧可能存在。