简单介绍常见的并发模型

本文是作者对于《七周七并发模型》一书的读后总结归纳,主要介绍了一些并发模型,这些模型本身是不依赖具体的语言的,但是特定的语言对于特定的模型是有优化的,因此在介绍模型的同时会有一些编程语言相关的内容。

1 Java的线程与锁

线程和锁模型本质上是共享可变状态,在多线程情形下可能会产生如下的问题:

  • 竞态条件—— 当多个线程竞争同一资源时,如果对资源的访问顺序敏感,就称存在竞态条件 。
  • 内存可见性—— 当读操作与写操作在不同的线程中执行时,无法确保执行读操作的线程能适时地看到其他线程写入的值。
  • 死锁—— 两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。

1.1 内置锁

Java提供的最简单的解决方案,但是存在如下的限制

  • 线程因为等待内置锁而进入阻塞后,就无法中断该进程。
  • 尝试获取内置锁时,无法设置超时。
  • 获得内置锁,必须使用synchronized语法。

1.2 Concurrent包

由于内置锁的局限性,Java在java.util.concurrent包中为并发提供了额外的支持

  • 高级锁
  • CopyOnWrite容器(写时加锁复制,读时不加锁)
  • 原子变量 CAS

1.3 总结

该模型灵活,适用面广,很容易集成入大部分编程语言,具有更接近于硬件的工作方式。
然而只适用于单机系统内的共享内存模型,需要别的技术的帮助才能够支持分布式内存模型。多线程代码经常会出现难以重现的bug,调试难,维护难。

2 Clojure的函数式编程

由于共享可变状态副作用很大,因此函数式编程直接选择了不使用可变状态。

Java非函数式编程示例:

public int sum(int[] numbers) {
    int accumulator = 0;
    for (int n: numbers) {
        accumulator += n;
    }
    return accumulator;
}

Clojure函数式编程范例:

// 便于理解的冗长写法
(defn recursive-sum [numbers]
    (if (empty? numbers)
        0
        (+ (first numbers) (recursive-sum (rest numbers)))))

// 实际只需要这么写
(defn sum [numbers]
    (reduce + numbers))

​ 函数式编程能轻松并行化的原因是每个函数都具有引用透明性。
​ 即在任何调用函数的地方,都可以用函数运行的结果来替换函数的调用。讲人话就是,每个函数之间都是互不依赖,可以独立执行的。

2.1 Reducers

Clojure提供了reducers库,描述了各种对集合进行化简的方法,这些方法被称为reducer。
普通版本的map接受一个函数和一个(可能是懒惰的)序列,并返回另一个(可能是)懒惰的序列:

user=> (map (partial * 2) [1 2 3 4])
(2 4 6 8)

而clojure.core.reducers提供的map不同,接受相同的参数,但返回的是一个化简器reducible:

user=> (require [clojure.core.reducers :as r])
user=> (r/map (partial * 2) [1 2 3 4])
#

reducible不能直接使用,而是作为参数传递给reduce或者fold。

user=> (reduce conj [] (r/map (partial * 2) [1 2 3 4]))
[2 4 6 8]
user=> (into [] (r/map (partial * 2) [1 2 3 4]))
// into函数内部使用了reduce,所以和上面的代码是等价的
[2 4 6 8]

化简器,简单讲就是不直接求值,而是返回计算的过程。多个化简器使用时会自动组合为同一个化简器。直到遇到reduce和fold才会进行计算。好处是不用构造中间状态的序列。

2.2 Reducers中的fold

fold是reduce函数的并发版本,使用的是二分算法。

首先将集合分为两组,每组继续分为更小的两组,以此类推,直到每个分组的规模小于某个限制值(默认为512)。
然后fold会对每个分组内的元素逐个化简。
最后,对个分组的结果进行两两合并,直到最终剩下一个最终的结果。


fold函数示意图

3 Clojure的分离标识与状态

​ Clojure也是支持共享可变状态的,通过分离状态与标识来实现。而分离状态与标识,则通过原子变量/代理/引用来实现
标识是什么? 一个可变量的引用,就是标识。例如Int a;
状态是什么? a在不同时刻对应的值。当时刻1的时候线程x获取到了a的值,时刻2的时候线程y修改了a的值,x获取的时刻1的值依然不变。

