闭关之 C++ 并发编程笔记(三):有锁和无锁数据结构

第6章 设计基于锁的并发数据结构

  • 一种保护数据的方式是采用独立互斥和外部锁,第3章和第4章介绍过
  • 另一种方式则是专门为并发访问而自行设计数据结构。

6.1 并发设计的内涵

  • 线程安全的数据结构、满足以下条件
    • 多线程执行的操作无论异同,每个线程所见的数据结构都是自恰的
    • 数据不会丢失或破坏,所有不变量终将成立,恶性条件竞争也不会出现
  • 只有限定进行几种特定的并发访问,数据结构才能保证安全
  • 保护的范围越小,需要的串行化操作就越少,并发程度就可能越高。
  • 设计并发数据结构的指引
    • 我们需要考虑两方面
      • 确保访问安全,并实现真正的并发访问。
    • 数据结构的使用者须自行保证:
      • 在构造函数完成以前和析构函数开始之后,访问不会发生
    • 数据结构的设计者,需要思考下列问题。
      • 能否限制锁的作用域,从而让操作的某些部分在锁保护以外执行?
      • 数据结构内部的不同部分能否采用不同的互斥
      • 是否所有操作都需要相同程度的保护
      • 能否通过简单的改动,提高数据结构的并发程度,为并发操作增加机会,而不影响操作语义?
    • 这些问题全部都归结为一个核心问题:
      • 我们如何才可以只保留最必要的串行操作,将串行操作减少至最低程度,并且最大限度地实现真正的并发访问

6.2 基于锁的并发数据结构

  • 确保先锁定合适的互斥,再访问数据,并尽可能缩短持锁时间

6.2.1 采用锁实现线程安全的栈容器

  • Code_6_2_1
    • 可能引起死锁, 栈容器的使用者提出要求,负责保证避免死锁场景
      • 在持锁期间执行用户代码
        • 栈容器所含的数据中,有用户自定义的复制构造函数
        • 移动构造函数
        • 拷贝赋值操作符
        • 移动赋值操作符
        • 重载new操作符
      • 栈容器要插入或移除数据,在操作过程中数据自身调用了上述函数,则可能再进一步调用栈容器的成员函数,因而需要获取锁,但相关的互斥却已被锁住,最后导致死锁
    • 若栈容器还未构建完成,则其他线程不得访问数据
    • 只有当全部线程都停止访问之后,才可销毁栈容器
  • 缺点
    • 线程一旦为了获取锁而等待,容易退化成串行
    • 未提供任何等待/添加数据的操作
      • 栈容器满载数据而线程又等着添加数据,它就必须定期反复调用 empty()
    • 如果需要经常查验栈容器是否为空
      • 另行编写代码,以在外部实现 “等待-通知” 的功能(如利用条件变量)

