本章主要内容

  • 设计并发数据结构的含义
  • 设计指南
  • 并发数据结构的示例实现

在上一章中我们了解了底层原子操作和内存模型。本章我们先把底层的细节放一放(尽管在第7章我们将需要它们),探讨一下数据结构。

为编程问题选择数据结构可能是整个解决方案的关键部分,并行程序也不例外。如果一个数据结构需要被多个线程访问,要么它完全不可变,因此数据永远不会变化,并且没有必要同步,要么程序必须设计成确保变动在线程间被正确的同步。一种选择是使用独立的互斥锁以及外部加锁来保护数据,比如使用我们在第3和第4章讨论的技术,另一种选择是设计自身支持并发访问的数据结构。

当设计并发数据结构时,可以使用前面章节提及的应用于多线程程序的基础构件块,比如:互斥锁和条件变量。实际上,你已经看过了几个示例,这些示例展示了如何组合这些构建块来编写对多线程并发访问安全的数据结构。

在本章中,我们将从为并发设计数据结构的一些通用指南开始。然后,我们将使用锁和条件变量的基本构建块,并在转向更复杂的数据结构之前重新讨论这些基本数据结构的设计。在第7章中,我们将看到如何回归基础,并使用第5章中描述的原子操作来构建无锁的数据结构。

好了!言归正传,让我们来看一下设计并发数据结构都需要什么。

6.1 并发设计的含义

从基本层面上讲,为并发设计数据结构意味着,多个线程可以并发的访问这个数据结构,不管线程执行的是相同还是不同的操作,并且每一个线程都能看到数据结构的前后一致的视图。没有数据丢失或者损坏,所有的不变量都被支持,且没有有问题的竞争条件。这样的数据结构,称之为线程安全(thread-safe)的数据结构。通常情况下,一个数据结构只对特殊类型的并发访问是安全的。也许有可能让多个线程并发地对数据结构执行一种类型的操作,而另一种操作则需要由单个线程独占访问。或者,如果多个线程正在执行不同的操作,那它们并发访问数据结构可能是安全的,但多个线程执行相同的操作就会有问题。

然而,真正为并发而设计远不止这些:真正的设计意味着要为线程提供并发访问数据结构的机会。从本质上讲,互斥锁提供了互斥:一次只能有一个线程获得互斥锁。互斥锁保护数据结构是通过显式地阻止对它所保护数据的真正并发访问来实现的。

这称为串行化(serialzation):线程轮流访问被互斥锁保护的数据。它们必须串行而非并发的访问它。因此,必须仔细考虑数据结构的设计,使得能够真正的并发访问。虽然有些数据结构比其他数据结构具有更大的并发范围,但在所有情况下,其思想都是相同的:受保护的区域越小,串行化的操作就越少,并发的潜力也就越大。

在进行数据结构的设计之前,让我们快速的浏览一下并发设计的指南。

6.1.1 设计并发数据结构的指南

之前提过,当设计并访问的数据结构时,需要考虑两个方面:确保访问安全以及允许真正的并发访问。在第3章中,已经介绍了如何使数据结构是线程安全的基础知识:

  • 确保没有线程能够看到数据结构的不变量被另一个线程破坏的状态。
  • 通过提供完整操作的函数,而非一个个操作步骤的函数来小心避免接口固有的竞争条件。
  • 注意数据结构在有异常时的行为,从而确保不变量不会被破坏。
  • 通过限制锁的范围以及避免嵌套锁,将死锁的概率降到最低。

在思考这些细节之前,想想要对数据结构的用户施加什么约束也是很重要的;如果线程通过一个特定的函数对数据结构进行访问,其他线程能安全调用哪些函数?

这是一个需要考虑的关键问题,通常,构造函数和析构函数需要互斥地访问数据结构,但是需要由用户确保它们不会在构造函数完成之前或者析构函数开始以后被访问。如果数据结构支持赋值操作,swap()或拷贝构造,作为数据结构的设计者,你需要决定这些操作与其他操作并发调用是否安全,或者它们是否要求用户确保独占访问,尽管大多数用于操作数据结构的函数可以从多个线程并发地调用而没有任何问题。