命令式语言中,一个变量混合了状态与标识——一个标识只能拥有一个值,这让我们很容易忽略一个事实:状态实际上式随时间变化的一系列值。持久数据结构将标识与状态分离开来,如果获取一个标识的当前状态,无论将来对这个标识怎样修改,获取的那个状态将不再改变。

我的理解:可以参考我们在Java中对原子变量的使用方式,每次对原子变量的操作都会通过基于CAS的swap!方法重试直至成功。类似Java中的AtomicXXX,如果线程a修改变量x的同时(例如值+1或者-1),有的线程b修改了该变量,那么线程a就会重新以x的新值进行计算。对于Clojure来说,这是其中一种实现状态与分离的方式。

3.1 原子变量

原子变量 通过 持久数据结构来实现

持久数据结构:数据被修改时总是保留其之前的版本(基本上就是CopyOnWrite的机制)由于这种方式很浪费空间,因此原子变量之间可以共享数据结构(例如list1的值为[1,2,3,4,5],list2的值为[3,2,3,4,5],那么list1和list2共享[2,3,4,5]的部分)

原子变量可以设置校验器(校验器在原子变量的值修改之前被调用,如果修改后的值符合校验器的要求,则允许本次修改,否则将丢弃本次修改。例如:需要一个非负值的原子变量)。

原子变量也可以设置监控器,监控器会在原子变量的值修改后被调用。(可以用来监听事件)

3.2 代理

和原子变量类似。代理也是对一个值的引用,就像原子变量通过swap!来操作一样,代理通过send来进行操作。
但是有个区别,就是send的行为是类似于NIO的,send执行后会马上返回,但send中传输的函数可能要到之后才会被执行。如果多个线程对一个值同时调用send函数,那么这些send函数将会被收集起来,并串行执行,就像消息总线一样。类似于ZStack中的XXXBase接收thdf处理的msg的方式。

3.3 引用

比原子变量和代理都要复杂。通过引用可以实现软件事务内存(Software Transactional Memory,STM)。通过原子变量和代理每次仅能修改一个变量,但是STM可以对多个变量进行原子性地修改,就像数据库的事务一样。

STM具有以下特性:

  • 原子性:在其他事务看来,当前事务的所有副作用要么全部发生,要么都不发生。
  • 一致性:事务保证全程遵守校验器定义的规范,如果事务中的任意一个校验失败,那么所有的修改都不会发生。
  • 隔离性:多个事务可以同时运行,但同时运行的事务的结果和串行运行这些事务的结果是完全相同的。

如果STM运行时检测到多个并发事务的修改发生冲突,那么其中一个或者几个事务将进行重试,就像原子变量一样。

3.4 总结

Clojure支持共享可变状态的三种机制,每种都有各自的适用场景。

  • 原子变量:对一个对象进行同步更新
  • 代理:对一个对象进行异步更新
  • 引用:结合事务,对多个对象进行一致的、同步的更新。

4. Elixir的Actor模型

actor类似于一个面向对象(OO)编程中的对象——其内部封装了状态,但通过消息和消息队列来和其他actor通信。
每一个actor都会有一个专用的队列用来接收消息,并不断地串行消费这些消息。

4.1 错误处理内核

对一个使用actor模型的程序而言,由一个管理者进程作为“错误处理内核”,管理子进程——actor,包括启动、重启、停止等操作。而actor不进行防御式编程,而是“任其崩溃”。这样做的好处显而易见:

  1. 代码会变得更加简洁且容易理解。业务代码和错误处理代码泾渭分明。
  2. actor之间是互相独立的,崩溃的actor不太可能殃及到别的actor。
  3. 管理者可以选择不处理崩溃,而是记录崩溃的原因,这样我们就会得到崩溃通知并进行后续处理。

4.2 Actor模型的特点

Actor模型天然支持分布式——它可以将消息发送到另一台机器的actor,就像发送到本地计算机的actor一样。

5 Clojure的通信顺序进程(CSP)

