一.线程间数据共享问题
1.不变量:不变量(invariants)的概念对开发者们编写的程序会有一定的帮助——对于特殊结构体的描述,比如:“变量包含列表中的项数”。多线程操作数据鸡狗如果不加以保护措施很容易破坏不变量,特别是复杂的数据结构。
例子:双链表中每个节点都有一个指针指向列表中下一个节点,还有一个指针指向前一个节点。其中不变量就是节点A中指向“下一个”节点B的指针,还有前向指针。为了从列表中删除一个节点,其两边节点的指针都需要更新。当其中一边更新完成时,就破坏了不变量,直到另一边也完成更新。在两边都完成更新后,不变量就稳定了。假如在节点删除过程中,有另外一个线程在没有删除完毕时访问链表将会产生未定义行为,导致不变量结构破坏。
例如线程1在a中删除中间节点,在没有删除完毕(前驱后继指针未更新),线程2删除后继节点将导致不变量结构破坏。
2.条件竞争:恶性条件竞争通常发生于对多个数据块的修改,例如:对两个连接指针的修改。操作要访问两个独立的数据块,独立的指令会对数据块将进行修改,并且其中一个线程可能正在进行修改,另一个线程就对数据块进行了访问。因为出现的概率低,很难查找,也很难复现。如CPU指令连续修改完成后,即使数据结构可以让其他并发线程访问,问题再次复现的几率也相当低。当系统负载增加时,随着执行数量的增加,执行序列问题复现的概率也在增加,这样的问题可能会出现在负载比较大的情况下。条件竞争通常是时间敏感的,所以程序以调试模式运行时,错误常会完全消失,因为调试模式会影响程序的执行时间(即使影响不多)。
3.避免恶性条件竞争的手段
1)最简单的办法就是对数据结构采用某种保护机制,确保只有修改线程才能看到不变量的中间状态。从其他访问线程的角度来看,修改不是已经完成了,就是还没开始。
2)对数据结构和不变量进行修改,修改完的结构必须能完成一系列不可分割的变化,也就保证了
每个不变量的状态,这就是所谓的无锁编程。不过,这种方式很难得到正确的结果。到这个级别,无论是内存模型上的细微差异,还是线程访问数据的能力,都会让工作量变的很大。
3)是使用事务的方式去处理数据结构的更新(这里的"处理"就如同对数据库进行更新一样)。所需的一些数据和读取都存储在事务日志中,然后将之前的操作进行合并,再进行提交。当数据结构被另一个线程修改后,或处理已经重启的情况下,提交就会无法进行,这称作为“软件事务内存”(software transactional memory (STM)),这是一个很热门的理论研究领域。
Note:c++标准库提供最基本的保护机制Mutex(互斥锁)。
二.C++ Thread库互斥锁
1.mutex(mutual exclusion):故名思意,相互排斥。用于在访问共享数据之前锁定相关联数据的锁。完成数据访问后,解锁互斥锁。可以保证不变量的中间状态不被第二个线程所访问。
Note:线程库确保一旦一个线程锁定了特定的互斥锁,所有其他的尝试锁定同一互斥锁的线程都必须等待,直到成功锁定的互斥锁的线程进行解锁。互斥锁的缺点是也有可能造成死锁或者保护过度或过少。
c++中使用std::mutex的实例创建一个互斥锁,使用lock()成员变量进行锁定。unlock()解锁,标准库提供std::lock_guard类模板为互斥锁实现了RAII(资源申请即初始化)。
示例:
但是使用互斥量保护数据并不是单纯上锁就可以的,如下所示。如果调用的用户代码进行了恶意打洞,也会使得数据保护失效。使得互斥锁作用域内的期望保护数据可以通过外部直接访问。
使用Mutex无法保护由于接口设计缺陷导致的未定义行为。对于C++ STL库中的stack,如果使用默认的容器类型(比如deque),则其本身是线程不安全的,因为它没有内置的互斥机制来确保同一时刻只有一个线程对其进行访问。如果需要在多线程环境中使用stack,可以考虑采用互斥锁(mutex)来保证线程安全性。比如可以使用std::lock_guardstd::mutex来在访问stack前加锁,访问结束后自动解锁,从而避免多个线程同时访问导致的竞争问题。总之,要保证stack的线程安全性,需要在多线程环境中加入互斥机制来避免竞争。如下为一个stack的ADT类。如果线程1判断了!s.empty(),线程2在其返回后删除了最后一个栈顶元素,则右图用户代码将导致未定义行为,使用mutex是无法解决此类问题的,本质是接口的问题。(因为成员函数返回后,其他成员函数可以自由访问栈)。stack线程不安全的本质原因是在内部接口设计时,没有引入数据保护机制。
接口的问题可能导致另外一种条件竞争,如下所示。
2.死锁问题以及解决办法
1)死锁:一对线程中的每一个都需要锁定一对相同互斥锁(多个互斥量的锁定)才能继续执行。在这一对线程执行过程中,都只成功锁定了一个互斥量,且已经锁定的互斥量互不相同。双方线程此时都在等待对方释放其持有的互斥锁,从而导致程序阻塞。
2)解决死锁的手段:
(1)同时锁定互斥量:(只是建议,也有可能因为用户代码的问题造成死锁且不容易被发现)建议使用相同顺序锁定两个互斥量:如果总是先锁定A再锁定B就不会碰到死锁。但是每个互斥锁都在保护同一个类的不同实例就没有这么简单了。比如在线程1执行swap(a,b),在线程2执行swap(b, a)。swap规定先锁定左参数的互斥量,再锁定右参数的互斥量。又比如交换执行线程1,线程2的join()成员函数就会轻易的造成死锁,这是用户代码层面的问题。(在线程1中join线程2对象,在线程1中join线程1对象)。
c++标准库提供了std::lock可以实现对两个或者多个互斥量的同时锁定且无死锁风险,推荐使用如下所示。
c++17使用新的RAII模板std::scope_lock<>提供了额外支持。它的作用与std::lock_gard<>完全等效,只是多提供了一个可变参数模板。
(2)避免嵌套锁定,如果锁定了一个互斥量就最好不要去锁定另外一个。保证一个线程只有一个互斥量。如果非要锁定多个互斥量则使用std::lock同时上锁。
(3)避免在持有锁时,调用用户提供的代码。这样可能会导致锁嵌套。
3.一种顺序上锁的方式-使用层次锁(c++标准库未提供层次锁支持)
这种对链表节点依次上锁的方式,给了我们一个提示,可以给节点划分层级去依次上锁。也就是层次锁。锁层次结构可以提供一种再运行时检查是否遵守顺序约定的方法。其思想是将应用程序划分层次。当代码尝试锁定一个互斥锁时,如果它已经持有较低层级的锁那么不允许锁定该互斥锁。
三.共享数据保护的备选功能
1.读写锁
使用场景:保护很少更新的数据结构。由单个写入者线程独占访问,或者由多个读取者线程共享并进行并发访问。C++17标准库提供两种互斥锁std::shared_mutex,std::shared_time_mutex
c++14仅具有std::shared_time_mutex。c++11不提供支持。
使用shared_lock锁定支持共享锁定资源的并发访问。使用互斥锁则独占资源。
2.递归锁