并发编程编年史

1 并发模型编年史

并发编程从上个世纪计算机的出现伊始就一直是一个老生常谈的问题。以Dijikstra(迪杰斯特拉大家应该很熟悉,就是那个被全世界所熟知的最短路径算法的作者)为代表的一批远古级的程序员们就准备拿并发编程去做一些事情的时候,就被事务交互所带来的的并发性所困扰着。同时运行的并发事务会在不同时间或空间产生交互,无法进行直观的描述。为了保证并发系统设计的正确性人们试图通过形式化的模型来规范它。包括有比较早出现的是曾经小圈子盛行的Petri Net(PN 是对离散并行系统的数学表示)。由日耳曼人Carl Adam Petri(卡尔·A·佩特里)在1962年时提出,在他的论文里提出了一个新的信息流模型,这个模型基于系统各部分的异步并发的操作,并把各部分之间的关系用网状的图来描述。Petri网用于描述和分析系统中的控制流和信息流,尤其是那些有异步和并发活动的系统。研究领域趋向认为Petri网是所有流程定义语言之母。然后就是70年代提出了Actor、CSP模型等的这些形式化模型。形式化模型虽然可以保证正确性,但如何落地,特别是高效的实现它是个问题;有些模型随着系统的复杂,自身也会变得复杂起来,所以无法在整个系统里采用统一的模型;应用模型设计以及使用相关工具需要专业知识;等上述因素导致实际工程中,一般不会拿模型做指导。比较早把并发模型作为一个通用的编程基础设施的,就是大名鼎鼎的Erlang了,Erlang它从语言层面上引入了Actor模型。随后,Golang的设计者在Hoare的建议下,实现了CSP模型的一些概念。其实这些较晚出现的并发模型之间有着比较多的相似之处,不仅可以相互模拟,而且实现中也使用了一些本质上相同的机制。比如Scala akka的作者受Erlang的启发,搞了一套基于多线程的Actor模型,也借用了CSP模型里的Channel的概念。这些语言上支持的并发模型-其实准确应该说是机制,因为不是绝对意义上的形式化模型,给开发大规模并发系统带来了很多便利,它们也被称为是统一线程和事件驱动的并发模型。

关于事件驱动和线程在实现并发系统中的优劣曾经是工程领域里长期争论的一个话题。孰优孰劣一直都处在喋喋不休的争论中。事件驱动是跟客观世界打交道必不可少的手段,比如计算机输入输出系统依赖的中断机制,就是一种典型的事件驱动,这里如果采用其他方式,比如轮询机制的话就丧失了并发性,或者造成处理上的繁琐。相反,回到计算机内部,独立执行的逻辑显然采用线程更加容易,事件驱动机制会把代码搞的支离破碎。其实不管是事件驱动和线程,两者想要达到的效果是一致的,都是一种调度代码执行方式的一种手段,让计算机可以执行并发操作。不过两者的区别也是非常的显而易见的,事件驱动比较擅长处理问题,一般称作为异步事件,什么是异步事件,这个词大家应该是很熟悉了,用白话说就是事件的发生不受程序执行的流程影响。线程比较擅长处理有着强因果关系的逻辑。不过反过来说当需要处理跨事件的事务逻辑时,事件驱动就会变的复杂难解,而处理和代码当前处理流程不相关的东西的时候,这时候就是事件的强项了,比如Unix常见的signal调用。

事件驱动的并发性和灵活性是与生俱来的,主要表现在外部输入和输出上。线程更加准确的说是利用OS的分时调度或抢占式调度机制通过模拟一个个独立的个体来表现并发性的。而事件驱动的并发性可视做是和个体以外的其他客体交互表现出来的。在GUI场合下,为了可以模拟桌面上出现任意动作,采用了事件驱动。在事件驱动机制擅长的领域,会采用事件循环这样一种同步调度或者协作式的调度机制,所以大多数人不用考虑一个完整系统下的需要考虑的并发或者共享问题,这些问题已经被系统底层处理好了。同时,我们也知道大部分时候,尽管不是全部时候,这些事件之间不存在强逻辑关系。这种单一数据集,不可预测的多事件输入场景下几乎只能采用事件驱动,如果采用线程机制的话,反而会因为同步带来诸多问题。知道两者的优劣之后,聪明的人类自然而然就会萌生一个想法-----能否将两者统一。在需要的时候使用趁手的机制。但是,一旦把事件驱动和线程结合起来,人们往往就会发现,一方面带来了同步问题或交互问题,还有一方面遭遇阻塞情况下,大量使用线程导致性能劣化。异步编程带来的复杂性是众所周知的,聪明的系统开发者是不会把这种问题交给上层应用的,所以,在事件驱动没那么有必要的年代,表现出来的系统调用都是同步的,当然也是阻塞的。这也是为什么后来线程大行其道的原因,事件驱动遭遇阻塞调用的情况下,就丧失它那些自然的优势。所以有人说大多数情况下线程不是个好主意,有人就说事件对高并发服务器场合来说就是个坏主意。Whatever站在他们的立场上来看,他们的描述都没错。

现在人们知道,并发模型都可以视作是事件驱动的,它们大多依赖于一些消息队列,管道,邮箱这些设施,来保证并发事务间正确交互和同步所需要的特性,或者更底层一些,是事务操作这些同步设施时使用接口的相对原子性,保证了交互时不会出现使用共享内存中锁互斥带来了一些问题。所以回过头看Erlang,确实处理问题的方式是极其粗野而霸道的,通过取消线程,直接断绝了共享的念头;再通过轻量级进程,允许大量的使用进程,或异步调度,来避免到阻塞调用的带来的问题;最后再提供一个良好的消息机制,满足事务交互或同步的需要。最终提供了一个既灵活又足够高效的并发体系。当然,在一般人看来Erlang的成功毕竟只是在特定场合下的,没人会感到,这会对系统层面和一些关键应用带来什么影响。

影响什么的,毋庸置疑,一定是有影响的。有一门通用的纯函数编程语言叫Haskell,它实现了线程和事件驱动的统一。Haskell在任何时候采用线程都是可行而且对用户是透明的。这个实现通过运行时系统提供应用级线程调度,然后使用monad来表达顺序逻辑状态演变,赋予do记号线程执行特性;同时在事件循环中可以使用映射或者模式匹配表达事件处理过程以及赋予事件处理过程的CPS monad同样的线程执行特性,来提供高并发性。之后Scala也是依葫芦画瓢也搞了一个,事件和线程被赋予统一的actor语义。Golang的Goroutine本质上也是一样,只不过没有函数式的这么多限制限制用户罢了。一般认为Actor模型更像事件驱动,因为消息发送和接收是异步的,但这不是关键。这些事件驱动和线程统一实现的最大共同点就在于它们都允许大量的线程/进程的存在,当然,为了避免性能问题,这语言都依赖于其运行时系统实现的所谓轻量级的线程/进程。由于这个因素,线程间的通信无论是异步还是阻塞的,除了是否同步外,差异已经非常小了。

开篇这些只不过是笔者的闲言碎语,作为抛砖引玉的一个引子,顺便再做一个归纳总结。只是想让读者可以系统性的去理解并发编程的演变,我们现在看到的这些形式化模型并非一蹴而就,也是经过长时间的演变而来。接下来笔者会选择几个极具有代表性的模型展开讲解。

1.1 多线程编程模型

多线程模型是用于处理并发的最通用手段,在 C/C++/JAVA 等语言中广泛存在。主要特性有:

  • 多个相互独立的执行流。
  • 共享内存(状态)。
  • 抢占式的调度。
  • 依赖锁,信号量等同步机制。

多线程的程序容易编写,每一个线程都是按照串行的执行顺序,但是问题在于一旦除了问题就很难分析,难调试。常见的问题比如说data race(竞争条件),deadlock(死锁),资源耗尽,优先级反转等等。而且为了降低多线程模型的编写难度,很多语言都一直不断引入并发编程方面的新特性,以Java为例。1996年JDK1.0版本的时候就有了Thread,Runable类,确立了最基础的线程模型,这已经比直接去调用OS内核的POSIX接口创建多线程应用的方式有了很大的提高。JDK5时引入了java.util.concurrent包,这个包中的线程池(Thread Pool,Executors)等的类库使得Java在并发编程的易用性有了进一步的提升。到了JDK7,Fork/Join框架被引入,虽然底层一样是基于ExecutorService线程池的实现。但在编写并发逻辑时会比传统多线程方式更加直观,开发者可以将一个大的作业抽象为几个可以并发的子任务的结果整合;而每个子任务又可以继续按此逻辑继续划分,充分发挥现代多核CPU的性能。举个例子,如果要计算一个超大数组的和,最简单的做法是用一个循环在一个线程内完成。基于Fork/Join的设计,那我们就可以将数组拆成N份,且每一份都有一个独立的线程去计算求和,最后把所有线程的计算结果SUM求和,最后就出了结果。

图中最顶层的任务使用submit方式被提交到Fork/Join框架中,后者将前者放入到某个线程中运行,工作任务中的compute方法的代码开始对这个任务T1进行分析。如果当前任务需要累加的数字范围过大(代码中设定的是大于200),则将这个计算任务拆分成两个子任务(T1.1和T1.2),每个子任务各自负责计算一半的数据累加,请参见代码中的fork方法。如果当前子任务中需要累加的数字范围足够小(小于等于200),就进行累加然后返回到上层任务中。

Fork/Join框架中还有Work-Stealing的任务调度机制,能够在尽量降低线程竞争的同时尝试自动均衡各工作线程之间的任务负载。

看这个图有没有一种很熟悉的感觉,图中的队列和Go语言中的Process有异曲同工之妙。我去翻了下Java7的发布时间是2011年7月7日,那个时候Go还在1.0的时代,还是GM模型呢。Dmitry Vyukov的《Scalable Go Scheduler Design Doc》是2012年5月2号发表的,有了这篇设计之后才有了现在Go语言的GMP模型。写到这里我也不经感叹设计之妙,牛🐂!!

  • 4个线程每个都有独立的工作队列,避免单任务队列竞争
  • 队列中的任务采用类似LIFO方式进出。由于整体作业都是按照一个大任务fork出多个子任务来抽象,因此可以视为越大粒度的任务会沉在队列的越底部。
  • 当某个线程(示例中为线程D)的工作队列为空时,该线程就会自动尝试从另一个线程(示例中为线程A)的队列底部”偷“一个任务过来执行。由于是从底部窃取的任务,可以假设这个任务将展开更多的子任务,从而减少窃取动作的产生,降低线程争用频率。

通过这些手段,Fork/Join框架能帮助开发者无需在考虑手动实现并发任务执行时的高效同步逻辑。

随后,JDK8中又引入了并行流(Parallel Streams)的概念,该特性基于Fork/Join框架,但在易用性方面继续有所提升。并行流采用共享线程池的思路,从而连线程/线程池的配置逻辑都帮开发者简化了。当然,正是因为这个共享池( ForkJoinPool.commonPool() )是被JVM管理,同时被JVM内的所有线程共享,也导致了一些隐患,如果开发者并没有了解并行流的底层实现机制,则可能导致应用中利用到并行流的任务产生停滞现象。例如下面的代码示例:

由于 WS.url(url).get()会触发HTTP请求,因此执行到这一句代码时,线程池会被阻塞在IO操作上,结果导致了当前JVM中所有并行流中的任务全部被阻塞。

1.2 Callback编程模型

“回调”是一个很容易理解的名词。简单来说:某个函数(A)可以接受另一个函数(B)作为参数,在执行流程到某个点时作为参数的函数B就会被函数A调用执行,这个行为就被称为回调。

现实中,回调常常用于异步事件。即,函数A一般会在函数B没有被调用的情况下就先返回,而在某个异步事件发生时再触发调用函数B。这里就不得不提下Nginx了,通过异步非阻塞的事件处理机制,实现了循环处理多个准备好的事件,从而实现轻量级和高并发。看源码会发现充斥着大量的函数回调。说实话给伊戈尔·赛索耶夫跪了,用业余时间撸出了传奇级别的Nginx。其实还是Apache的Httpd太屎了,一个请求一个线程这种做法就很麻瓜。

如下图:

1.3 Actor编程模型

Actor并发模型,随着erlang编程语言一起被大众熟知,它由Joe Armstrong在爱立信开发,并且被WhatsApp使用来处理复杂的并发逻辑。在Actor模型中,Actor参与者是一个并发原语,简单来说,一个参与者就是一个工人,与进程或线程一样能够工作或处理任务。可以将Actor想象成面向对象编程语言中的对象实例,不同的是Actor的状态不能直接读取和修改,方法也不能直接调用。Actor只能通过消息传递的方式与外界通信。每个参与者存在一个代表本身的地址,但只能向该地址发送消息。在计算机科学领域,Actor是一个并行计算的数学模型,最初是为了由大量独立的微处理器组成的高并行计算机所开发的。

Actor模型的理念非常简单:万物皆Actor。Actor模型将Actor当作通用的并行计算原语:一个参与者Actor对接收到的消息做出响应,本地策略可以创建出更多的参与者或发送更多的消息,同时准备接收下一条消息。简单来说,Actor模型是一个概念模型,用于处理并发计算。它定义了一系列系统组件应该如何动作和交互的通用规则,最著名的使用这套规则的编程语言是Erlang。Erlang引入了”随它崩溃“的哲学♂理念,这部分关键代码被监控着,监控者supervisor唯一的职责是知道代码崩溃后干什么,让这种理念成为可能的正是Actor模型。在Erlang中,每段代码都运行在进程中,进程是Erlang中对Actor的称呼(这里需要说明下,Erlang中所谓的进程并非OS的进程,这里的进程是指运行在Erlang虚拟机上的轻量级单位,可以创建上万个,创建一个Erlang进程仅需要300 byte的内存空间,对比下goroutine是2MB,差距还是蛮大的。)这就意味着它的状态不会影响其他进程。系统中会有一个supervisor,实际上它只是另一个进程。被监控的进程挂掉了,supervisor会被通知并对此进行处理,因此也就能创建一个具有自愈功能的系统。如果一个Actor到达异常状态并且崩溃,无论如何,supervisor都可以做出反应并尝试把它变成一致状态,最常见的方式就是根据初始状态重启Actor。有点儿K8s的声明式API的意思了。简单来说,Actor通过消息传递的方式与外界通信,而且消息传递是异步的。每个Actor都有一个邮箱,邮箱接收并缓存其他Actor发过来的消息,通过邮箱队列mail queue来处理消息。Actor一次只能同步处理一个消息,处理消息过程中,除了可以接收消息外不能做任何其他操作。每个Actor是完全独立的,可以同时执行他们的操作。每个Actor是一个计算实体,映射接收到的消息并执行以下动作:发送有限个消息给其他Actor、创建有限个新的Actor、为下一个接收的消息指定行为。这三个动作没有固定的顺序,可以并发地执行,Actor会根据接收到的消息进行不同的处理。总结一下,换一种简单的说法,一个Actor是一个基本的计算单元,通常Actor会接收消息,然后基于消息做某些事情,Actor相互之间又是完全隔离,不会共享内存,同事Actor会维护私有状态,不会直接被其他Actor修改,Actor之间只能传递消息。

在Actor系统中包含一个未处理的任务集,每个任务都由三个属性标识:

  • tag用以区分系统中的其他任务
  • target 通信到达的地址
  • communication 包含在target目标地址上的Actor,处理任务时可获取的信息。

为简单起见,可见一个任务视为一个消息,在Actor之间传递包含以上三个属性的值的消息。

Actor模型有两种任务调度方式:基于线程的调度、基于事件的调度

  • 基于线程的调度

    为每个Actor分配一个线程,在接收一个消息时,如果当前Actor的邮箱为空则会阻塞当前线程。基于线程的调度实现较为简单,但线程数量受到操作的限制,现在的Actor模型一般不采用这种方式。

  • 基于事件的调度

    事件可以理解为任务或消息的到来,而此时才会为Actor的任务分配线程并执行。

因此,可以把系统中所有事物都抽象成为一个Actor:

  • Actor的输入是接收到的消息
  • Actor接收到消息后处理消息中定义的任务
  • Actor处理完成任务后可以发送消息给其它Actor