第二个需要考虑的方面是允许真正的并发访问。在这个方面我没法提供太多的指南。相反,作为一个数据结构的设计者,需要问自己以下问题:

  • 是否可以限制锁的作用范围,以允许操作的某些部分在锁外执行?
  • 数据结构不同部分能否被不同的互斥锁保护?
  • 所有的操作需要同一级别的保护吗?
  • 是否可以对数据结构进行简单的修改,以增加并发访问的机会,并且不影响操作语义?

所有这些问题都基于一个思想:如何最小化必须的串行操作,并且使得真实的并发最大化?就数据结构而言,允许多线程并发的只读访问,而修改线程必须互斥访问的情况很普遍。这是通过使用像std::shared_mutex这样的结构来支持的。类似地,你很快就会看到,在串行线程尝试执行相同操作的同时,数据结构支持执行不同操作的线程并发地访问也很普遍。

最简单的线程安全数据结构,通常使用互斥锁来保护数据。尽管这样做存在一些问题,但就像你在第3章中看到的,确保一次只有一个线程访问数据结构相对比较简单。为了让你更容易设计线程安全的数据结构,我们将在本章继续研究这种基于锁的数据结构,并将无锁并发数据结构的设计留到第7章讨论。

6.2 基于锁的并发数据结构

设计基于锁的并发数据结构,都是为了确保在访问数据时锁住正确的互斥锁,并且持有锁的时间最短。对于只有一个互斥锁的数据结构来说,这很困难。你需要确保数据不能在互斥锁的保护之外被访问,并且接口中没有固有的竞争条件,就如第3章中看到的那样。如果使用不同的互斥锁来保护数据结构中不同的部分,问题会进一步恶化,如果操作需要锁住多个互斥锁时,现在也可能产生死锁。所以相比单一互斥锁的设计,使用多个互斥锁的数据结构需要更加小心。

在本节中,你将应用6.1.1节中的指南来设计一些简单的数据结构,通过使用互斥锁来保护数据。在每个例子中,都是在确保数据结构保持线程安全的前提下,找出更大并发的机会。

我们先来看看第3章中栈的实现,它是最简单的数据结构,且只使用了一个互斥锁。那么它是线程安全的吗?它离真正的并发访问有多远呢?

6.2.1 使用锁的线程安全栈

下面的清单复制了第3章中线程安全的栈。目的是编写一个类似于std::stack<>的线程安全的数据结构,它支持将数据项推入栈中并再次弹出它们。

我们依次看下每条指南以及它们是如何应用在这里。

首先,如你所见,基本的线程安全是通过使用互斥锁m上的锁保护每个成员函数提供的。这将确保在任何时候只有一个线程在访问数据,因此只要每个成员函数保持不变量,就没有线程能看到被破坏的不变量。

其次,在empty()和pop()成员函数之间有潜在的竞争条件,不过代码会在pop()函数持有锁的时候,显式的查询栈是否为空,所以这里的竞争条件没有问题。通过直接返回弹出的数据项作为调用pop()的一部分,避免了分离的top()和pop()成员函数(std::stack<>类似)之间潜在的竞争条件。

然后,栈中也有一些潜在抛异常的地方。对互斥锁上锁可能会抛出异常,但这种情况不仅极其罕见的(这意味着互斥锁有问题,或者缺乏系统资源),而且它是每个成员函数的第一个操作。由于没有数据被修改,所以是安全的。解锁互斥锁不会失败,所以总是安全的,并且使用std::lock_guard<>确保了互斥锁不会一直处于上锁的状态。

对data.push()①的调用可能会抛出一个异常,只要拷贝/移动数据值抛出一个异常,或者可分配的内存不足。不管是哪种情况,std::stack<>都能保证是安全的,所以也没有问题。

在第一个重载的pop()中,代码本身可能会抛出一个empty_stack的异常②,但由于什么都没有修改,所以是安全的。创建res③可能会抛出一个异常,有几个方面的原因:对std::make_shared的调用,可能因为无法为新对象以及引用计数需要的内部数据分配出足够的内存而抛出异常,或者在拷贝/移动到新分配内存的时候,返回的数据项的拷贝构造或移动构造函数可能抛出异常。两种情况下,C++运行库和标准库会确保没有内存泄露,并且新创建的对象(如果有的话)会被正确的销毁。因为仍然没有对栈进行任何修改,所以也不会有问题。调用data.pop()④保证不会抛出异常,随后是返回结果,所以这个重载的pop()函数是异常安全的。

