并发编程的七个模型
线程与锁:线程与锁模型有很多众所周知的不足,但仍是其他模型的技术基础,也是很多并发软件开发的首选。
函数式编程:函数式编程日渐重要的原因之一,是其对并发编程和并行编程提供了良好的支持。函数式编程消除了可变状态,所以从根本上是线程安全的,而且易于并行执行。
Clojure之道——分离标识与状态:编程语言Clojure是一种指令式编程和函数式编程的混搭方案,在两种编程方式上取得了微妙的平衡来发挥两者的优势。
actor:actor模型是一种适用性很广的并发编程模型,适用于共享内存模型和分布式内存模型,也适合解决地理分布型问题,能提供强大的容错性。
通信顺序进程(Communicating Sequential Processes,CSP):表面上看,CSP模型与actor模型很相似,两者都基于消息传递。不过CSP模型侧重于传递信息的通道,而actor模型侧重于通道两端的实体,使用CSP模型的代码会带有明显不同的风格。
数据级并行:每个笔记本电脑里都藏着一台超级计算机——GPU。GPU利用了数据级并行,不仅可以快速进行图像处理,也可以用于更广阔的领域。如果要进行有限元分析、流体力学计算或其他的大量数字计算,GPU的性能将是不二选择。
Lambda架构:大数据时代的到来离不开并行——现在我们只需要增加计算资源,就能具有处理TB级数据的能力。Lambda架构综合了MapReduce和流式处理的特点,是一种可以处理多种大数据问题的架构。
1. 线程与锁
原始,底层(既是优点也是缺点),有效,仍然是开发并发软件的首选。
几乎每种编程语言都以某种形式提供了支持,我们应该了解底层的原理,但是,多数时候,应该使用更上层的类库,更高效,更不易出错。
这种方式无外乎几种经典的模式,互斥锁(临界区),生产者-消费者,同步等等。
书中举了个外星方法的示例,即使我们自己的代码没有死锁,但是你不知道调用的方法做了什么,或许就会导致死锁。
Java提供了一些类库来解决并发编程的问题。
比如ReentrantLock
a. 可中断,如果是原始的Thread,除了停止JVM,没有其他方式终止死锁;
b. 超时,需要的资源到时间没有释放,不会阻塞在那里,我们也可以在后续把之前的资源释放掉,反正当前任务也不能执行,不如提高系统整体的能力;
c. 交替锁,比如插入链表时,不用锁整个链表,只要锁两个相关的结点即可
d. 条件变量,当满足某种条件时,才继续执行
比如atomic包
原子操作。不会忘了在正确的时间获取锁;由于没有锁,不会死锁;非阻塞。
线程池,大小很重要,对于CPU密集型,大小是CPU可用核数,对于IO密集型,可以适当大些,比如*2。更好的方式是做压力测试以衡量性能。
多线程程序难点不在于难以编写,而是在于难以测试。
找到bug原因也很难,有点程序一直运行良好,但是过几个月才出一次问题,绝对很难定位问题。
这种模型也是其他某些模型的基础。
2. 函数式编程
对于线程间共享的可变的数据可能会出现各种问题,我们另辟蹊径,对于不变的数据,多线程不用锁就可以安全地进行访问。这就是为什么函数式编程会如此的引人入胜,它没有可变状态,所以不会遇到共享可变状态带来的种种问题。
传统的程序可能会隐藏可变的状态或者存在逃逸的可变状态。比如Java里面的DateFormat.parse就会隐匿可变的状态,多线程使用的时候就有可能抛出异常,我在云知声实习的时候就遇到这个问题;再比如两个函数都加了同步关键字,一个返回迭代器,一个往List里面add,也会出问题的。
而函数式编程处理并行的时候,就不会有这些问题。
除了并行,函数式并发也值得关注。因为函数式语言中的函数具有引用透明性,在任何调用函数的地方,都可以用函数运行的结果来替换函数的调用,而不会对程序产生副作用。比如:(+ (+ 1 2) (+ 3 4)),先计算1+2或者先计算3+4,对结果是没有影响的,也就是说,同样的结构,不同的求职顺序,结果一致。Clojure提供了future和promise模型,就是用类似的思想来解决并发问题的。
很多人认为并行一定会伴随着不确定性,这是不对的。对于有时序依赖的问题,会有不确定性,但是对于从0加到10000,或者统计某个页面的词频,结果应该总是一样的。
使用线程和锁的模型中,大多数潜在的竞态条件并不是来源于问题本身的不确定性,而是隐藏于解决方案的细节中。
3. 标识和状态分离
如果一个线程引用了持久数据结构,那么其他线程对数据结构的修改对该线程就是不可见的。因此持久数据结构对并发编程的意义非比寻常,其分离了标识(identity)与状态(state)。
“你的汽车有多少油”是一个标识,其状态是一直在改变的,也就是说,实际上它是一系列不同的值——2012-02-23 12:03,值是0.53;2012-02-23 14:30,值是0.12;2012-02-23 14:31,值是1.00。
命令式语言中,一个变量混合了标识与状态——一个标识只能拥有一个值,这让我们很容易忽略一个事实:状态实际上是随时间变化的一系列值。持久数据结构将标识与状态分离开来——如果获取了一个标识的当前状态,无论将来对这个标识怎样修改,获取的那个状态将不再改变。Clojure是一门不纯粹的函数式语言,提供了大量的可变数据类型。我们已经学习了其中最简单的一种——原子变量。命令式语言和不纯粹的函数式语言的区别是今天的一个重点。命令式语言中,变量默认是状态易变的,代码会经常修改变量。不纯粹的函数式语言中,变量默认是状态不易变的,代码仅在必要时修改变量。函数式语言中,数据结构是持久的,也就是说当一个线程修改它时,将不会影响到引用同一个数据结构的其他线程。借助上述特性,我们可以分离标识与状态。与标识不同,状态实际上是一系列随时间变化的值。
STM事务具有原子性、一致性和隔离性。
-
原子性:在其他的事务看来,当前事务的所有副作用或者全部发生,或者都不发生。
-
一致性:事务保证全程遵守校验器定义的规范(就像我们在原子变量和代理中看到的一样)。如果事务的一系列修改中任一个校验失败,那么所有的修改都不会发生。
-
隔离性:多个事务可以同时运行,但同时运行的事务的结果与串行运行这些事务的结果应当完全一样。
你可能已经看出来了,这三个性质是许多数据库支持的ACID特性中的前三个。唯一遗漏的性质是持久性——STM的数据在电源故障或系统崩溃时会丢失。如果需要用到持久性,就必须使用数据库。
原子变量可以对单一值进行隔离的、同步的更新。
代理可以对单一值进行隔离的、异步的更新。
引用可以对多个值进行一致的、同步的更新。
我们可以用Clojure“函数式地”解决函数式的问题,也可以在必要的时候突破函数式的禁锢。
传统命令式语言的变量混淆了标识与状态这两个概念,而Clojure的持久数据结构将可变量的标识与状态分离开来。这解决了使用锁的方案的大部分缺点。专家级Clojure程序员知道解决并发问题的最佳选择是那个“刚刚够用”的方案。
“Clojure之道”的主要缺点在于不支持分布式(地理分布或其他)编程。与之相关,它也无法直接提供容错性。
由于Clojure在JVM中运行,很多第三方库可以为Clojure弥补这些缺
4. Actor
多个actor(进程)可以同时运行、不共享状态、通过向信箱异步地发送消息来进行通信。
使用actor模型的程序并不进行防御式编程,而是遵循“任其崩溃”的哲学,让actor的管理者来处理这些问题。这样做有几个好处,比如:
代码会变得更加简洁且容易理解,可以清晰区分出“一帆风顺”的代码和容错代码;
多个actor之间是相互独立的,并不共享状态,因此一个actor的崩溃不太会殃及到其他actor。尤其重要的是一个actor的崩溃不会影响到其管理者,这样管理者才能正确处理此次崩溃;
管理者也可以选择不处理崩溃,而是记录崩溃的原因,这样我们就会得到崩溃通知并进行后续处理。
虽然第一眼看上去“任其崩溃”的哲学有点奇怪,但它和错误处理内核模式都在产品环境上反复进行过验证。一些系统的可用性据说提高到了99.9999999%(9个9)
Smalltalk的设计者、面向对象编程之父Alan Kay曾经这样描述面向对象的本质:
很久以前,我在描述“面向对象编程”时使用了“对象”这个概念。很抱歉这个概念让许多人误入歧途,他们将学习的重心放在了“对象”这个次要的方面。
真正主要的方面是“消息”……日文中有一个词ma,表示“间隔”,与其最为相近的英文或许是“ interstitial”。
创建一个规模宏大且可生长的系统的关键在于其模块之间应该如何交流,而不在于其内部的属性和行为应该如何表现。
这么多年,看到了对于面向对象不一样的理解,对象是第二等的,对象之间的交流才是第一位的。
actor模型精心设计了消息传输和封装的机制,由此带来好处,虽然多个actor可以同时运行,但它们并不共享状态,而且在单个actor中所有事件都是串行执行的。所以关于并发,只需要关注于多个actor之间的消息流即可。每个actor可以被单独测试,而且当测试覆盖了某个actor的消息类型和消息顺序时,就可以确定这个actor非常可靠。如果发现了一个与并发相关的bug,也就知道重点应该放在actor之间的消息流上。
容错
使用actor模型的程序天生具有容错性。这不仅会让程序更加强壮,而且(通过“任其崩溃”的哲学)会让代码更加简洁明了。
分布式编程
actor模型支持共享内存模型,也支持分布式内存模型,这就带来了很多优点。首先,actor模型几乎可以解决任何规模的问题。我们不需要将问题局限于用一个系统解决。其次,actor模型可以解决地理分布式问题。对于不同部分需要部署在不同地理位置的软件,Actor模型是个极佳的选择。最后,分布式是软件具有容错能力的基石。
缺点
缺点也是有的,而且有该模型固有的某些缺点:
actor模型的程序比使用线程与锁模型的程序更容易debug,但actor模型仍会碰到死锁这一类的共性问题,也会碰到一些actor模型独有的问题(例如信箱溢出)。类似于线程与锁模型,actor模型对并行也没有提供直接支持。需要通过并发的技术来构造并行的方案,这样就会引入不确定性。而且,由于多个actor并不共享状态,仅通过消息传递来进行交流,所以不太适合实施细粒度的并行。
5. 通信顺序模型
通信顺序进程(Communicating Sequential Processe,CSP)模型也是由独立的、并发执行的实体所组成,实体之间也是通过发送消息进行通信。但两种模型的重要差别是:CSP模型不关注发送消息的实体,而是关注发送消息时使用的channel(通道)。channel是第一类对象,它不像进程那样与信箱是紧耦合的,而是可以单独创建和读写,并在进程之间传递。
缓冲区往往有三种类型:阻塞型,弃用新值型,移出旧值型。
后面还介绍了异步编程模式,C#中也引入了这种模式。
6. 数据并行
电脑中的超级计算机——图形处理单元(GPU)。现代GPU是一个强力的数据并行处理器,其用于数学计算时性能超过了CPU,这种做法称为基于图形处理器的通用计算(General-Purpose computing on the GPU),或GPGPU编程。
作者通过OpenCL来讲解这种并行模型。
GPU会综合流水线、多ALU等技术提高性能,不同的GPU之间差异很大,好在有OpenCL,针对多种架构抽象地进行编程。不同的GPU厂商会提供各自的编译器和驱动程序,使代码可以被编译并在相应的硬件上运行。
让人意想不到的是,OpenCL还适用于CPU。
有其他语言的库,比如书中第三天就使用了Java库,其封装了OpenCL和OpenGL,用起来更方便。
数据并行非常适用于处理大量数值数据,尤其适合于科学计算、工程计算以及仿真领域,比如流体力学、有限元分析、N体模拟、模拟退火、蚁群优化、神经网络等。
它的缺点也就是它的优点太突出:数据并行编程,更准确地说是GPGPU编程,在其适用的领域内所向披靡。但它并不适用于所有问题领域,适用范围很小。
7. Lambda架构
与GPGPU编程不同,Lambda架构是站在大规模场景的角度来解决问题的,它可以将数据和计算分布到几十台或几百台机器构成的集群上进行。这种技术不但解决了之前因为规模庞大而无法解决的难题,还可以构建出对硬件错误和人为错误进行容错的系统。
将一个问题拆分成一个映射操作和一个化简操作,使其更容易被并行化。MapReduce,在本章中使用的这个术语特指一个使用多台计算机的、由映射操作和化简操作构成的、高效且容错的分布式系统。
信息可以分为原始数据和衍生信息。原始数据是永恒的真相,而且是不变的。基于这个特性,利用Lambda架构的批处理层,可以创建具有以下特性的系统:高度并行化,可以处理TB级别的数据;简单,容易创建且不易出错;对技术性故障和人为故障进行容错;支持对日常数据的操作,也支持对历史数据生成报表和进行分析。
批处理层最大的缺点在于其有延迟,Lambda架构利用加速层来解决这一问题。
加速层创建的实时视图包含了最后一次生成批处理视图后产生的数据,这样就完善了Lambda架构。
Lambda架构主要用于解决大规模数据的问题——这些问题是传统数据处理架构难以应对的。Lambda架构非常适合于报表和分析——以前我们会使用数据仓库来进行这类工作。Lambda架构最大的优点——擅长处理大规模数据——这也正是它的缺点。除非你的数据达到数太字节甚至更多,否则其成本(计算成本和智力成本)将高于收益。