在一个系统中可以将一个大规模的任务分解为一些小任务,这些小任务可以由多个Actor并发处理,从而减少任务的完成时间。Actor模型的另一个好处是可以消除共享状态。这句话怎么理解,因为Actor本身封装了状态和行为,并发编程的情况下,Actor只需要关注消息和它本身,而消息又是一个不可变的对象,所以Actor不需要去关注锁和内存原子性的一系列多线程常见的问题而且Actor每次只能处理一条消息,所以Actor内部可以安全的处理状态,锁就不需要考虑了。这点其实非常牛逼,从模型的设计层面就避免了一些并发编程所导致的各种不可控问题,况且使用锁,多多少少都会在性能上面带来一定的影响,使用锁也是一个非常讲究的事情。下图是Actor模型的设计图。

Actor包含发送者和接收者,设计简单的消息驱动对象用来实现异步性。

举一个耳熟能详的场景,买火车票。场景的描述是用户购买一定数量的火车票,如果火车票足够,则成功下单,相应的库存的火车票应该被减,若库存不足则拒绝下单请求。这里把接收用户购票请求的计数器替换为一个Actor。当然Actor也要在线程中运行。Actor只有在有事情可做的时候才有持有线程,这点可以放心。请求者也具象化成一个Actor实例。假设,请求者是CutomerActor,计数器是TicketsActorCustomerActorTicketsActor在空闲idle或没有事情做的时候都不会持有线程。

在初始购买操作时CustomerActor需要发送一个buy消息给TicketsActor,buy消息中包含了要购买的数量。当TicketsActor接收到buy消息时会校验购买数量是否超过库存数量,若合法则递减数量。此时TicketsActor会发送一条success消息给CutomerActor表明订单被成功接受。若购买数量超过库存数量TicketsActor也会发送给CustomerActor一条消息,表明订单被拒绝。整一个业务流程被具象化成三点

1. Customer Actor 发送 buy 消息
2. Tickets Actor 处理消息
3. Tickets Actor 拒绝购买请求
  1. Customer Actor 发送 buy 消息

customer Actor,它们各自发送 buy 消息给 Tickets Actor。这些 buy 消息会在 Tickets Actor 的收件箱(mailbox)中排队。发送一条消息并未将“执行线程”从发送者转移到目标。一个actor可以发送一条消息并继续无阻塞地运行。因此,在同样的时间内,它可以完成更多任务。

  1. Tickets Actor 处理消息

如下展示的是请求购买五张火车票的第一条消息。

当一个Tickets Actor收到一条消息时,Tickets Actor将这条消息添加到队列尾部,如果Tickets Actor没有被调度执行,它将被标记为ready。一个调度器获取这个Tickets Actor并开始执行它:Tickets Actor在队列头部取出一条消息。

随后,Tickets Actor 检查购买数量没有超出剩余火车票的数量。在当前的情况下,火车票数量是 15,因此购买请求能够接受,剩余火车票数量会递减,Tickets Actor 还会发送一条消息给发出请求的 Customer Actor,表明火车票购买成功。

Tickets Actor 会处理其收件箱中的每条消息。需要注意,这里没有复杂的线程或锁。这是一个多线程的处理过程,但是 Actor 系统会管理线程的使用和分配。

在这里Actor区别于多线程并发模型的是,多线程并发模型改变了actor并对内部状态,而actor独立处理收到的消息,并且它们一个一个地响应连续到来的消息。虽然每个actor连续地处理发给它的消息,不同的actors之间并发地工作,所以一个actor系统可以同时处理多条消息。因为每个actor中同时最多处理一个消息,所以Actor模型无需使用锁。

  1. Tickets Actor 拒绝购买请求

当请求的数量超过剩余值时,Tickets Actor 会如何进行处理。这里所展现的是当我们请求俩火车票,但是仅剩一火车票时的情况。Tickets Actor 会拒绝这个购买请求并向发起请求的 Customer Actor 发送一条“sold out”的消息。

在线程方面有一定经验的开发人员会知道,可划分为两个阶段的行为检查和火车票数量递减能够通过同步的操作序列来完成。以在 Java 中为例,我们可以使用同步的方法或语句来实现。但是,基于 Actor 的实现不仅在每个 Actor 中提供了自然的操作同步,而且还能避免大量的线程积压,防止这些线程等待轮到它们执行同步代码区域。在火车票样例中,每个 Customer Actor 会等待响应,此时不会持有线程。这样所形成的结果就是基于 Actor 的方案更容易实现,并且会明显降低系统资源的占用。

