C++ 并发编程学习03
共享数据带来的问题
使用互斥量保护数据
数据保护的替代方案
3.1 共享数据带来的问题
修改共享数据,致使不变量遭到了破坏,可能会造成并行中常见的错误: 条件竞争(race condition)
并发中 条件竞争 的形成,取决于一个以上的线程的相对执行顺序,每个线程都抢着完成自己的任务。并发中对数据的条件竞争通常表示为 恶性条件竞争
避免恶性条件竞争
避免 恶性条件竞争,最简单的办法就是对数据结构采用某种保护机制,确保只有进行修改的线程才能看到不变量被破坏时的中间状态。从其他访问线程的角度来看,修改不是已经完成了,就是还没开始。
另一个选择是对数据结构和不变量的设计进行修改,修改完的结构必须能完成一系列不可分割的变化,也就是保证每个不变量保持稳定的状态,这就是所谓的无锁编程。
另一种处理条件竞争的方式是,使用事务的方式去处理数据结构的更新。所需的一些数据和读取都存储在事务日志中,然后将之前的操作合为一步,再进行提交
3.2 使用互斥量保护共享数据
当访问共享数据前,将数据锁住,在访问结束后,再将数据解锁。线程库需要保证,当一个线程使用特定互斥量锁住共享数据时,其他的线程想要访问锁住的数据,都必须等到之前那个线程对数据进行解锁后才能访问,保证了所有线程可以看到共享数据,而不破坏不变量。
3.2.1 使用互斥量
通过实例化std::mutex
创建 互斥量实例 ,通过 成员函数 lock()
对互斥量上锁,unlock()
进行解锁。
C++标准库为互斥量提供了一个 RAII语法 的 模板类 std::lock_guard
,在 构造 时就能提供已锁的互斥量,并在 析构 的时候进行解锁,从而保证了一个已锁互斥量能被正确解锁。
将互斥量和需要保护的数据放在同一类中时, 当其中一个成员函数 返回 的是保护数据的指针或引用时,会破坏数据 。具有访问能力的指针或引用可以访问(并可能修改)被保护的数据,而不会被互斥锁限制,所以要确保互斥量能锁住数据的访问,并且 不留后门 。
3.2.2 用代码来保护共享数据
使用 互斥量 来保护数据,并不是仅仅在每一个成员函数中都加入一个std::lock_guard
对象那么简单,一个 指针或引用 ,也会让这种保护形同虚设。在确保成员函数 不会传出 指针或引用的同时,检查成员函数是否通过指针或引用的方式来调用也很重要。
3.2.3 定位接口间的条件竞争
STL中
的stack类
是线程不安全的,当你模仿着想写一个属于自己的线程安全的类Stack
时,你在 push 和 pop 等操作得时候,加了互斥锁保护数据。但是在多线程环境下使用使用你的Stack类
的时候,却仍然有可能是线程不安全的,为什么?
假设Stack
类的接口如下:
1 | class Stack |
类中的每个函数都是线程安全的,但是 组合起来却不是。加入栈中有 9,3,8,6
四个元素,现在使用两个线程分别取出栈中的元素进行处理,如下:
ThreadA | ThreadB |
---|---|
int v = st.top() //6 |
|
int v = st.top() //6 |
|
st.pop(); //弹出6 |
|
st.pop(); //弹出8 |
|
process(v);//处理6 |
|
process(v);//处理6 |
可以发现在这种执行顺序下, 栈顶元素被处理了两遍,而且多弹出了一个元素 8 ,导致 8 没有被处理!这就是由于接口设计不当引起的竞争。
解决办法:
选项1. 传入一个引用
选项2. 无异常抛出的拷贝构造函数或移动构造函数
选项3. 返回指向弹出值的指针
选项4. 1+2 或 1 + 3
解决方案1: 将两个接口合并为一个
1 | class Stack |
但是这样修改之后虽然是 线程安全 的,但是 并不是异常安全 的。这也是为什么STL中栈的出栈操作分解成了两个步骤的原因。参考
具体这个 异常安全 问题:如果向上面的代码那样,将 pop() 函数的的“弹出值”同时作为返回值返回到调用函数时,调用函数抛出了一个异常会怎么样?因为在将返回值返回到调用函数时发生了内存的拷贝,但是不能保证每次拷贝都是成功的,一旦失败,那么“弹出值”已经从栈上移除了,这样即没得到这个返回值,还丢失了原来栈上的值。这也就是为什么std::stack
将这个操作分成了两步:先获取顶部元素(top()), 然后再从栈中移除(pop()),这样就在 不能安全的将元素拷贝出去的情况下,栈中的数据还依旧存在,没有丢失。来源
解决方案2——线程安全的栈:传入一个引用或者返回指向弹出值的指针(选项一 + 选项三)
1 |
|