七周七并发模型
作者
: Paul Butcher
译者
: 黄炎(前同事)
读者
: 锅巴GG
并发还是并行
-
并发是同一时间应对多件事情的能力
- 并发是问题域中的概念
- 并发程序的执行通常是不确定的
-
并行是同一时间动手做多件事的能力
- 并行是方法域中的概念
- 并行程序是确定性的,并行不引入不确定性
并行架构
- 位级并行
例如64bit计算机,可以并行处理64位数的8字节
- 指令级并行
CPU处理看上去是串行的结果,但内部其实是并行的优化
- 数据级并行
SIMD架构,并行的在大量数据上施加同一操作
例如:GPU,增加图片亮度,就需要增加每个像素点的亮度,GPU因为图像处理的特点而演化成极其强大的数据并行处理器
- 任务级并行
多处理器带来的两个内存模型
- 共享内存
- 分布式内存
并发:不只是多核
- 目的:不仅是为了让程序并行运行从而发挥多核的优势。若正确使用并发,程序会获得以下优点:及时响应、高效、容错、简单。
并发是系统及时响应的关键。- 地理分布式: 软件在非同步运行的多台计算机上分布运行,其本质就是并发。
- 容错性:并发代码的关键是独立性和故障检测。独立性指一个故障不会影响到故障任务以外的其他任务。故障检测是指当一个任务失败,需要通知故障处理任务来处理。
七个模型
- 线程与锁
基础,简单常用
- 函数式编程
函数式编程消除了可变状态,所以从根本上是线程安全的,而且易于并行执行
- Clojure之道——分离标识与状态
指令式编程和函数式编程的混搭,在两种编程方式上取得了微妙的平衡来发挥两者的优势
- actor
适用于共享内存模型和分布式内存模型,适合解决地理分布式问题,提供强大的容错性
- 通信顺序进程(CSP)
CSP模型和actor模型都基于消息传递,CSP模型侧重于传递消息的通道,而actor模型侧重于通道两端的实体,使用CSP模型的代码会有明显不同的风格
- 数据级并行
GPU,超级计算机,适合有限元分析、流体力学计算以及其他大量数字计算
- Lambda架构
综合了MapReduce和流式处理的特点,是一种可以处理多种大数据问题的架构
请思考
- 这个模型适用于解决并发问题、并行问题,还是两者皆可?
- 这个模型适用于哪种并行架构?
- 这个模型是否有利于我们写出容错性强的代码,或用于解决分布式问题的代码?
线程与锁
互斥和内存模型
用锁保证某一个时间仅有一个线程可以访问数据
麻烦:竞争条件和死锁
难点:内存模型——共享内存
多线程编程很难的原因之一,就是运行结果可能依赖于时序,多次运行的结果并不稳定。
读-改-写模式,竞争条件的解决方案是对资源的同步访问。一种是原生的内置锁(互斥锁mutex)、管程(monitor)或临界区(critical section)来同步对资源的调用。
注意
乱序执行的发生:
- 编译器的静态优化可以打乱代码的执行顺序
- JVM的动态优化也会打乱代码的执行顺序
- 硬件可以通过乱序执行来优化执行
- 有时一个线程产生的修改可能对另一个线程不可见
同步化的重要性
需要有明确的标准来告诉我们可能发生怎样的副作用,这就是内存模型
- 内存的可见性
对共享变量的所有访问都需要同步化
读线程和写线程都需要同步化- 多把锁
按照约定的全局顺序来获取多把锁- 监听器模式(listener)
- 当持有锁时避免调用外星方法
- 持有锁的时间尽可能短
超越内置锁
没有办法终止死锁的线程,除非全部销毁(如:java终止JVM)
- 超时——不要无尽的死锁下去,注意“活锁”
- 交替锁(hand-over-hand locking)
很像MySQL中InnoDB中的间隙锁(gap) ——锅巴GG
- 不会违背“全局顺序”规则
- 条件变量、原子变量(无锁非阻塞)、volatile变量(低级形式的同步)
写入时复制(COW)
保护性复制的策略
- 线程池应该有多大?
一般原则:对于CPU密集的任务,线程池大小应该接近于可用核数;对于IO密集型的任务,线程池可以设置的更大些。
压测来衡量性能
- 阻塞队列
一般只允许生产者的速度在一定程度上超过消费者的速度,但是不会很多
生产者-消费者模式的优点不仅在于可以并行的生产和消费,还可以存在多个生产者和多个消费者
总结
线程与锁模型的优点和缺点
- 优点:
适用面广,可以解决从小到大不同颗粒度的问题
可以集成到大多数的编程语言中- 缺点:
线程与锁模型没有为并行提供直接的支持
仅支持共享内存模型
无助——编程语言层面无法提供足够的帮助,全部由程序员设计编写- 不易察觉的错误
降低了可维护性,错误不容易重现
函数式编程
与命令式编程不同,函数式编程将计算过程抽象成表达式求值,这些表达式由纯数学函数构成,这些数学函数是第一类对象并且没有副作用,所以更容易做到线程安全,适合于并发编程。直接支持并行的模型。
相对于线程和锁模型,锁是来自于线程之间共享的可变数据——就是共享可变状态。而对于不变的数据,多线程不使用锁就可以安全地进行访问。函数式编程没有可变状态,所以不会遇到由共享可变状态带来的问题。
- 可变状态
并发编程中,隐藏和逃逸是两种可变状态带来的常见风险,还有许多其他风险,如果找到一种不使用可变状态的方法,就可以规避风险。
- 引用透明性
在任何调用函数的地方,都可以用函数运行的结果来替代函数调用,而不会对程序产生副作用
- 数据流式编程
future模型和promise模型,所有函数都可以同时(理论上)执行,数据到达时,就可以推进程序整体的执行。
- future函数可以接受一段代码,并在一个单独的线程中执行这段代码。其返回一个future对象,对future对象解引用来获取值,解引用时阻塞线程,直到其代表的值变得可用(被求值)。
- promise模型
类似于future对象,promise对象也是异步求值,不同的是,使用promise对象的代码并不会立即执行,而是等deliver为promise对象赋值后才会执行。
总结
优点:确信程序按照我们预想的方式运行。更容易推理,也易于测试。利用引用透明性,可以轻松将程序并行化。
缺点:小部分场景下有性能损失,但是带来了程序健壮性和扩展性的提升。
Clojure之道——分离标识和状态
- 原子变量
Clojure提供的用于并发的可变数据类型。
具有原子性的变量。
- 持久数据结构
这里的持久并不是指持久化到磁盘,而是数据结构被修改时总是保留其之前的版本,这样可以为代码提供一致的数据视角
持久数据结构被修改时看上去就像创建了一个完整的副本。使用了共享结构来优化性能
标识与状态
如果一个线程引用了一个持久数据结构,那么其他线程对数据结构的修改对该线程是不可见的。因此持久数据结构对并发编程的意义非比寻常,其分离了标识(identity)与状态(state)
标识只拥有一个值,而状态是一系列随时间变化的值
代理和软件事务内存
- 代理 与原子变量类似,代理包含了对一个值的引用
代理是actor吗?不是
actor没有提供直接获取值的方式
代理不能包含行为,对数据的操作由调用者提供
代理只提供了简单的错误报告机制,无法恢复
代理不支持分布式
多个actor可能引发死锁,而多个代理不会
- 软件事务内存
引用比原子变量和代理更复杂,通过引用可以实现软件事务内存(STM)。通过原子变量和代理每次仅能够修改一个变量,而通过STM可以对多个变量进行并发的一致的修改
STM事务具有原子性、一致性和隔离性
- 原子性 在其他事务看来,当前事务的所有副作用或者全部发生,或者都不发生
- 一致性 事务保证全程遵守校验器定义的规范,如果事务的一系列修改中任一个校验失败,所有修改都不会发生
- 隔离性 多个事务可以同时运行,但是同时运行的事务的结果与串行运行这些事务的结果应当完全一样
总结
优点:持久数据结构将可变量的标识和状态分离开,解决了使用锁的方案的大部分缺点。
缺点:不支持分布式(地理分布或其他)编程,无法直接提供容错性
由于Clojure在JVM中运行,有很多第三方库可以适当弥补这些缺点,如Akka
Actor
使用actor就像租车,需要就可以快速的租一辆,如果车发生故障,通知到租车公司换一辆即可
类似于OOP中的对象,封装了状态,并通过消息与其他actor通信。区别是所有actor可以同时运行,与“oo式”(调用方法)消息传递不同,actor之间的消息传递是真实地在传递消息
actor模型是一个通用的并发编程模型,语言无关。
消息和信箱
- 队列式信箱
异步地发送消息是用actor模型编程的重要特性之一。消息并不是直接发送到一个actor,而是发送到一个mailbox。
这样的设计解耦了actor之间的关系——actor都以自己的步调运行,且发送消息时不会被阻塞。
虽然所有的actor可以同时运行,但他们都按照信箱接收消息的顺序来依次处理消息,且仅在当前消息处理完成后才会处理下一个消息,因此只关心发送消息时的并发问题即可
- 接收消息
通常actor会进行无限循环,通过receive等待接收消息,并进行消息处理。递归调用也没有关系,Elixir实现了尾调用消除,如果函数在最后调用了自己,递归调用会被转换成简单的跳转
- linking到进程
彻底关闭一个actor,要满足两个条件,第一告诉actor在完成消息处理后就关闭,第二需要知道actor何时完成关闭
- 双向通信
actor是异步发送消息,发送者不会被阻塞,如何获得一个消息回复?
actor模型并没有提供直接的消息回复机制,一般自行解决:将发送进程的标识符包含在消息中。
- 为进程命名
将一个消息发送给一个进程时,需要知道进程的标识符,如果是别人创建的进程,如何发送消息呢,一般我们会为进程命名
错误处理和容错性
- 错误处理内核模式
分布式
- 没完全理解,
TOneverDO
通信顺序进程
CSP也是由独立的、并发执行的实体组成,实体之间也是通过发送消息进行通信。区别于actor,CSP不关注发送消息的实体,关注发送消息时使用的channel(通道),channel是第一类对象,它不像经常一样与信箱紧耦合,而是可以单独创建和读写,并在进程之间传递。
随着go语言的流行,CSP又重新流行起来
与使用线程与锁模型和actor模型一样,CSP模型也容易受到死锁影响。且没有提供直接的并行支持。使用CSP模型时,并行需要建立在并发的基础上,也就引入了不确定性
数据并行
数据并行,是并行编程技术,而非并发编程技术
- GPGPU框架,包括CUDA、DirectCompute以及RenderScript Computation
- 是小规模应用数据并行技术的例子,小规模指的是程序运行在单台计算机上
- GPU和OpenCL
- 多维空间与工作组
- 注意OpenCL的平台模型和内存模型
- OpenCL/OpenGL都在GPU上运行
Lambda架构
大规模(跨越多台计算机)应用数据并行技术
站在大规模场景的角度来解决问题
来源于函数式编程的相似性,本质上是将计算函数施加于大量数据的一种通用方法
- 可行性
开发调试MapReduce的起点是在本地运行Hadoop
将一个问题拆分成一个映射操作和一个化简操作,使其更容易被并行化
- 数据还是原始的好
数据的不变性和并行计算是天作之合
删除数据只是遗忘不被处理,并没有改变
-
优点,擅长处理大规模数据,同时也是缺点,除非数据规模非常大,否则成本高于收益
-
替代方案
Lambda方案并没有与MapReduce绑定——批处理层可以使用其他分布式批处理系统来实现
- Apache Spark
集群计算框架,DAG执行引擎
读后感
真不知道译者是怎么把这本书翻译完成的...读起来都这么痛苦
开了眼界,长了见识
想加入更多乐读创业社的活动,请访问网站→ http://ledu.club
或关注微信公众号选取: