CH06-CSP

CSP 看上去类似于 Actor,但最大的区别在于:actor 模型的重点在于参与交流的实体,而 CSP 模型的重点在于用于交流的通道。

大家都在跌跌不休的争论涡轮增压与自然吸气孰优孰劣,让中置发动机布局与前置发动机布局一较高下,却忘记了最重要的方面其实与车辆本身无关。你能去往何方、能多快到达目的地,首要的决定因素是道路网络而不是车辆本身。

消息传递系统与之类似,决定其特性和功能的首要因素并不是用于传递消息的代码或消息的内容,而是消息的传输通道。

万物皆通信

使用 actor 模型的程序是由独立的、并发执行的实体组成,这些实体之间通过发送消息进行通信。每个 actor 都有一个信箱,用于保存已经收到但尚未被处理的消息。

与 actor 模型类似,CSP 模型也是由独立的、并发执行的实体组成,实体之间也是通过发送消息进行通信。但两种模型的重要差别在于:CSP 模型不关注发送消息的实体,而是关注发送消息时使用的 channel(通道)。通道是第一类对象,它不想 actor 的信箱一样与实体紧耦合,而是可以单独创建和读写,并在进程之间传递。

与函数式编程和 actor 模型类似,CSP 模型也是正在复兴的古董。由于近来 Go 语言的兴起,CSP 模型又流行了起来。

channel 与 go block

core.async 库将 Go 的并发模型引入了 Clojure,channel 与 go block 是其提供的主要工具。在大小有限的线程池中,go block 允许多个并发任务复用线程资源。

channel

一个 channel 就是一个线程安全的队列——任何任务只要持有 channel 的引用,就可以向其一端添加消息,也可以从另一端删除消息。在 actor 模型中,消息是从指定的 actor 发往指定的另一个 actor;与之不同,使用 channel 发送消息时发送者并不知道谁是接收者,反之亦然。

缓存区

默认情况下,channel 是同步的(或称无缓存的)——一个任务向 channel 写入消息的操作会一直阻塞到另一个任务从 channel 中删除该消息。

如果向创建 channel 的 chan 函数传入缓存区大小,就可以创建一个有缓存的 channel。当缓存没有被消息填满时,向其写入消息会理解返回,不会阻塞。

关闭

close! 可以关闭一个 channel。从已经关闭的空的 channel 中读出消息将会得到 nil;向已经关闭的 channel 写入消息时,消息将会被丢弃,写入 nil 则会报错。

缓存已满

默认情况下,向一个缓存已满的 channel 写入消息将会一直被阻塞。但通过向 chan 函数传入缓冲区来改变这个策略。

  • default:阻塞
  • dropping-buffer:满时丢弃,不再阻塞
  • sliding-buffer:启用已有消息,使用新消息填充,不再阻塞

go block

线程创建与启动都会带来开销,这也正是使用线程池的原因。但是线程池并非总是适用,尤其是当线程可能会被阻塞时,使用线程池则可能会带来麻烦。

阻塞问题

线程池技术是处理 CPU 密集型任务的利器——任务进行时会占用某个线程,任务结束后将线程返还给线程池,以使得线程能够被复用。但涉及线程通信时使用线程池是否合适呢?如果线程被阻塞,那么它将被无限期占用,这就削弱了使用线程池技术的优势。

这种问题是存在解决方案的,但通常会对代码风格加以限制,使之变成事件驱动式编程。事件驱动是一种编程风格。

虽然这些方案能够解决问题,但破坏了控制流的自然表达形式,让代码变得难以阅读和理解。更糟的是,这些方案还会大量使用全局状态,因为事件处理器需要保存一些数据,以便之后的事件处理器使用。我们已经学习过这个结论了:状态与并发不要混用。

go block 提供了一种两全其美的解决方案——既可以写出事件驱动的代码来解决目前遇到的阻塞问题,又可以不牺牲代码的结构性和可读性。其原理是 go block 在底层将串行的代码透明的重写成了事件驱动的形式。

控制反转

与其他 Lisp 方言类似,Clojure 有一套强大的宏系统。如果你使用过其他语言的宏系统,就会觉得 Lisp 的宏更像是魔法,它可以进行神奇的代码变换。go 宏就是其中一个小魔法。

