gitbook/编译原理实战课/docs/280663.md
2022-09-03 22:05:03 +08:00

19 KiB
Raw Blame History

35 | 并发中的编译技术Erlang语言厉害在哪里

你好,我是宫文学。

在前面两讲,我们讨论了各门语言支持的并发计算的模型。线程比进程更加轻量级,上下文切换成本更低;协程则比线程更加轻量级,在一台计算机中可以轻易启动几十万、上百万个并发任务。

但不论是线程模型、还是协程模型,当涉及到多个线程访问共享数据的时候,都会出现竞争问题,从而需要用到锁。锁会让其他需要访问该数据的线程等待,从而导致系统整体处理能力的降低。

并且编程人员还要特别注意避免出现死锁。比如线程A持有了锁x并且想要获得锁y而线程B持有了锁y想要获得锁x结果这两个线程就会互相等待谁也进行不下去。像数据库这样的系统检测和消除死锁是一项重要的功能以防止互相等待的线程越来越多对数据库操作不响应并最终崩溃掉。

既然使用锁这么麻烦那在并发计算中能否不使用锁呢这就出现了Actor模型。那么什么是Actor模型为什么它可以不用锁就实现并发这个并发模型有什么特点需要编译技术做什么配合

今天这一讲我们就从这几个问题出发一起学习并理解Actor模型。借此我们也可以把用编译技术支持不同的并发模型的机制理解得更深刻。

首先我们看一下什么是Actor模型。

什么是Actor模型

在线程和协程模型中,之所以用到锁,是因为两个线程共享了内存,而它们会去修改同一个变量的值。那,如果避免共享内存,是不是就可以消除这个问题了呢?

没错这就是Actor模型的特点。Actor模型是1973年由Carl Hewitt提出的。在Actor模型中并发的程序之间是不共享内存的。它们通过互相发消息来实现协作很多个一起协作的Actor就构成了一个支持并发计算的系统。

我们看一个有三个Actor的例子。

图1三个Actor的例子

你会注意到每个Actor都有一个邮箱用来接收其他Actor发来的消息每个Actor也都可以给其他Actor发送消息。这就是Actor之间交互的方式。Actor A给Actor B发完消息后就返回并不会等着Actor B处理完毕所以它们之间的交互是异步的。如果Actor B要把结果返回给A也是通过发送消息的方式。

这就是Actor大致的工作原理了。因为Actor之间只是互发消息没有共享的变量当然也就不需要用到锁了。

但是,你可能会问:如果不共享内存,能解决传统上需要对资源做竞争性访问的需求吗?比如,卖电影票、卖火车票、秒杀或者转账的场景。我们以卖电影票为例讲解一下。

在用传统的线程或者协程来实现卖电影票功能的时候,对票的状态进行修改,需要用锁的机制实现同步互斥,以保证同一个时间段只有一个线程可以去修改票的状态、把它分配给某个用户,从而避免多个线程同时访问而出现一张票卖给多个人的情况。这种情况下,多个程序是串行执行的,所以系统的性能就很差。

如果用Actor模式会怎样呢

你可以把电影院的前半个场地和后半个场地的票分别由Actor B和 C负责销售Actor A在接收到定前半场座位的请求的时候就发送给Actor B后半场的就发送给Actor CActor B和C依次处理这些请求如果Actor B或C接收到的两个信息都想要某个座位那么针对第二个请求会返回订票失败的消息。

图2Actor用于订票场景

你发现没有在这个场景中Actor B和C仍然是顺序处理各个请求。但因为是两个Actor并发地处理请求所以系统整体的性能会提升到原来的两倍。

甚至你可以让每排座位、每个座位都由一个Actor负责使得系统的性能更高。因为在系统中创建一个Actor的成本是很低的。Actor跟协程类似很轻量级一台服务器里创建几十万、上百万个Actor也没有问题。如果每个Actor负责一个座位那一台服务器也能负责几十万、上百万个座位的销售也是可以接受的。

当然,实际的场景要比这个复杂,比如一次购买多张相邻的票等,但原理是一样的。用这种架构,可以大大提高并发能力,处理海量订票、秒杀等场景不在话下。