通讯顺序进程模型(Communicating Sequential Process),和actor模型具有很多相似之处,比如,CSP模型也是由独立的、并发执行的实体所组成,实体之间也通过发送消息来进行通讯。但有一个根本区别。CSP模型中,并不关注发送消息的实体,而是发送消息时使用的消息总线,在CSP中称为channel(通道)。channel和实体不像在actor模型中一样,是紧耦合的,而是可以单独创建和读写,并在实体之间传递。

在actor模型中,消息是从指定的一个actor发送指定的另一个actor中;而在CSP模型中,使用channel发送消息的实体,并不知道谁是接收者,反之亦然。channel本身类似于一个数组,而且可以对channel里的元素进行map、filter等操作。

5.1 Clojure的go块

而Clojure针对CSP模型的一大利器就是go块。线程本身是有一定的开销的,因此现代程序一般都会使用线程池来进行线程的复用。但是当程序阻塞的时候,线程池就会造成麻烦。
​例如在执行IO密集型任务的时候,BIO导致的阻塞使进程被无限期占用,最终导致线程耗尽。

解决方案一般是通过事件驱动的方式来实现AIO,然而这样的代码往往是难以阅读和难以理解的,并且这些方案往往带有大量的全局状态,比如回调。而状态和并发混用时往往会造成麻烦。

而go块中的代码会在底层被透明地重写为事件驱动的形式。原理是其中的代码会被转换成一个状态机,当从channel中读取或者写入消息时,状态机会暂停,并释放它所占用的线程的控制权。当代码可以继续运行时,状态机将进行状态转换,并可能在另一个线程中继续运行。

5.2 CSP模型的特点

CSP模型最大优点是灵活性和效率,可以自由调节并发的粒度,但容易出错。而actor模型则侧重于容错性和分布式,但牺牲了并发性能。

6 Lambda架构

批处理层和加速层

6.1 原始数据

对于Lambda架构而言,数据有两种类型:

  • 原始数据
  • 衍生信息

例如:

  • 银行账户的余额是衍生信息,而账户的收入和支出是原始数据;
  • Facebook的好友列表是衍生信息,而添加好友和删除好友的记录是原始数据。

衍生信息是可变的,而原始数据是对实际发生事情的记录,是不变的。

6.2 批处理层

批处理和服务层

理论上来说,只需要批原始数据+批处理层,就可以得到一个数据系统。
批处理层会不断循环运行,从原始数据中重新生成批处理视图。
每一个批处理完成后,对应的结果都会被更新到数据库中。

该数据系统有以下优点

  1. 由于只需要处理不可变的原始数据,批处理层可以轻松实现并行化,原始数据可以分布到集群中处理,可以轻松处理TB级别的数据。
  2. 该数据系统容错性较强。一方面,原始数据更容易备份。另一方面,如果存在bug,最坏的情况就是批处理视图是暂时错误的,只要修复bug后重新计算批处理视图即可。

然鹅,存在着一个严重问题——延迟
如果批处理层需要1个小时的运行时间,那么用户查询到的数据永远都有1个小时的延迟。

批处理层一般使用MapReduce架构的Hadoop来处理,原理如下所示:


Hadoop处理模型

mapper将输出映射为键值对,reducer将键值对转换为最终的输出格式(通常还是键值对)。
一个mapper产生的键值对可以发送给多个reducer,而Hadoop会确保同一个键的键值对(不管是哪个mapper产生的),都会被发送给同一个reducer处理。

6.3 加速层

批处理视图速度过慢,因此产生了加速层。加速层只需处理未被批处理层处理的数据(通常是几个小时的数据)。一旦批处理层赶上进度,就的数据就会从加速层移除。


引入了加速层的Lambda结构

由于加速层使用的是增量算法,因此要同时处理原始数据和衍生数据。必须重新面对传统数据库的特性:随机写、锁机制和事务机制等。
加速层写入数据库有同步和异步两种方案。这里主要演示异步方案。


传统数据库的异步架构

6.4 总结

​ Lambda非常适用于处理大规模数据的问题,主要使用场景是报表和分析。
​ Lambda不一定总是和MapReduce绑定,可以使用Apache Spark作为批处理层,使用DAG执行引擎。并且Spark提供了流处理相关的API,这意味着批处理层和加速层都可以用Spark实现。

你可能感兴趣的:(简单介绍常见的并发模型)