第二个重载的pop()类似,不过这次是在拷贝赋值或移动赋值时可能抛出异常⑤,而不是在构造新对象和std::shared_ptr实例时。再次,直到调用data.pop()⑥(pop仍然保证不会抛出异常)前,没有修改数据结构,所以这个函数也是异常安全的。

最后,empty()不会修改任何数据,所以也是异常安全的。

这里有几个可能导致死锁的机会,因为你在持有锁的时候调用了用户代码:数据项上的拷贝构造或移动构造(①,③)和拷贝赋值或移动赋值操作⑤,也可能是用户自定义的new操作符。如果这些函数或者调用了栈上的成员函数(而栈正在插入或移除数据项),或者需要任何类型的锁,而在调用栈成员函数时又持有了另一把锁,那么就有可能出现死锁。但明智的做法是要求栈的用户负责确保这一点;你不能期望在不拷贝或不为它分配内存的情况下将数据项添加到栈或从栈中删除。

由于所有成员函数都使用std::lock_guard<>保护数据,所以不管多少线程调用栈成员函数都是安全的。唯一不安全的成员函数是构造函数和析构函数,但这不是问题;对象只能被构造一次,也只能被销毁一次。调用一个不完全构造的对象或是部分销毁的对象的成员函数永远都不可取,不管并发与否。因此,用户必须确保其他线程直到栈完全构造才能访问它,,并且必须确保在栈对象销毁前,所有线程都已经停止访问栈。

尽管多个线程并发调用成员函数是安全的,但由于使用了锁,每次只有一个线程在栈数据结构中做一些工作。线程的串行化会潜在的限制应用程序的性能,因为这里会有严重的锁争用:当一个线程在等待锁时,它没有做任何有用的工作。同样,栈也没有提供什么方法等待添加一个数据项,所以如果线程需要等待时,它必须周期性地调用empty()或pop(),并且捕获empty_stack异常。如果这种场景是必须的,那这种栈实现就是个糟糕的选择,因为等待线程要么消耗宝贵的资源去检查数据,要么要求用户编写外部等待和通知的代码(例如,使用条件变量),这就使内部上锁没有必要,因而造成浪费。第4章中的队列展示了一种使用数据结构内部的条件变量将这种等待合并到数据结构本身的方法,接下来我们看一下这个。

6.2.2使用锁和条件变量的线程安全队列

清单6.2复制了第4章中的线程安全队列,就像栈是仿照std::stack<>一样,这个队列也是仿照了std::queue<>。再次,接口不同于标准容器适配器,因为实现的数据结构需要支持多线程并发访问。

除了在push()①中调用data_cond.notify_one(),以及wait_and_pop()②③外,清单6.2中队列的实现与6.1清单中的栈类似。两个重载的try_pop()几乎和清单6.1中一样,只是在队列为空时不抛异常,取而代之返回一个bool值表示是否检索到值或者一个NULL指针(对应返回指针的重载版本)如果没有值可以检索的话。这也是实现栈的一个有效方式。如果排除wait_and_pop()函数,对栈的分析在这里也同样适用。

新的wait_and_pop()函数解决了在栈中碰到的等待队列条目的问题;比起持续调用empty(),等待线程调用wait_and_pop()函数并且数据结构使用条件变量来处理等待。对data_cond.wait()的调用,直到队列中至少有一个元素时才会返回,所以不用担心会出现空队列的情况,并且数据仍然被互斥锁保护。因此,这些函数不会添加任何新的竞争条件或死锁的可能性,并且将支持不变量。

在异常安全性方面有一个细微的变化,当一个条目被推入队列时,如果有多个线程在等待,那么只有一个线程会被data_cond.notify_one()唤醒。但是,如果这个线程在wait_and_pop()中抛出一个异常,比如当构造新的std::shared_ptr<>对象④时,那么没有其他线程被唤醒。这种情况不可接受,调用可以替换成data_cond.notify_all(),它将唤醒所有的工作线程,代价就是大多线程发现队列依旧是空时,重新进入休眠状态。第二种替代方案是,有异常抛出的时,让wait_and_pop()函数调用notify_one(),从而让另一个线程可以去尝试检索存储的值。第三种替代方案是,将std::shared_ptr<>的初始化过程移到push()中,并且存储std::shared_ptr<>实例,而不是直接使用数据值。将std::shared_ptr<>从内部std::queue<>中拷出不会抛出异常,这样wait_and_pop()又是安全的了。下面的程序清单,就是基于这种思路修改的。