其实我个人比较喜欢Actor这种模式因为它跟现实世界里的分工协作很相似。比如餐厅里不同岗位的员工他们通过互相发信息来实现协作从而并发地服务很多就餐的顾客。

分析到这里我再把Actor模式跟你非常熟悉的一个概念面向对象编程Object Oriented ProgrammingOOP关联起来。你可能会问Actor和面向对象怎么还有关联

是的。面向对象语言之父阿伦 · 凯伊Alan KaySmalltalk的发明人在谈到面向对象时是这样说的对象应该像生物的细胞或者是网络上的计算机它们只能通过消息互相通讯。对我来说OOP仅仅意味着消息传递、本地保留和保护以及隐藏状态过程并且尽量推迟万物之间的绑定关系。

I thought of objects being like biological cells and/or individual computers on a network, only able to communicate with messages (so messaging came at the very beginning it took a while to see how to do messaging in a programming language efficiently enough to be useful)

OOP to me means only messaging, local retention and protection and hiding of state-process, and extreme late-binding of all things. It can be done in Smalltalk and in LISP.

总结起来Alan对面向对象的理解强调消息传递、封装和动态绑定没有谈多态、继承等。对照这个理解你会发现Actor模式比现有的流行的面向对象编程语言更加接近面向对象的实现。

无论如何通过把Actor和你熟悉的面向对象做关联我相信能够拉近你跟Actor之间的距离甚至会引发你以新的视角来审视目前流行的面向对象范式。

好了到现在你可以说是对Actor模型比较熟悉了也可以这么理解Actor有点像面向对象程序里的对象里面可以封装一些数据和算法但你不能调用它的方法只能给它发消息它会异步地、并发地处理这些消息。

但是你可能会提出一个疑问Actor模式不用锁的机制就能实现并发程序之间的协作这一点很好那么它有没有什么缺点呢

我们知道,任何设计方案都是一种取舍。一个方案有某方面的优势,可能就会有其他方面的劣势。采用Actor模式会有两方面的问题

第一由于Actor之间不共享任何数据因此不仅增加了数据复制的时间还增加了内存占用量。但这也不完全是缺点一方面你可以通过在编写程序时尽量降低消息对象的大小从而减少数据复制导致的开销另一方面消息传递的方式对于本机的Actor和集群中的Actor是一样的这就使得编写分布式的云端应用更简单从而在云计算时代可以获得更好的应用。

第二基于消息的并发机制基本上是采用异步的编程模式这就和通常程序的编程风格有很大的不同。你发出一个消息并不会马上得到结果而要等待另一个Actor发送消息回来。这对于习惯于编写同步代码的同学可能是一个挑战。

好了我们已经讨论了Actor机制的特点。接下来我们再看看什么语言和框架实现了Actor模式。

支持Actor模型的语言和框架

支持Actor的最有名的语言是Erlang。Erlang是爱立信公司发明的它的正式版本是在1987年发布其核心设计者是乔 · 阿姆斯特朗Joe Armstrong最早是用于开发电信领域的软件系统。

在Erlang中每个Actor叫作一个进程Process。但这个“进程”其实不是操作系统意义上的进程而是Erlang运行时的并发调度单位。

Erlang有两个显著的优点首先对并发的支持非常好所以它也被叫做面向并发的编程语言COP。第二用Erlang可以编写高可靠性的软件可以达到9个9。这两个优点都与Actor模式有关

  • Erlang的软件由很多Actor构成
  • 这些Actor可以分布在多台机器上相互之间的通讯跟在同一台机器上没有区别
  • 某个Actor甚至机器出现故障都不影响整体系统可以在其他机器上重新启动该Actor
  • Actor的代码可以在运行时更新。

所以由Actor构成的系统真的像一个生命体每个Actor像一个细胞。细胞可以有新陈代谢而生命体却一直存在。可以说用Erlang编写的基于Actor模式的软件非常好地体现了复杂系统的精髓。到这里你是不是就能解释“Erlang语言厉害在哪里”这个问题了。

鉴于Actor为Erlang带来的并发能力和高可靠性有一些比较流行的开源系统就是用Erlang编写的。比如消息队列系统RabbitMQ、分布式的文档数据库系统CouchDB都很好地体现了Erlang的并发能力和健壮性。

除了Erlang以外Scala语言也提供了对Actor的支持它是通过Akka库实现的运行在JVM上。我还关注了微软的一个Orleans项目它在.NET平台上支持Actor模式并进一步做了一些有趣的创新。

那接下来我们继续探讨一下这些语言和框架是如何实现Actor机制的以及需要编译器做什么配合。

Actor模型的实现

在上一讲研究过协程的实现机制以后我们现在再分析Actor的实现机制时其实就应该会把握要点了。比如说我们会去看它的调度机制和内存管理机制等。鉴于Erlang算是支持Actor的最有名、使用最多的语言接下来我会以Erlang的实现机制带你学习Actor机制是如何实现的。

首先我们知道肯定要有个调度器把海量的Actor在多个线程上调度。

并发调度机制

那我们需要细究一下对于Actor该如何做调度呢什么时候把一个Actor停下让另一个Actor运行呢

协程也好Actor也好都是在应用级做调度而不是像线程那样在应用完全不知道的情况下就被操作系统调度了。对于协程我们是通过一些像yield这样的特殊语句触发调度机制。那Actor在什么时候调度比较好呢

前面我们也讲过了Actor的运行规律是每次从邮箱取一条消息并进行处理。那么我们自然会想到一个可选的调度时机就是让Actor每处理完一条消息就暂停一下让别的Actor有机会运行。当然如果处理一条消息所花费的时间太短比如有的消息是可以被忽略的那么处理多条消息累积到一定时间再去调度也行。

了解了调度时机,我们再挑战第二个比较难的话题:如果处理一条消息就要花费很长时间怎么办呢?能否实现抢占式的调度就像Goroutine那样

当然可以,但这个时候就肯定需要编译器和运行时的配合了。

Erlang的运行机制是基于一个寄存器机解释执行。这使得调度器可以在合适的时机去停下某个Actor的运行调度其他Actor过来运行。

Erlang做抢占式调度的机制是对Reduction做计数Reduction可以看作是占时不长的一小块工作量。如果某个Actor运行了比较多的Reduction那就可以对它做调度从而提供了软实时的能力具体可以参考这篇文章)。

在比较新的版本中Erlang也加入了编译成本地代码的特性那么在生成的本地代码中也需要编译器加入对Reduction计数的代码这就有点像Goroutine了。

这也是Erlang和Scala/Akka的区别。Akka没有得到编译器和JVM在底层的支持也就没办法实现抢占式的调度。这有可能让某些特别耗时的Actor影响了其他Actor使得系统的响应时间不稳定。

最后一个涉及调度的话题,是I/O与调度的关系。这个关系如果处理得不好,那么对系统整体的性能影响会很大。

通常我们编写I/O功能时会采用同步编程模式来获取数据。这个时候操作系统会阻塞当前的线程直到成功获取了数据以后才可以继续执行。

getSomeData();    //操作系统会阻塞住线程,直到获得了数据。
do something else //继续执行

采用这种模式开发一个服务端程序会导致大量线程被阻塞住等待I/O的结果。由于每个线程都需要不少的内存并且线程切换的成本也比较高因此就导致一台服务器能够服务的客户端数量大大降低。如果这时候你在运行时查看服务程序的状态就会发现大量线程在等待CPU利用率也不高而新的客户端又连接不上来造成服务器资源的浪费。

并且如果采用协程等应用级的并发机制一个线程被阻塞以后排在这个线程上的其他协程也只能等待从而导致服务响应时间变得不可靠有时快有时慢。我们在前一讲了解过Goroutine的调度器。它在遇到这种情况的时候就会把这条线程上的其他Goroutine挪到没被阻塞的线程上从而尽快得到运行机会。

由于阻塞式I/O的缺点现在很多语言也提供了非阻塞I/O的机制。在这种机制下程序在做I/O请求的时候并不能马上获得数据。当操作系统准备好数据以后应用程序可以通过轮询或被回调的方式获取数据。Node.js就是采用这种I/O模式的典型代表。

