详论 GO 与 ERLANG 的并发编程模型差异
许式伟:我们开始,先介绍一下ECUG,从07年开始,最早在珠三角珠海广州深圳,在珠三角兴起,最早是Erlang的社区。大概到10年的时候更名为实时效云计算的群组,最早的时候也不局限于Erlang,而是会有各种语言如Haskell、Scala等..,其实根本就没有限制,只要是中途穿插后端开发运维的实践都可以,后来我们就正式改名为实效云计算的群组。,范围扩也蛮大到全国,基本上北京、长三角都有举办过。所以应该说到今天坚持了也差不多有8年,总共有9届,07年的时候办了2届。这个是ECUG 的历史。南京是第一次办这个大会,我大学是在南京念的,。今年想的挺久的,我们希望是能够把这个火花在所有的城市都能够点燃,所以今年就选择了南京这样一个对我来说比较有特殊意义的地方。
我开始我的话题了。其实,这个话题我其实在杭州的ECUG上时候讲过,但是当时讲的比较婉约,实际上我当时已经意识到Erlang的编程风格的问题,但是解刨得并不彻底,,所以我今天又回头谈一下这个话题,用相对比较详细的方法去对比说Go与Erlang并发模型两者到底有什么不同?因为基本上我知道ECUG 发展历史的人都有困惑,为什么我会从Erlang转到切到GoO。
这个是话题的来由还是想从头谈谈GO和Erlang的并发,我在09年开始决定放弃用Erlang自己在C++里面重新造一个Erlang的编程模型,CERL这个网络库最初的出发点是这样的,所以CERL的C代表的是C/C++、ERL代表的是Erlang。最早的思路是简单把Erlang搬到C++,因为Erlang的程序员确实比较难招,但是后来发现其实Erlang的并发模型并没有我想象得那么舒畅,就搞了一个CERL2.0,它是对Erlang模型的反思和改进。最后发现这个反思最终得到的结果和Go的并发模型完全一致。所以CERL1.0和2.0的对比,其实你可以认为是 Erlang 和 Go 的对比。其实很多人问我这个话题,为什么CERL没有开源?原因是我觉得过了那个时间点了,开源没有太大的意义,所以我觉得不太想误人子弟,因为我自己最早是C++的粉丝,但是我接触Go以后有一个非常强烈的愿望我希望C++这样的东西最好还是能够早点退出历史舞台,关于这个话题我曾经有一个演讲,是讲Go与七牛的历史,我反思了我的C++奋斗史,那个演讲我会后给大家作为一个补充的材料放上去。
既然没有开源那应该怎么理解CERL呢?其实这个世界有类似的东西,这个Pth是我最近才知道的,中途还看到过另一个开源库,大概在08年开源的,可惜我一时找不到项目网址了。当时我看了一下跟CERL差不多,但是没有CERL库写的完整,但是Pth这个库出现历史是非常早的,而且是GNU下的一个开源项目,它是99年就已经起动了,到06年左右就不再更新了。它的出现时间非常早,并发模型和CERL是几乎一样,而且完成度非常高,毕竟发展了7年,所以要理解CERL其实也是研究一下这个库基本上就差不多了。但是我其实有一个反思,为什么这个Pth这么好的东西为什么没有流行起来?第一个是生不逢时,出现的太早所以没有引起注意,因为其实大概谈多核时代这样的概念在我的印象当中是07年左右开始有这样的概念,Erlang也是那个时候才逐步被人意识到价值的。第二个就是不是标准库,因为这样一个库侵入性是非常强的不光你意识它好还有别人也意识到,否则有一个问题别人写库你是不能用的,这个就是侵入性和传染性,所以会导致其实没有办法真的把这个库用起来,这种有侵入性和传入性的库最初兴起的时候需要有一些激发的条件,它没有这样的条件。这和 C/C++ 中 GC 比较难流行是类似的道理,因为 GC 也有侵入性和传染性。第三个是从实现讲,还是有瑕疵的,最大的瑕疵就是轻量级进程并不是真的轻量。轻量级进程的核心不只是要性能好, 更重要的是资源占用要小,但多数情况下的这种资源占用小这个其实是比较难实现的, Go 在这一点做的比较好,有栈的自动增长,最小的栈最初的时候可以只有 4K , 这样每个轻量级进程从资源占用来说真的很轻量。但是要达到这一点这个绝大多数的库都很难做到。像CERL我们只能做到说你自己指定说这个轻量级进程栈要多大,但是对程序员来说指定栈大小是非常困难的事情,有很大的心智负担。要理解CERL,研究这个Pth是比较好的学习材料,当然第二个我认为就是直接学习Go的Runtime了。从轻量级进程来讲,它的底层跟CERL是一样的。
轻量级进程模型我非常早就提了,从我最初提倡Erlang的时候就已经提出了这个概念,什么是轻量级进程模型呢?很简单就是两个,一个是鼓励用同步IO写程序逻辑,第二点是用尽可能多的并发进程来提升IO并发能力。这和异步IO并发模型不一样的,哪怕你是单线程也可以做高并发。
所有轻量级进程并发模型的核心思想都是一样的,第一让每个轻量级进程的资源占用更小,这样就可以创建百万千万级别的并发,能够创建的进程个数唯一限制就是你的内存。每个进程资源占用的越小能够产生的并发能力就越高。做服务端大家都知道,内存资源是非常宝贵的资源,但某种意义来说也是非常廉价的。第二就是更轻的切换成本,这是为什么把进程做到用户态,这个和函数的调用基本是在同一个数量级的,切换成本非常非常低。但是如果是操作系统进程则至少要从用户态到核心态再到用户态的切换。
讲一下轻量级进程模型的实现原理,这个是蛮多人还是比较关注的。我之前比较少谈这个,但是我今天我们详细的谈一谈轻量级进程到底是怎么回事。先谈谈进程,所谓的进程到底是什么样的东西?其实进程本质上无非就是一个栈加上寄存器器的状态。进程的切换怎么做呢?就是保存当前进程的寄存器,然后把寄存器修改为另外一个新进程的寄存器状态,这样相当于同时也切换了栈,因为栈的位置其实也寄存器维持的(ESP/EBP)。这个就是进程的概念,哪怕操作系统的内核帮你做的本质上也是这样。所以这些事情是在用户态一样可以做到,而不是不能做到。本质上来讲和函数的那个调用你可以认为也是差不多,因为函数的调用也是保存寄存器,只是相对少一些,至少不会切换栈。所以本质上讲其实是这个切换的成本是和函数调用是基本上差不多的,我自己测过,大概就是函数调用的10倍左右,基本还是在同样的数量级范畴。那么在这样一个轻量级进程的概念引入以后,实际上整个轻量级进程的程序物理上是怎么样的?底层其实还是线程池加异步IO,你可以把这个线程池中的每个线程想象成虚拟CPU(VCPU)。逻辑的轻量级进程(routine)的个数通常是远大于物理的线程数的,每个物理的线程同一个时刻肯定只有一个routine在跑,更多的routine是在等待当中的。但是这个等待中的routine有两种,一种是等IO的,就是说我把CPU交给他也干不了活,还有一种是IO操作已经完成,或者是自己本身并没有等任何前置条件,总之是可以参与调度的。如果某一个物理的线程(VCPU)它的routine主动的或者是因为IO触发了一个调度,把线程(VCPU)让出来,这个时候就可以让一个新routine跑在上面,也就是从等待当中并且可以满足调度的routine参与调度,按照某种优先级算法选择一个routine。所以轻量级进程调度原理是这样的,它是用户态的线程,然后有一个非强占式的调度机制,调度时机主要由IO操作触发。发生IO操作的时候,IO操作的函数是这样实现的:首先发起一个异步的IO请求,发起后把这个routine状态设置为等待IO完成,然后再出让CPU,这个时候也就触发调度器的调度,这个时候调度器就会看看有没有人等着调度,有它就可以切换过去。然后再IO事件完成的时候,IO完成后通常会有一个回调函数作为IO完成的事件通知,这个会被调度器接管,具体做什么呢?很简单就是把这个IO操作所属的routine设为Ready,可以参与调度了。因为刚刚它的状态是在等IO,就算调度到它也没有办法做事情。而 Ready 的话就是让这个routine可以参与调度。还有一种情况就是routine主动出让CPU,这种情况下routine的状态在切换的时候仍然是Ready的,任何的时间都可以切到它。以上几个基本上是非强占式的调度里面最基础的几个调度器触发的条件:IO操作、IO完成事件、主动出让CPU。但是其实在用户态的线程也可以实现强占式的调度,做法也是非常简单的,调度器起来一个定时器,这个定时器定时出发一个定时任务,这个定时任务检查每个正在执行当中的routine的状态,发现占CPU时间比较长就可以让它主动地让出CPU,这就就可以实现强占式的调度。所以哪怕在用户态,它可以完全实现操作系统进程调度所有做的事情。这就是轻量级进程的实现原理。
下面一个问题是Erlang和Go到底有什么不同?这两个不都是轻量级进程的并发模型?应该说它们的基础哲学确实差不多,但是细节上有非常大的差异,而不是一点点的差异。主要的差异是在于几点:第一个对锁的态度不一样,第二个对异步IO的态度不一样,第三个不算最主要的细节,但是是次重要的细节,两者的消息机制不太一样。
首先谈谈对锁的态度,Erlang 对锁非常反感,它认为变量不可变可以很大程度避免锁,Erlang 认为锁有很大的心智负担所以不应该存在锁。Go 的观念是锁确实有很大的心智负担,但是锁基本上避无可避。我们先宏观看看锁为什么是避无可避的,首先服务器首先是一个共享资源,是很多用户在用的,不是为某一个人用的, 所以服务器本身就是共享资源, 一旦有并发就是这些并发请求就在抢这个共享资源。我们清楚, 一旦有人共享状态并且相互强占去改变它的话,这个时候必然是有锁的,这点是不以技术的实现细节为转移的, 当然这个分析是从宏观角度讲,后面我还会讲技术细节,来谈锁为什么不可以避免。
Erlang为什么没有锁呢?实际上Erlang的服务器是单进程(Process)的,是逻辑上就无并发的东西。一个Process就是一个执行体,所以Erlang的服务器和Go的服务器不一样,Go的服务器必然是多进程(goroutine)一起构成一个服务器的,每个请求一个独立的进程(goroutine)。但是Erlang不一样,一个Erlang服务器是一个单进程的东西,既然是一个单进程的首先所有的并发请求都进入了进程邮箱(后面会谈这个进程邮箱),然后这个服务器从进程邮箱里面取邮件(请求的内容)然后处理,所以Erlang的单个服务器并没有并发的请求,这个是他不需要锁的根本原因,其实并不是因为它没有变量,变量不可变这些。因为大家都知道单线程的服务器一定是没有锁的。那么可能会有人问,那Erlang怎么做高并发呢?其实是两点:第一是每个Erlang物理的进程会有很多的服务器,每个服务器相互是无干扰的,它们可以并发。第二是单服务器想要高并发怎么办?Erlang对这个问题的回答就是请异步IO。
但是异步 IO 给 Erlang 带来了什么麻烦呢?首先是服务器状态变复杂了,这个复杂是非常非常要命的,这导致我最后认为 Erlang一旦引入了异步 IO 之后,其实比正统的异步 IO 编程模型还要糟糕。我们看几点。首先为什么会有中间状态的引入?因为有异步 IO,所以刚刚的某一个请求其实还没有完成,但是它必须把时间让给另外一个请求,所以这个时候服务器就要维持刚刚没有完成的那个请求的中间状态。一旦有中间状态的话,这个服务器的状态本身就不干净,单次请求的中间状态要服务器来维持状态,这个是非常不合理的事情。第二,这个服务器的中间状态将导致比较复杂的状态机,这里面的状态很复杂,因为服务器不只是要维持一个请求的状态,而是所有的未完成的请求的状态都要它来维持。第三,这些中间状态会导致有锁的诉求,为什么会有锁的诉求我下面会讲。所以Erlang虽然试图避开锁,但是一旦有异步 IO 其实本质上仍然没有办法避开锁。
为什么Erlang没有避开锁呢?刚刚我们已经讲了,本质上讲是因为有进程邮箱的存在,而且Erlang的服务器是单进程(执行体),所以常规上没有并发所以不需要锁,但是一旦引入了异步IO以后就会有伪的并发。既然是单的进程,不可能真的有并发,但如果我们把Erlang的进程(Process)也是认为一个VCPU,因为有请求没有完成,所以同时就有很多并发请求在同一个VCPU上跑。这中间可能出现某个请求需要暂时占用某种资源是不能释放的,会出现一些相互互斥的行为。一旦有这样的行为就必然有锁,这个锁虽然不是操作系统实现而是自己实现,具体可能会体现为类似BusyFlag这样的东西,这其实就是锁。所有锁的特征,比如说忘记把这个释放了,整个服务器就被挂住了,它的行为和所有的锁的行为是完全一样的。有人会说我根本没有操作系统锁,的确单线程的程序必然不会有操作系统的锁,但是不能怀疑其实我们代码里面是有锁的。
所以,在对锁的态度这个问题上,Erlang竭力避免锁,但是实质上只是把锁的问题抛给用户。而Go则选择接受了锁无法回避的事实。
我们再看对异步IO的态度。Go认为,无论如何都不应该有异步IO的代码。而Erlang从轻量级进程并发模型来说不是很纯粹,它没有排斥异步IO,是一个混杂体,是异步IO编程加上轻量级进程模型的混杂,这个混杂的结果是让Erlang的编程,一旦用了异步IO的话,其实是比单纯的异步IO编程的心智负担还要大。
最后一个细节是我刚刚讲过的次重要的概念,它是 Erlang的进程邮箱,所有发给Erlang进程的消息都会发到这个进程邮箱,Erlang提供邮箱收发消息的元语。Go则提供了channel这样的通讯设施,这个channel可以轻易创建很多个,然后用它进行进程通讯。相比之下,Go的消息机制抽象更轻盈。消息队列和进程是完全独立的设施。
那么,我们再看看我们应该如何去理解Go的并发模型?Go的并发模型很新吗?其实不是的。我在很多的场合都讲过,Go的并发模型其实根本不是一个创新性的东西,为什么呢?因为Go的并发模型是从有网络以来我们就是这么写程序的,从第一天写网络程序的时候我们写的就是Go推崇的并发模型。那么问题在哪里呢?为什么大家最后放弃了最古老的并发模型?原因是因为OS的进程和线程太重,导致了大家人们去想方设法提高IO并发的时候用了一些歪招,也就是今天大家广泛接受的异步IO编程范式。这个异步IO变成范式带来的问题是程序员的编程心智负担大大加重。所以Go的创举有两点:第一点就是价值回归,其实最古老的并发编程模型就是最好的并发模型。它的问题是执行体的成本,所以Go的最重要的事情就是让执行体的成本无限降低,大家知道Go的最新版本栈最小可以到4K,小到让很多人觉得不可思议。所以这一点Go其实是从实现层面解决的,而不是从编程范式解决的。Go第二个创举是让执行体变成了语言内建的标准设施,刚刚我说那个Pth库流行不起来是因为这种并发模型是有传染性和互斥性的,这个系统当中不应该有两个这样的设施,而如果大家用的设施不一样,它是会排斥的,这个传染性必须要求执行体必须成为标准化的东西。而且这已经是什么年代了?多核时代已经喊了快十年了,但是我们大家可以看到,几乎没有多少语言把执行体这个作为语言内建标准来做,我觉得这是Go很大的创举。
让我们回顾一下,Go的并发模型其实就是这一页提到的东西。它是最古老的并发模型。现代的操作系统,以及大家学的操作系统原理,和Go里面的概念完全一致。首先这个并发模型涉及的是执行体这样一个概念,也就是Go的goroutine,然后一次是原子操作、互斥体、同步、消息,最后就是同步IO。这些就是Go的并发模型所有包含的内容。
那么最后一个问题,Erlang中是不是可以实施Go的并发模型?在Go里面实施Erlang的并发模型是比较容易的,但是反过来想Erlang里面可不可以实现Go的并发模型呢?原则上是不能。因为在Erlang当中进程不能实现共享状态,这个是他反对锁的最重要的基点。进程不能共享状态,所以不用锁,但其实我认为这个是最大的问题,为什么呢?因为Erlang收到请求以后没有办法创建一个子的执行体,然后让它处理某一个具体的请求不用再管它。但是Erlang里面进程没有共享状态,你要改服务器状态必须用异步IO的方式,把事情做了再把消息扔给服务器对他说你自己改状态。通过消息改服务器状态,这个成本是比较大的,而且带来了很多问题。所以我认为Erlang的用消息改这个状态是不好的做法,绕了一大圈没有本质改变任何的东西。当然,如果我在Erlang里面非要做到Go的并发模型也可以,这需要对Erlang做一个阉割,如果我们让Erlang的服务器都无状态的话,是可以实施Go的并发模型。什么样的服务器是无状态的?大家可能很容易想到PHP服务器。它把状态交给所有的外部的存储服务,由存储服务来维持状态。如果说Erlang的服务器是无状态的是可以实施Go的并发模型,因为所有的状态都通过修改外部的存储。但是这样的话Erlang程序员肯定是很伤心,看起来Erlang语言并没有带来什么实质性的好处。所以我的结论是:是时候放弃Erlang了。
注:虽然该文章是推崇GO,劝离Erlang,但其中对于Erlang的剖析还是值得审视的,更何况对于程序员来说,语言只是实现需求的工具,博采众家之长,完善丰富知识体系才是根本