通过std::shared_ptr<>持有数据的影响比较直接:通过引用变量来接收新值的pop函数现在必须对存储的指针解引用①②;并且,在返回给调用者前,返回std::shared_ptr<>实例的pop函数可以从队列中检索它③④。

通过std::shared_ptr<>持有数据还有个好处:在push()⑤中分配新实例可以在锁外面完成,而在清单6.2中,只能在pop()持有锁时完成。因为内存分配是个典型的代价高昂的操作,这有利于队列的性能,因为它减少了持有互斥锁的时间,并允许其他线程同时在队列上执行操作。

如同栈示例,使用互斥锁来保护整个数据结构限制了该队列的并发支持;尽管在不同的成员函数中,队列上可能阻塞多个线程,但一次只能有一个线程开展工作。但是部分限制来自于实现中使用了std::queue<>;通过使用标准容器,你现在可以决定数据项是否受保护。通过控制数据结构的实现细节,你可以提供更细粒度的锁从而实现更高级别的并发。

6.2.3使用细粒度锁和条件变量的线程安全队列

在清单6.2和6.3中,有一个受保护的数据项(data_queue)和一个互斥锁。为了使用细粒度锁,需要查看队列内部的组成部分,并将一个互斥锁与每个不同的数据项关联起来。

最简单的队列是单链表,如图6.1所示。队列包含一个头指针,指向链表中的第一个项,然后每一项指向下一项。从队列中删除数据项,是用指向下一项的指针替换头指针,然后将之前头指针的数据返回。

数据项从队列的另一端添加到队列。为了做到这点,队列还有一个tail指针,它指向链表中的最后一项。新节点的添加是通过改变最后一项的next指针,让它指向新的节点,然后更新tail指针指向这个新的数据项。当链表为空时,头/尾指针都为NULL。

图6.1 用单链表表示的队列

下面的清单显示了这个队列的简单实现,它基于清单6.2中队列接口的简化版本;只有一个try_pop()函数,没有wait_and_pop(),因为这个队列只支持单线程使用。

首先,注意清单6.4中使用了std::unique_ptr<node>来管理节点,因为这能保证当不再需要它们的时候,它们(以及它们引用的数据)会自动删除,而不必使用显式的delete。这个所有权链的管理从head开始,tail是指向最后一个节点的裸指针,因为它需要引用std::unique_ptr<node>已经拥有的节点。

虽然这个实现在单线程环境工作的很好,但当在多线程下尝试使用细粒度锁时,有几个事情会带来麻烦。因为在给定的实现中有两个数据项(head①和tail②);原则上可以使用两个互斥锁来分别保护头和尾指针,但这样做会有几个问题。

最明显的问题就是push()可能同时修改head⑤和tail⑥,所以它必须锁住两个互斥锁。尽管很不幸,但这倒不算是太大的问题,因为锁住两个互斥锁是可能的。关键的问题是push()和pop()都能访问next指针指向的节点:push()更新tail->next④,然后try_pop()读取head->next③。如果队列中只有一个元素,那么head==tail,所以head->next和tail->next是同一个对象,并且这个对象需要保护。由于不同时读取head和tail的话,没法区分它们是否是同一个对象,你现在必须在push()和try_pop()中锁住同一个锁,所以,也没比以前好多少。那有什么办法摆脱这个困境吗?