上一讲提到的C++协程库libco也把非阻塞的网络通讯机制和协程机制做了一个很好的整合大大增加了系统的整体性能。

而Erlang在很早以前就解决了这个问题。在Erlang的最底层所有的I/O都是用事件驱动的方式来实现的。系统收到了一块数据就调用应用来处理整个过程都是非阻塞的。

说完了并发调度机制,我们再来看看运行时的另一个重要特征,内存管理机制。

内存管理机制

内存管理机制要考虑栈、堆都怎么设计,以及垃圾收集机制等内容。

图3Erlang的内存模型

首先说栈。每个Actor也需要有自己的栈空间在执行Actor里面的逻辑的时候用于保存本地变量。这跟上一节讲过的Stateful的协程很像。

再来看看堆。Erlang的堆与其他语言有很大的区别它的每个Actor都有自己的堆空间而不是像其他编程模型那样不同的线程共享堆空间。这也很容易理解因为Actor模型的特点就是并发的程序之间没有共享的内存所以当然也就不需要共享的堆了。

再进一步由于每个Actor都有自己的堆因此会给垃圾收集带来很大的便利

  • 因为整个程序划分成了很多个Actor每个Actor都有自己的堆所以每个Actor的垃圾都比较少不用一次回收整个应用的垃圾所以回收速度会很快。
  • 由于没有共享内存所以垃圾收集器不需要停下整个应用而只需要停下被收集的Actor。这就避免了“停下整个世界STW”问题而这个问题是Java、Go等语言面临的重大技术挑战。
  • 如果一个Actor的生命周期结束那么它占用的内存会被马上释放掉。这意味着对于有些生命周期比较短的Actor来说可能压根儿都不需要做垃圾收集。

好了基于Erlang我们学习了Actor的运行时机制的两个重要特征一是并发调度机制二是内存管理机制。那么与此相配合需要编译器做什么工作呢

编译器的配合工作

我们说过Erlang首先是解释执行的是用一个寄存器机来运行字节码。那么编译器的任务,就是生成正确的字节码。

之前我们已经分别研究过Graal、Python和V8 Ignition的字节码了。我们知道字节码的设计很大程度上体现了语言的设计特点体现了与运行时的交互过程。Erlang的字节码设计当然也是如此。

比如针对消息的发送和接收它专门提供了send指令和receive指令这体现了Erlang的并发特征。再比如Erlang还提供了与内存管理有关的指令比如分配一个新的栈桢等体现了Erlang在内存管理上的特点。

不过我们知道仅仅以字节码的方式解释执行不能满足计算密集型的需求。所以Erlang也正在努力提供编译成机器码运行的特性这也需要编译器的支持。那你可以想象出生成的机器码一定也会跟运行时配合来实现Erlang特有的并发机制和内存管理机制。

课程小结

今天这一讲我们介绍了另一种并发模型Actor模型。Actor模型的特点是避免在并发的程序之间共享任何信息从而程序就不需要使用锁机制来保证数据的一致性。但是采用Actor机制也会因为数据拷贝导致更大的开销并且你需要习惯异步的编程风格。

Erlang是实现Actor机制的典型代表。它被称为面向并发的编程语言并且能够提供很高的可靠性。这都源于它善用了Actor的特点由Actor构成的系统更像一个生命体一般的复杂系统

在实现Actor模型的时候你要在运行时里实现独特的调度机制和内存管理机制这些也需要编译器的支持。

本讲的思维导图我也放在了下面,供你参考:

好了,今天这一讲加上第3334讲,我们用了三讲,介绍了不同计算机语言是如何实现并发机制的。不难看出,并发机制确实是计算机语言设计中的一个重点。不同的并发机制,会非常深刻地影响计算机语言的运行时的实现,以及所采用的编译技术。

一课一思

你是否也曾经采用过消息传递的机制,来实现多个系统或者模块之间的调度?你从中获得了什么经验呢?欢迎你和我分享。

参考资料

  1. Carl Hewitt关于Actor的论文
  2. 微软Orleans项目介绍
  3. 介绍Erlang虚拟机原理的在线电子书
  4. 介绍Erlang字节码的文章