6.2.2 采用锁和条件变量实现线程安全的队列容器

  • Code_6_2_2
    • 代码 push()调用 data_cond.notify_one()只会唤醒其中一个wait_and_pop()`
      • 若该觉醒的线程在执行 wait_and_pop() 时抛出异常,就不会有任何其他线程被唤醒
      • 如果不能接受这种行为方式, 可以有三种方式处理
        • data_cond.notify_one() 改为 data_cond.notify_all()
          • 这样就会唤醒全体线程,但要大大增加开销
          • 它们绝大多数还是会发现队列依然为空,只好重新休眠
        • 倘若有异常抛出,则在 wait_and_pop() 中再次调用 notify_one()
          • 从而再唤醒另一线程,让它去获取存储的值
        • std::shared_ptr<> 的初始化语句移动到 push() 的调用处
          • 令队列容器改为存储 std::shared_ptr<> ,而不再直接存储数据的值
          • Code_6_2_2_1
            • 好处
              • push()中,我们依然要为新的 std::shared_ptr<> 实例分配内存
                • 但可以脱离锁保护,减少锁的作用范围
  • 两个代码共同缺点
    • 都是由唯一的互斥保护整个数据结构,它所支持的并发程度因此受限。

6.2.3 采用精细粒度的锁和条件变量实现线程安全的队列容器

  • 并发目标
    • 最大程度实现真正的并发功能
    • 让尽可能多的操作有机会并发进行,所以希望持锁时长最短
  • Code_6_2_3
    • 无限队列
  • 有限队列
    • 其最大长度在创建之际就已固定
    • 一旦有限队列容量已满,再试图向其压入数据就会失败,或者发生阻塞,直到有数据弹出而产生容纳空间为止
    • 有限队列可用于多线程的工作分配,它能够依据待执行的任务的数量,确保工作在各线程中均匀分配
    • 能防止以下情形发生
      • 某些线程向队列添加任务的速度过快,远超线程从队列领取任务的速度
    • 基于无限队列 Code_6_2_3 的实现方式
      • 只需限制 push() 中的条件变量上的等待数量
      • 我们需要等待队列中的数据被弹出(由pop()执行),所含数据数目小于其最大容量,而不是等着有数据被压入而使队列非空

6.3 设计更复杂的基于锁的并发数据结构

6.3.1 采用锁编写线程安全的查找表

  • 标准库迭代器不适合并发,首先移除
  • 查找表上只有几项基本操作
    • 增加配对的键/值对
    • 根据给定的键改变关联的值
    • 移除某个键及其关联的值。
    • 根据给定的键获取关联值(若该键存在)
  • 选用 std::shared_mutex,它同时支持多个读线程和一个写线程
  • 设计采用精细粒度锁操作的map数据结构
    • 有3种常用的方法可以实现关联容器
      • 二叉树,如红黑树
      • 有序数组
      • 散列表
    • 要增加并发操作的机会,二叉树并不怎么具备潜力
      • 它每次查找或改动都要从根节点开始访问,因而必须对其加锁
      • 访问线程会逐层向下移动,根节点上的锁会随之释放。
        • 比起为整个数据结构单独使用一个锁,这种情形好不了多少
    • 有序数组则更差,因为它无法预知所需查找的目标的位置
    • 散列表比较适合
      • 假定散列表具有固定数量的桶,每个键都属于一个桶,键本身的值和散列函数决定键具体属于哪个桶
        • 可以安全地为每个桶使用独立的锁。
        • 若再采用共享锁,支持多个读线程或一个写线程,就会令并发操作的机会增加 N 倍
          • N 是桶的数目
      • 短处是,我们需要一个针对键的散列函数
        • std::hash<>
          • 具备针对基础类型的特化版本,如 intstd::string
    • Code_6_3_1

6.3.2 采用多种锁编写线程安全的链表

  • 链表所需要的操作
    • 向链表加入数据
    • 根据一定的条件从链表移除数据项
    • 根据一定的条件在链表中查找数据
    • 更新符合条件的数据
    • 向另一容器复制链表中的全部数据
  • 链表若要具备精细粒度的锁操作,则基本思想是让每个节点都具备自己的互斥
    • 如果链表增长,互斥数量也会变多
      • 好处是,可以在链表不同部分执行真正的并发操作
        • 每个操作仅仅需要锁住目标节点,当操作转移到下一个目标节点时,原来的锁即可解开
  • Code_6_3_2

总结

设计思想

  • 锁实现线程安全的栈容器 Code_6_2_1
  • 锁和条件变量实现线程安全的队列容器(无限队列) Code_6_2_3
  • 锁编写线程安全的查找表 Code_6_3_1
  • 锁编写线程安全的链表 Code_6_3_2

第7章 设计无锁数据结构

7.1 定义和推论

  • 阻塞型算法和阻塞型数据结构
    • 算法和数据结构中只要采用了互斥、条件变量或future进行同步操作,就称之为阻塞型算法和阻塞型数据结构
  • 阻塞型调用
    • 应用程序调用某些库函数,发起调用的线程便会暂停运行,即在函数的调用点阻塞,等到另一线程完成某项相关操作,阻塞才会解除,前者才会继续运行,这种库函数的调用被命名为阻塞型调用
  • 操作系统往往会把被阻塞的线程彻底暂停,并将其时间片分配给其他线程,等到有线程执行了恰当的操作,阻塞方被解除。
    • 恰当的操作可能是释放互斥、知会条件变量,或是为future对象装填结果值而令其就绪。
  • 非阻塞型算法和非阻塞型数据结构
    • 算法和数据结构若没有采用上述阻塞型库函数调用,则对应地称为非阻塞型(nonblocking)算法和非阻塞型数据结构

7.1.1 非阻塞型数据结构

  • 分辨该型别/函数属于哪一类
    • 无阻碍(obstruction-free)
      • 假定其他线程全都暂停,则目标线程将在有限步骤内完成自己的操作
    • 无锁(lock-free)
      • 如果多个线程共同操作同一份数据,那么在有限步骤内,其中某一线程能够完成自己的操作
    • 免等(wait-free)
      • 在某份数据上,每个线程经过有限步骤就能完成自己的操作,即便该份数据同时被其他多个线程所操作

7.1.2 无锁数据结构

  • 如果某数据结构具备无锁特性,那么它必须能够同时接受多个线程的访问,但多个线程所执行的操作不一定相同
    • 无锁队列准许一个线程压入数据,另一线程则同时弹出数据
      • 但若两个线程同时压入新数据,却有可能导致出错
    • 某线程访问该数据结构,操作系统的调度器却在操作到中途时令其停止,那么其他线程必须依然能分别完成自己的操作,而不必等待暂停的线程
  • 一旦在算法中对数据结构执行比较-交换操作,其中就通常会含有循环
  • 假设别的线程被暂停运行,而比较-交换操作最终得以成功执行,这份代码就是无锁实现
  • 否则,内含比较-交换操作的循环其实成了自旋锁,虽然不会造成阻塞,却不属于无锁实现

7.1.3 无须等待的数据结构

  • 无须等待的数据结构(wait-free data structure,以下简称“免等数据结构”)
    • 具备额外功能的无锁数据结构
    • 如果它被多个线程访问,不论其他线程上发生了什么,每个线程都能在有限步骤内完成自己的操作
    • 若多个线程之间存在冲突,导致某算法无限制地反复尝试执行操作,那它就是免等算法
  • 本章的大部分范例都具有上述特性
    • 它们都含有一个 while 循环,在里面执行 compare_exchange_weak()compare_exchange_strong()
    • 且循环次数不进行限制
    • 线程调度由操作系统掌控,可能导致某一线程的循环次数超乎寻常的多,而其他线程的循环次数却很少
    • 因此,以上方式都不是免等操作
  • 无锁数据结构和免等数据结构的实现极其困难,若要亲手实现,就需要强有力的理由
    • 为了令收益高于代价

7.1.4 无锁数据结构的优点和缺点

  • 采用无锁数据结构的原因

    • 最大限度地实现并发
    • 代码健壮性
  • 在基于锁的容器上,若某个线程还未完成操作,就大有可能阻塞另一线程,使之陷入等待而无法进行处理;而且,互斥锁的根本意图就是杜绝并发功能

  • 缺点

    • 免等数据结构的代码太容易出现偏差,最终写成自旋锁。
    • 我们无法限制多个线程访问无锁数据结构,必须谨慎行事,力求保持不变量成立,或选取别的可以一直成立的不变量作为替代
    • 留心施加在各项操作上的内存次序约束,数据的修改必须采用原子操作,以免出现与数据竞争相关的未定义行为
    • 算法很可能比别的算法复杂,即便没有其他线程同时访问数据结构,也依然要执行更多步骤。会降低无锁代码和免等代码的效率
  • 优点

    • 免等数据结构则完全无须等待
    • 代码健壮性
    • 由于无锁数据结构完全不含锁,因此不可能出现死锁
      • 但活锁(live lock)反机会而有出现
        • 假设两个线程同时更改同一份数据结构,若它们所做的改动都导致对方从头开始操作,那双方就会反复循环,不断重试,这种现象即为活锁
  • 在免等代码中,执行一项操作所需步骤的数目总是被设定了上限,所以不存在活锁问题

  • 无论是基于锁的数据结构,还是无锁数据结构,在提交代码之前,我们都有必要核查与性能相关的各项指标(其中包括最差等待时间、平均等待时间、整体执行时间,以及其他方面

7.2 无锁数据结构范例

  • 牢记于心
    • 只有原子标志 std::atomic_flag 的实现可以保证无锁
    • 在部分平台上,某些代码看似无锁,却用到了 C++ 标准库中的内部锁

7.2.1 实现线程安全的无锁栈

  • 比较-交换函数的功能
  • Code_7_2_1

7.2.2 制止麻烦的内存泄漏:在无锁数据结构中管理内存

  • 内存泄漏
    • 某线程删除了节点,而另一线程却仍持有指向该节点的指针,并要根据它执行取值操作。但对于任何正常、合理的C++程序,内存泄漏均不可接受,必须设法杜绝
  • 我们需要针对节点实现特定用途的垃圾回收器
  • Code_7_2_1

7.2.3 运用风险指针检测无法回收的节点

  • 术语“风险指针”是指 Maged Michael 发明的一种技法
    • 这个术语得名的缘由是,若某节点仍被其他线程指涉,而我们依然删除它,此举便成了“冒险”动作
  • 基本思想
    • 假使当前线程要访问某对象,而它却即将被别的线程删除,那就让前者设置一指涉目标对象的风险指针,以通知其他线程删除该将产生实质风险
    • 若程序不再需要那个对象,风险指针则被清零
    • 线程要删除对象时,必须先在系统中核查隶属其他线程的风险指针
      • 如果目标对象没有被任何风险指针所指涉,即可安全删除,否则必须留待以后处理
  • 程序把这些滞留对象组织成一个链表,按时、定期检查,逐个判定能否删除
  • 通过 C++ 实现
    • 首先,我们要确定存储风险指针的内存区域,才可通过该指针访问目标节点
      • 这块区域必须对全体线程可见、且每个线程都配备自己专属的风险指针
    • 此后,若要依据风险指针读取指向目标,就需把指针设置为相关的值
      std::shared_ptr<T> pop()
      {
          std::atomic<void*>& hp=get_hazard_pointer_for_current_thread();
          //程序先读取旧的head指针
          node* old_head=head.load(); //1
          node* temp;
          //设置风险指针,中间可能存在时间空隙,
          //所以风险指针的设置操作必须配合while循环,
          //从而确保目标节点不会在间隙内删除
          do
          {
              temp=old_head;
              //然后设置风险指针
              hp.store(old_head);//2
              old_head=head.load();
          //只要head指针与风险指针的目标不一致,我们便不断循环对比两者,直到它们变成一致为止
          } while(old_head!=temp);//3
          // ...
      }
      
  • 风险指针需满足一项特殊要
    • 在它指涉的节点被删除以后,我们依然能安全使用该指针的值。
    • 若采用默认的 new 和 delete 操作,则此模式在技术上属于未定义行为
    • 为满足要求,我们需自行实现 new 和 delete 操作(操作符重载)
    • 或采用定制的内存分配器(allocator)
  • 实现 Code_7_2_3
    • 例子的效率不高,有更好的实现方式
    • 实际开发中最好不使用
  • 风险指针还有另一个缺点
    • IBM公司就这项技术申请了专利

7.2.4 借引用计数检测正在使用中的节点

  • 代码略(7.2.5 有最优实现,该节内容与上一节思想类似)
  • 如果想要深入理解,可以看一下这一节的内容
  • 本章的原子操作一直采用默认的std::memory_order_seq_cst次序
  • 放宽某些内存次序的约束。对于这个数据结构的使用者,我们不希望其带来任何不必要的额外开销

7.2.5 为无锁栈容器施加内存模型

  • 采用更加宽松的内存次序,得以提升性能而不影响程序的正确运行
  • Code_7_2_5

7.2.6 实现线程安全的无锁队列

  • 仅能服务单一生产者和单一消费者的无锁队列
    • Code_7_2_6
  • 支持多线程访问的无锁队列(略)
    • 实现挺复杂的,不可能短时间看明白
    • 如果实际需要,可以花时间再看一下本节后续内容
  • 必须牢记于心
    • 要评判哪个内存分配器相对更优,唯一的方法就是进行测试,对比前后版本的代码性能。
    • 常见的内存分配优化技术包括
      • 为每个线程配备独立的内存分配器,引入空闲内存列表循环使用节点,
      • 而不是把它们交回内存分配器处理。

7.3 实现无锁数据结构的原则

  • 原则1:在原型设计中使用std::memory_order_seq_cst次序
    • 基本操作均正常工作后,我们才放宽内存次序约束
  • 原则2:使用无锁的内存回收方案
    • 锁代码中的一大难题是内存管理
    • 只要目标对象仍然有可能正被其他线程指涉,就不得删除。然而,为了避免过度消耗内存,我们还是想及时删除无用的对象。
    • 确保内存回收满足安全要求
      • 暂缓全部对象的动作、等到没有线程访问数据结构的时候,才删除待销毁的对象
      • 采用风险指针,以辨识特定对象是否正在被某线程访问;
      • 就对象进行引用计数,只要外部环境仍正在指涉目标对象,它就不会被删除。
    • 种方法的关键思想
      • 都是以某种方式掌握正在访问目标对象的线程数目,仅当该对象完全不被指涉的时候,才会被删除
    • 另一种处理方法是重复使用节点,等到数据结构销毁时才完全释放它们
      • 由于重用了节点,因此所分配的内存便一直有效,代码从而避开了一些涉及未定义行为的麻烦细节。
      • 缺点
        • 它导致程序频频出现被称为“ABA问题”的情形
  • 原则3:防范ABA问题
    • 定义

      • 一个线程先读取共享内存数据值A,随后因某种原因,线程暂时挂起,同时另一个线程临时将共享内存数据值先改为B,随后又改回为A。随后挂起线程恢复,并通过CAS比较,最终比较结果将会无变化。这样会通过检查,这就是ABA问题 (引用)
    • 在所有涉及比较-交换的算法中,我们都要注意防范ABA问题

    • 本章内容所涉及的算法均不存在ABA问题,但它很容易由无锁算法的代码引发

    • 该问题最常见的解决方法之一是,在原子变量 x 中引入一个 ABA 计数器。将变量 x 和计数器组成单一结构,作为一个整体执行比较-交换操作

  • 原则4:找出忙等循环,协助其他线程
    • 假设按照调度安排,某线程先开始执行,却因另一线程的操作而暂停等待,那么只要我们修改操作的算法,就能让前者先完成全部步骤,从而避免忙等,操作也不会被阻塞

总结

设计思想

  • 无锁栈容器 Code_7_2_5
  • 无锁队列(单一生产者和单一消费者)Code_7_2_6
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值