go block 中的代码会被转换成一个状态机。当从 channel 中读出消息或向 channel 中写入消息时,状态机将暂停,并释放它所占用的线程的控制权。当代码可以继续运行时,状态机进行一次状态转换,并可能在另一个线程中继续运行。

通过这样的控制反转,core.async 运行时可以在有限的线程池中高效的运行多个 go block。

状态机暂停

channels.core=> (def ch (chan)) 
#'channels.core/ch 
channels.core=> (go 
  #_=> (let [x (<! ch) 
  #_=> y (<! ch)] 
  #_=> (println "Sum:" (+ x y)))) 
#<ManyToManyChannel clojure.core.async.impl.channels.ManyToManyChannel@13ac7b98> 
channels.core=> (>!! ch 3) 
nil 
channels.core=> (>!! ch 4) nil 
Sum: 7

这段代码首先创建了一个名为 ch 的 channel。然后创建了一个 go block,用来从 ch 中读取两个值,再输出两个值之和。虽然看上去 go block 从 channel 中读取数据时应当阻塞,实际上却发生了有趣的事情。

这段代码并没有使用 <!! 从 channel 中读取数据,而是使用了 <!。单个谈好意味着本次读 channel 是进行暂停操作,而不是进行阻塞操作。

如下图所示,go block 将串行的代码转换成拥有 3 个状态的状态机:

NAME

该状态机包含以下 3 个状态:

  1. 初始状态会直接暂停,等待 ch 中有数据可以被读取。满足条件时进入状态 2。
  2. 状态机首先从将 ch 读取的值绑定到 x 上,然后暂停,等待 ch 中下一个可以被读取的数据。满足条件时,进入状态 3。
  3. 状态机将从 ch 中读取的值绑定到 y 上。输出计算结构,然后终止。

go block 的成本很低

go block 的只要意义在于其效率。与使用线程不同,使用 go block 的成本很低,因此可以创建很多个而不用担心耗尽资源。这看上去是个小小的改进,但实际上不用担心资源而能随意创建并发任务有着革命性的意义。

你可能已经注意到 go block 返回的是一个 channel,go block 运行完成时会将结果写入到这个 channel 中。

经过试验,创建并运行 10 万个 go block 仅需 3/4 秒。这意味着 go block 的性能比起 Elixir 的进程毫不逊色——该成绩非常优秀,因为 Elixir 运行在以并发性能为设计主旨的 Erlang 虚拟机中,而 Clojure 却运行于 JVM。

总结

优点

与 Actor 模型相比,CSP 模型的最大优点是灵活性。使用 actor 模型时,负责通信的媒介与执行单元是紧耦合的——即 actor 的信箱。而使用 CSP 模型时,channel 是第一类对象,可以被独立的创建、写入、读取,也可以在不同的执行单元中传递。

Clojure 语言的创始人 Rich Hickey 解释了他选择 CSP 而非 actor 的原因:

我个人对 actor 模型并不感兴趣。在 actor 模型中,生产者与消费者还是紧耦合在一起的。诚然,我们可以使用 actor 模型实现消息通信用的队列,但是 actor 模型本身就已经使用了队列,用它来实现基础的消息通信用的队列未免显得画蛇添足。

从更务实的角度来说,现在的 CSP 模型的实现,比如 core.async 库,使用了控制反转技术,不仅提高了异步程序的效率,还为原本使用回调函数来解决的应用领域提供了一种显著改进的编程模型。

缺点

基于 CSP 模型的编程语言也可以支持分布式和容错性,但与基于 actor 模型的编程语言不通,这两个主题没有得到足够多的重视和支持——也没有基于 CSP 模型实现的 OTP。

与使用线程锁模型和 actor 模型一样,CSP 模型也容易受到死锁影响,且没有提供直接的并行支持。使用 CSP 模型时,并行需要建立在并发的基础上,这也就引入了不确定性。

结语

CSP 模型和 Actor 模型各自的开发社区侧重点不同并各自发展,从而形成了两者之间的诸多差异。Actor 模型的开发社区侧重于容错性和分布式,而 CSP 模型的开发社区侧重于效率和代码表达的流畅性。

如果为 Actor 模型引入 CSP 形式的流畅性呢?