第3章 在线程间共享数据
线程并行的关键优点之一:简单,直接的共享数据的潜力
但共享数据的不正确使用是与并发有关的错误的最大诱因之一
3.1 线程之间共享数据的问题
共享数据只读,没有问题
同时一个或多个线程修改数据,就可能有很多的麻烦。
不变量的概念
修改线程间共享数据最简单的潜在问题就是破坏不变量
最常见的诱因:竞争条件。
3.1.1 竞争条件
数据竞争
未定义行为
3.1.2 避免有问题的竞争条件
几种办法:
1. 最简单:保护机制封装数据结构 ———— 本章阐述
2. 修改数据结构的设计及其不变量 ———— 无锁编程 第五章,第七章
3. 将对数据结构的更新作为一个“事务” ———— 本书不讲
进入本章正题:
3.2 用互斥元保护共享数据
为最常见的数据保护机制
伴随的问题: a.死锁
b.保护过多或过少的数据
3.2.1 使用C++的互斥元
std::mutex,但不使用它的lock和unlock
使用std::lock_guard
程序实例分析:
对全局实施保护 ————> 作为成员变量来保护 ————> 成员函数返回指针或引用,出bug
引出下一小节:
3.2.2 为保护共享数据精心组织代码
问题:
1. 接上小节:迷路的指针或引用
2. 没有向其调用的不在你掌控之下的函数传入迷路的指针或引用
3. 特别危险的:函数通过函数参数或其他方式在运行时提供的
程序实例分析:
结论:不要将对受保护数据的指针和引用传递到锁的范围之外。
但是绝非唯一可能的隐患,引出下一小节:
3.2.3 发现接口中固有的竞争条件
双向链表的例子
分析:
1. 这是一个典型的竞争条件: empty 和 pop
2. 还有另一个可能的竞争条件: top 和 pop
这要求对接口进行更加激进的改变。
有替代方案,但是有代价。
1.选项1.传入引用
2.选项2.要求不引发吟唱的拷贝构造函数或移动构造函数
3.选项3.放回指向栈顶的指针
4.选项4.同时提供上面3个选项
5.一个线程安全栈的示范定义
代码实例
结论:
1.接口问题中的竞争条件基本上因为锁定的粒度过小而引起的。
2.也可以由锁定的粒度过大而引起:消除了并发的优势
3.细粒度锁定方案的问题:需要多个互斥元 ————> 引发死锁问题。
3.2.4 死锁:
常见的建议:始终使用相同的顺序锁定两个互斥元
std::lock 可以解决这个问题:可以锁定多个互斥元,没有死锁风险
但是如果分别获取锁,就没用了
3.2.5 避免死锁的进一步指南:
规则可归为一个思路:如果另一个线程可能在等你,那你就别等它。
1. 避免嵌套锁
2. 在持有锁时,避免调用用户提供的代码
3. 以固定顺序获取锁
4. 使用锁层次
程序实例分析:
5. 将这些准则扩展到锁之外
虽然std::lock和std::lock_guard涵盖了大多数简单锁定的情况,但需要更大的灵活性
引出下一小节:
3.2.6 使用std::unique_lock灵活锁定:
使用std::unique_lock和std::defer_lock
小问题:占用空间多,速度略慢
代价:
比较std::lock_guard和std::unique_lock,推荐前者。但后者也有适用情形。
引出下一小节:
3.2.7 在作用域之间转移锁的所有权
…………………………
3.2.8 锁定在恰当的粒度
锁粒度的含义
原则:
1.选择足够粗的粒度
2.确保只在需要锁的操作中持有锁
3.特别的,在持有锁的时候,不做耗时操作
结论:
1.只应该以执行要求的操作所需的最小可能的时间去持有锁
2.如果不能在操作的整个过程中持有锁,就暴露在竞争条件中
但有时根本没有一个合适的粒度级别, 引出下一小节:替代机制
3.3 用于共享数据保护的替代工具
特别极端的情况:只需要在初始化时才需要保护
3.3.1 在初始化时保护共享数据
std::once_flag 和 std::call_once
更普遍的场景:“共享读”的数据结构偶尔需要更新,引出下一小节:
3.3.2 保护很少更新的数据结构
读写互斥元 ————第八章
3.3.3 递归锁
锁定已经拥有的互斥元是错误的,
std::recursive_mutex
不推荐
3.4 小结
--
修改:CyberPunker FROM 203.218.252.*
FROM 203.218.252.*