并发编程笔记(一):使用 C++11 进行并发编程

bigwolf 发布于1年前 阅读12310次
0 条评论

并发编程笔记(一):使用 C++11 进行并发编程

目录

  1. 并行与并发:现实世界到抽象概念 【完成】
  2. 线程的管理:线程库和并发模型 【完成50%】
  3. 数据共享:锁与其他【预计一周】
  4. 线程间顺序:生产者消费者、future、条件变量与其他【预计两周】
  5. 任务【预计一周】
  6. 内存管理与资源管理【预计一周】
  7. 并发性能【预计一周】
  8. 编程惯例【预计一周】
  9. 内存模型【预计一周】
  10. 附:C++与Java常见并发API对应表

序:并行与并发:现实世界到抽象概念

并行与并发

现实世界中,所有的事情,都是并行的,而并行的事情皆具有并发性。并发性,说的就是多件事情能够同时进行(并行)的一种能力。

并行的事情具有并发性,但是具有并发性的事情不一定并行地进行;具有并发性的事件也不一定非得并行地进行。

要点:并发是性质,并行是状态。

现实

举一个现实中的例子:有一个家用洗漱室,它具备刷牙洗脸,洗澡沐浴等功能。

但是呢,同一时间只允许一个人使用其中的一项功能。多个人不能同时使用这个洗漱室,只能一个接一个排队。

另外一种情况,学校宿舍的集体洗漱室,就是并发的,大家同时起床,同时刷牙洗脸。同一时间可以容纳多个人刷牙洗澡。

抽象

要把上面的例子映射到计算机里的抽象概念的话,这里的洗漱室,就像是内存(memory);这里的每个人,就像是线程(thread);这里的刷牙洗脸和洗澡沐浴,就像是任务(task)。这是并发编程里最重要的三个概念,所有的一切都是围绕着它们展开。并发编程就是关于如何抽象、封装和操作三要素内存、线程、任务的艺术。

要点:并发编程三要素:内存、线程、任务。

内存

其实并发编程的源头,就在于内存中的数据需要在不同的线程之间共享,因为多线程程序在运行时存在交错(interleaving),不加限制共享就会带来数据读写的错误。所有的并发编程设施和理论都是基于这个理由来搭建。

直观来说,数据在内存中的存储,可以分成这几种情况:1)原子存储,2)固定存储,3)动态存储。这个分类在并发编程中尤其重要,根据不同的共享存储类型,我们才能制定具体的并发策略,甚至让存储类型来配合我们对性能的要求。

要点:存储分类

  • Immutable
  • Mutable
    • 原子存储
    • 固定存储
    • 动态存储

再进一步,在并发编程中我们更关注数据的生命周期,即数据什么时候创建,什么时候读取,什么时候更新,什么时候销毁释放。这些问题在并发编程中如果没有妥善地设计,会带来非常多难以重现和调试的问题,而资深的软件工程师,都会在这些问题上有深入的理解和长期的经验。

最后,我们也需要了解,在多线程环境中一门语言的内存模型,static的生命周期,immutable的使用,即需要回答这样一个问题:『什么级别的操作即使我们不用锁也是线程安全的?』。还有,我们需要知道如何手动或自动地做垃圾回收,这又包含了智能指针、虚拟机的垃圾回收器等概念。这些概念看似分散,其实统一,像是一首交响乐,多个声部多套乐器带来着壮丽的旋律大和谐。

线程

每个语言都有一套API,来负责线程的创建、运行、切换和结束。在通常的理解中,并发编程有三种范式:CSP(Communicating Sequential Processes)、Functional、Procedural。

要点:并发编程三范式:CSP(Communicating Sequential Processes)、Functional、Procedural。

CSP就是类似与Go语言的API,通过一种类似于通信管道的方式来做线程的同步,它让并发编程的设计更容易,出bug的可能性更小;简单一句话,『Do not communicate by sharing memory; instead, share memory by communicating.』。