我想通过这个例子说明两点

1. actor通过消息传递的方式与外界通信。消息传递是异步的。每个actor都有一个邮箱,该邮箱接收并缓存其他actor发过来的消息,actor一次只能同步处理一个消息,处理消息过程中,除了可以接收消息,不能做任何其他操作。
2. Actor模型的另一个好处就是可以消除共享状态,因为它每次只能处理一条消息,所以actor内部可以安全的处理状态,而不用考虑锁机制。消息传输和封装虽然多个actor可以同时运行,但它们并不共享状态,而且在单个actor中所有事件都是串行执行的。

Actor是由状态(state)、行为(behavior)、邮箱(mailbox)三者组成的。

  • 状态(state):状态是指actor对象的变量信息,状态由actor自身管理,避免并发环境下的锁和内存原子性等问题
  • 行为(behavior):行为指定的是actor中计算逻辑,通过actor接收到的消息来改变actor的状态。
  • 邮箱(mailbox):邮箱是actor之间的通信桥梁,邮箱内部通过FIFO消息队列来存储发送发消息,而接收方则从邮箱中获取消息。

Actor模型描述了一组为避免并发编程的公理:

  • 所有的Actor状态是本地的,外部是无法访问的。
  • Actor必须通过消息传递进行通信
  • 一个Actor可以响应消息、退出新Actor、改变内部状态、将消息发送到一个或多个Actor。
  • Actor可能会堵塞自己但Actor不应该堵塞自己运行的线程

遵循上述的公约,Actor可以做到良性处理并发,并且资源占用也是比较优秀的。Actor还有一个优点就是容错性。Actor通过模型监督机制提供容错。这和Java中的throw exception有点类似,都是把处理响应错误的责任交给出错对象以外的实体。但在java中如果一个程序或者线程抛出了一个异常,你敢放心的恢复对应的程序或线程吗?你确保恢复之后还能正常的运行吗,毕竟需要很多资源需要重新创建。但Actor模型可以!这点也是我觉得Actor真正牛逼的地方

如上图所示actor之间是有层级关系的,子actor如果出现了异常会抛给父actor,父actor会根据情况重新构建子actor,子actor从出现异常,到恢复之后正常运行,这段时间内的所有消息都不会丢失,等恢复之后又可以处理下一个消息。也就是说如果一个actor抛出了异常,除了导致发生异常的消息外,任何消息都不会丢失。

除了容错性,actor还有一个有点就是分布式与位置透明性。Actor模型中一个很重要的概念就是actor地址,因为其他actor需要通过这个地址与actor进行通信。akka考虑到分布式的网络环境,对actor地址进行了抽象,屏蔽了本地地址和远程地址的差异,对于开发者来说基本上是透明的。

讲了这么多actor的优点,难道它就没有缺点的,世界上并不存在什么绝对完美,任何设计都是有针对的使用场景的,扬长避短。Actor模型针对一致性要求比较强的场景比较乏力。以银行转账为例,假设有两个用户,现在用户A向用户B转账100元。用户 A 和 用户 B 明显是两个 Actor ,但我们同时还需要一个可以控制用户A Actor 和用户B Actor 的 Actor ,我们称之为 转账管家 Actor,流程图如下。

可以看到,当一个转账需求过来的时候,Actor 管家会先向 用户A Actor 发送扣款 100 元的信息,接受到扣款成功消息后再发送消息给用户B Actor,发送让其增加 100 元的消息。

但是!在用户A Actor 扣款期间,用户B Actor 是不受限制的,此时对用户B Actor 进行操作是合法的!针对这种情况单纯的Actor模型就显得比较乏力了,需要加入其他机制以保证一致性。

总结下Actor的使用场景

1. 对高并发有严格要求的同时又要维护某种状态
2. 构建有限状态机,如果只是处理一个有限状态机,使用一个actor即可,如果是多个有限状态机,而且还要彼此交互,更应该选择actor模式
需要高并发,同时也需要很小心地管理并发
3. 需要高并发,同时也需要很小心地管理并发 eg:需要确保特定的一组操作可以与系统中的某些操作并发运行,但不能与系统中其他操作并发运行