qt 5编程入门(第2版)_《C++并发编程实战第2版》第六章:设计基于锁的并发数据结构(1/3)...相关推荐

  1. 树莓派python编程入门先学什么_树莓派Python编程入门与实战

    树莓派Python编程入门与实战 编辑 锁定 讨论 上传视频 本词条缺少概述图,补充相关内容使词条更完整,还能快速升级,赶紧来编辑吧! 树莓派是一个只有信用卡大小的裸露电路板,它也是一个运行开源Lin ...

  2. python单片机编程入门先学什么_编程入门必看:带你零基础了解编程和编程语言,入门应该学什么?...

    编程入门 什么是编程 我们通过有固定格式和固定词汇的"语言"来控制他人,让他人为我们做事情.语言有很多种,包括汉语.英语.法语.韩语等,虽然他们的词汇和格式都不一样,但是可以达到同 ...

  3. 1800个python编程入门必备英语词汇_1800个Python编程入门必备英语词汇整理!Word版词汇资料免费赠...

    为了方便大家更好的入门python学习,小编已经整理好了python语言入门常用的英文单词 由于篇幅限制,小编仅能截取部分资料截图,需要的小伙伴可以找小编领取完整版 Word版单词资料领取方式:转发文 ...

  4. python交互式编程入门先学什么_为什么 Python 对于编程入门学习来说,是一门很棒的语言...

    在这篇文章里,我会来阐述下为什么我觉得 Python 对于计算机编程入门教学来说是一门很棒的编程语言(对基础编程课程更多观点可以查看这篇文章).这也是从我针对初学者Python 编程教学过程中获得并总 ...

  5. python编程从入门到精通实践_《Python编程:从入门到实践》总结_Day01

    前言 是在原有文章的基础上直接扩充更新还是将其作为单独的系列文章呢?思虑再三,还是决定把接下来的Day01-Day05的总结独立出来.此系列是关于<Python编程:从入门到实践>的总结, ...

  6. python机械编程入门先学什么_编程入门先学什么

    很多同学在后台问我,编程入门学什么? 关于这个问题真不是一两句话就可以解释清楚的,所以,我写这篇文章. 希望准备学习编程的朋友能有所收获. 1.学什么好呢? 其实做这个还是挺害怕的 因为我在朋友圈发问 ...

  7. 弯管机编程软件电脑版_布丁少儿编程电脑版

    布丁少儿编程电脑版是一款青少年编程在线学习软件,通过布丁少儿编程电脑版可以提高编程思想,逻辑思维能力,布丁少儿编程电脑版通过趣味游戏的方式让小朋友了解编程. 软件优势 1.开发孩子的逻辑思维能力 2. ...

  8. 3d数学基础:图形和游戏开发(第2版)_游戏引擎编程需要哪些基本数学知识?

    现今,想要从头写一个功能强大的3D引擎,个人的力量恐怕难以胜任,即使能力足够,时间恐怕也不允许.在这个美好的开源时代,你只需具备修改各种引擎的能力便足以满足开发游戏的各项需求.现代游戏引擎的复杂级别已 ...

  9. 第一章:你的编程入门了吗?养成良好的编程思维

    我今天看到一个问答:你什么时候觉得自己编程入门了? 我是一个有十年编程经验的程序员,使用过C++,c语言,python,php,Scala等开发语言,做过小程序,使用汉语编程语言中的神器易语言写过工具 ...

最新文章

  1. UI培训教程分享:常用的商业插画风格有哪些?
  2. java 获取绝对路径
  3. Softer-NMS:CMU旷视最新论文提出定位更加精确的目标检测算法
  4. android studio快捷键大全
  5. what is your judgement basis?
  6. 云原生应用程序运行时 Kyma 的主要特性介绍
  7. 将Auth0 OIDC(OAUTH 2)与授权(组和角色)集成
  8. 工业以太网交换机有多少个快速以太网接口?
  9. 入门实践,Python数据分析
  10. 4个数之和 4Sum II
  11. 求一天的起始和结束(时间戳)和一个月的第一天和最后一天
  12. 阿里云OSS服务开通STS安全令牌
  13. 浅释丹道筑基功―—―混元桩【转载】
  14. 用pyecharts画地图(世界地图、中国省级地图、市级地图、某省市级地图、某市县级地图)
  15. RHEL5.5下载地址及安装序列号
  16. MapReduce稍微高级编程之PageRank算法的实现
  17. 我的第三本译作《机器学习即服务》上架啦
  18. 自定义整型转字符串函数
  19. 基于java的蜘蛛纸牌游戏的设计与实现
  20. 九、CentOS7安装HDF5

热门文章

  1. 让zabbix图像中文不再是乱码
  2. php __FILE__和$_SERVER['SCRIPT_FILENAME']区别
  3. 在 CCR 环境中使用 Exchange 命令行管理程序移动存储组和数据库
  4. 基4fft算法的蝶形图_原地且自动整序的FFT算法
  5. 详解:设计模式之-适配器模式
  6. python抓包代码_Python抓包并解析json爬虫的完整实例代码
  7. MySQL数据库是非关系_MySQL(数据库)基础知识、关系型数据库yu非关系型数据库、连接认证...
  8. [转载] JAVA语言程序设计(基础篇)第十版课后题答案(第一章)
  9. ruby hash方法_Ruby中带有示例的Hash.key?(obj)方法
  10. filterwriter_Java FilterWriter flush()方法与示例