Functional,在我理解就是一系列来自函数式语言中的概念在并发编程中的应用,它把函数当成语言里的一等公民来对待,以提供更高的抽象能力,例如:lambda calculus、no side effect、higher order functions、pattern matching、lazy evaluation、currying【 参考这里 】。要说C语言是冯诺依曼计算机体系中工程语言的一座高峰,那么函数式Lisp就是计算机抽象理论语言的另外一座高峰。因为这些概念,函数式的概念在本质上就特别适合用来做并发编程。但是纯函数式编程语言,例如Haskell,在编程方式和语言基础设施效率上还不太能让工业界接受。但是其中的概念正在逐步引入语言的新标准(C++1x、Java8),令其在并发编程上有更好的抽象能力和表达能力。

Procedural,是以C语系(C++、Java)为代表的过程式编程语言,它在处理并发编程时,通常是通过同步工具来进行的,参考了陈硕老师的观点,这些设施按抽象级别由高到低有:

要点:并发同步工具抽象级别由高到低:

  1. BlockingQueue、TaskQueue、Producer-Consumer Queue、CountDownLatch、Reader-Writer Lock等;
  2. mutex、conditiona variable、future,衍生出去还有:shared_future、promise、lock guard、unique lock等;
  3. lock-free、atomic、spin lock。

其中1.、2.中的设施用得最多,也能够处理绝大多数的情况,不过同时也有大量的细节需要我们注意,在不断的使用中累计经验,性能调优实践与直觉全然不同【 参考这里 】,例如自旋锁、例如读写锁、例如多线程,这些并发同步工具的使用和工程经验和量化分析紧密相关,直觉往往是会带来低性能。而在工程实践中,很多问题也都是不熟悉接口和语言细节造成的。有人说具体的语言并不重要,但是我得说,至少对于并发编程来说,一定至少要有一门精通的语言。熟悉的语言就是称手的兵器,在熟悉语言和陌生语言里开发,其开发效率和出bug的可能性都有接近十倍的差距。

任务

所有并发编程都是以『任务』为抽象的设计单元,一个线程,可以在它的生命周期里处理上十亿上百亿个任务,而这些任务又会跟其他线程里的任务有先后顺序,数据依赖关系。

抽象来看,一个任务,它包含了:

  1. 前置任务依赖;
  2. 输入、共享数据读;
  3. 执行的计算和操作;
  4. 输出、共享数据写;
  5. 后续任务通知。

要点:任务五要素:前序、读、计算、写、后续。

这样的任务或许在一个进程中同时存在千千万万个,在不同的线程中交错运行,几乎是乱序进行;大任务可以转化为若干个小任务,任务越小,共享的数据结构越小,设计并发就更容易。这在没有线程概念的程序员来看,简直就是一个新世界。而如何对这些毛线团一般的任务进行同步,必然会让新手毫无头绪,不行就胡乱加锁,设计出bug百出的程序,花大量时间也无法调试正常。

理想中的情况是,语言只需要定义一个个任务,指定依赖顺序和共享数据,不需要指定调度细节,语言运行时和操作系统就能自动生成任务调度的方案,让一个个任务按照依赖关系和数据依赖,以最优的顺序执行。但是,现在大部分情况下任务的调度,任务如何在线程中执行,都是由程序员手工编码好的。在并发编程中的自动化,可以参考petri网理论或者Funtional的约束来实现,不过现在看,想要在工业界流行起来还比较遥远。Erlang和Go语言已经开始做了类似抽象和自动并发调度,Rust语言也在编译期对并发的易错点进行了形式化验证。

要点:五要素充分决定:任务调度策略。

所以,根据上面的五个要素,需要总结出了一套方法来设计线程同步策略,可以把各种情况映射到线程同步基础设施(blocking queue、condition variable、mutex、future等)。让并发编程的策略设计起来更加容易。

要点:实际情况是,任务需手写调度策略,并熟悉底层原理,量化估算性能,决定同步工具。

引子