尽管使用actor模型的程序比使用线程与锁模型的程序更容易debug,但actor模型仍会碰到死锁这一类的共性问题,也会碰到一些actor模型独有的问题(例如信箱溢出)。

类似于线程与锁模型, actor模型对并行也没有提供直接支持。需要通过并发的技术来构造并行的方案,这样就会引入不确定性。而且,由于多个actor并不共享状态,仅通过消息传递来进行交流,所以不太适合实施细粒度的并行。

1.4 CSP模型

CSP模型,全称:Communicating Sequential Process ,翻译成中文是,通信顺序进程,最初于Tony Hoare的1977年的论文中被描述,影响了许多编程语言的设计(Golang就是其中最具有代表性的一个)。不过还是得实现澄清一些事情,Communicating Sequential Processes 本身是一门语言。wiki 上给的一种形式语法是这样的 (还没包括 trace 等):

粗略的语义描述可以参见 wiki, 但想要深入理解请走正道: 作者 Tony Hoare 写的http://www.usingcsp.com/cspbook.pdf

CSP 的一个作用是 model checking: 用这套语言来描述一个并发系统或者流程, 然后丢给分析软件, 它就能给出这个系统的很多性质. 例如你把社保局办证大厅的办事流程扔给它, 它或许会帮你找出其中的一些漏洞让你可以钻空子.

Golang 就和 Plan9 libthread, DirectShow, ... 一样, 只是顺带借用了些 CSP 概念, 但用 Golang 写的程序是不能用 CSP 工具分析的, Golang 编译器也没这样的分析, 所以依然无法杜绝死锁/活锁 (Go 编译器有一个 -race 检测, 不过和 CSP 的分析是两回事)。

Don’t communicate by sharing memory, share memory by communicating。

这句话熟悉吧,罗老爷子的经典语录,没错,这里其实指的就是CSP模型的核心理念,将消息做为不同的线程的通信的手段。我们常见的多线程模型一般是通过共享内存实现的,但是共享内存就会有很多问题。比如资源抢占的问题、一致性问题等等。为了解决这些问题,我们需要引入多线程锁、原子操作等等限制来保证程序执行结果的正确性。

Golang只使用了CSP当中关于Process/Channel的部分。简单来说Process映射Goroutine,Channel映射Channel。Goroutine即Golang当中的协程,Goroutine之间没有任何耦合,可以完全并发执行。Channel用于给Goroutine传递消息,保持数据同步。虽然Goroutine之间没有耦合,但是它们与Channel依然存在耦合。

整个Goroutine和Channel的结构有些类似于生产消费者模式,多个线程之间通过队列共享数据,从而保持线程之间独立。这里不过多深入,我们大概有一个印象即可。

1.5 CSP与Actor对比

对CSP模型和Actor模型,不管是Channel还是Actor,本质上都是通过消息通信实现线程间的通信。但是两者还是存在着明显的区别。

1.5.1 Channel与Mail BOX

channelmail box 看上去非常像,看上去都是FIFO队列,但是两者区别很明显。Actor模型的mail box对开发人员是透明的,这里的透明是指开发者从外部感知不到存在。mail box明确归属于一个特定的Actor进程,是Actor的内部机制,Actor之间是可以直接通信的,不需要通信媒介。回头看channelchannel就是一个通信媒介,而且对于开发者而然是可见的,可以感知到它的存在。这就是本质的区别。

1.5.2 Blocking与None Blocking

Actor 模型中发送消息是非阻塞的,而 CSP 模型中是阻塞的。Golang 实现的 CSP 模型,channel 是一个阻塞队列,当阻塞队列已满的时候,向 channel 中发送数据,会导致发送消息的协程阻塞。

1.5.3 消息送达

Actor 模型理论上不保证消息百分百送达,而在 Golang 实现的 CSP 模型中,是能保证消息百分百送达的。不过这种百分百送达也是有代价的,那就是有可能会导致死锁。比如穿件一个不带buffer的channel ch,然后从ch中接收数据,主协程blocking,整个应用也就随之blocking。

func main() {
    // 创建一个无缓冲的channel  
    ch := make(chan int)
    // 主协程会阻塞在此处,发生死锁
    <- ch 
}
posted @ 2022-02-19 11:51  ttlv  阅读(87)  评论(0编辑  收藏  举报