以前我热衷于算法和数据结构、Web开发、程序分析和编译原理,我使用C++、Python、Bash、Java,我感觉所谓的多线程无非就是调用一个线程接口,让它自个去跑一个线程主函数即可,遇到共享数据加加锁就OK。直到有一天,我读了数本并发编程的经典著作,才了解到程序原来可以几乎是乱序一样的交错运行,共享一块大数据,在多核跑出原来4-8倍的性能。而其中有存在这非常多的技巧和理论,需要大量的实践经验,熟悉常见的设计模式。 看王家卫导演的『一代宗师』,我们写程序的也是如此,有门派,有大师;技术造人,时代更造人;希望这本书能够把我所学所做整理出来,简洁有效地总结并发编程的经验。文中难免会有遗漏或错误,也请各位同行指正,相互学习。本书每个章节都配上可运行的样例代码,包括C++和Java两种语言。

本书结构

  1. 并行与并发:现实世界到抽象概念 【完成】
  2. 线程的管理:线程库和并发模型 【完成】
  3. 数据共享:锁与其他【预计一周】
  4. 线程间顺序:生产者消费者、future、条件变量与其他【预计两周】
  5. 任务【预计一周】
  6. 内存管理与资源管理【预计一周】
  7. 并发性能【预计一周】
  8. 编程惯例【预计一周】
  9. 内存模型【预计一周】
  10. 附:C++与Java常见并发API对应表

本书的每一章都有特定的主题,章与章之间相对独立,在章节内部对一个主题进行讨论,构造本书结构花费了不少心血,希望能够让读者用起来方便。具体每个章节的内容如下:

  1. 『并行与并发』:对本书整体做导论,综述重要的概念。同时介绍本书结构,让读者对全书有一个整体的认识,进一步有选择地阅读感兴趣的章节。
  2. 『线程的管理』:介绍线程的创建,等待,以及批量的管理;并进一步介绍一些并发模型。只有学会了创建和管理线程,才能在其他章节讨论线程间的合作方式。
  3. 『数据共享』:与『线程间顺序』并列,介绍了多个线程访问(增删查改)同一块内存时,如何用同步工具才能保证数据的一致性和操作的原子性。
  4. 『线程间顺序』:与『数据共享』并列的另外一种技术,介绍了如何用同步工具控制特定事件的先后顺序,例如如何让线程等待一个命令后才开始运行。
  5. 『任务』:如何把线程执行的任务封装成对象,甚至让线程在运行时,接收千千万万不同内容的任务并执行,实现『1线程-n任务』的模型。
  6. 『内存管理与资源管理』:介绍了基础的同步工具后,这一章来探讨内存管理与资源管理的内容,即程序的数据在内存里的分布,它们的生命周期,何时申请何时释放,什么时候会泄漏,如何复用等问题。并且进一步讨论堆和栈、对象生命周期、垃圾回收、智能指针、RAII、ownership、noncopyable < movable < copyable等原理和技术。
  7. 『并发性能』:进一步量化的讨论并发性能与线程模型的关系,在什么样的情况下使用何种同步工具,我们哪些关于并发的直觉是错误的。
  8. 『编程惯例』:补充一些常见的编程惯例,例如:线程本地变量、线程安全的随机数生成函数、延迟初始化、Singleton、Observer、Signal/Slots等。
  9. 『内存模型』:介绍为什么并发编程需要了解内存模型,简单列举需要用到的内存序用法。

要点:两大核心:『数据共享』和『线程间顺序』,是线程技术最常用到的两大技术,几乎包含了初学者所能遇到的绝大多数情况。

PBL(Problem-Based Learning)

本书通过PBL(Problem-Based Learning)的方法来对细节进行展开,即每章都尝试回答一系列的问题,并通过图示或者代码进行介绍。

每章用到的技术

并发编程笔记(一):使用 C++11 进行并发编程

查看原文:并发编程笔记(一):使用 C++11 进行并发编程

需要 登录 后回复方可回复, 如果你还没有账号你可以 注册 一个帐号。