原文 http://jlouisramblings.blogspot.com/2013/01/how-erlang-does-scheduling.html
免爬墙链接 http://www.dikutal.dk/blog/jlouis/how-erlang-does-scheduling
Jesper Louis Andersen,2013年1月12日
我用这篇文章解释一下Erlang和其他语言运行时相比不同之处。我还要解释为什么Erlang往往会牺牲吞吐换取更低的延迟。
太长了,我懒得读(译者注:原文TL;DR表示too long, didn’t read。原作者可能是在参与和别人的讨论中写的这篇文章,“懒得读”表现出一种“懒得跟你们争了”的有趣心态。校园网爬墙速度太慢,所以没有验证。)——Erlang和其他语言运行时不同之处在于关注的重点不同。本文描述了为什么进程很少的时候往往看上去似乎性能很差,但是进程很多的时候却表现得很好。
老是有人会问Erlang调度的原理。本文只是对Erlang真正调度原理的简单概述,但是描述了Erlang对其进程的操作方式。注意我这里讨论的是Erlang R15。未来Erlang可能会发生很大的变化,但是不论是Erlang还是其他系统,事物总是会朝着更好的方向发展。
从操作系统的角度来说,Erlang通常在机器上的每一个处理器核心上跑一个线程。每一个线程运行一个调度器。这种设定是为了确保机器上所有的核心都可以为Erlang系统卖力。通过+sbt参数可以将处理器核心和调度器绑定,也就是说调度器不会在众多核心之间跳来跳去。调度器绑定仅限于现代操作系统,因此OS X自然做不到。要实现调度器绑定,说明Erlang系统了解处理器的拓扑结构以及处理器相关的亲缘性,由于高速缓存以及迁移时间等原因,这些信息非常重要。设置+sbt参数通常都可以提升系统的速度。而且有的时候还能提升不少。
+A参数定义了异步线程池中异步线程的数量。驱动程序可以通过异步线程池中的线程执行阻塞的操作,这样调度器可以在线程池中有些线程阻塞的情况下依然执行其他有用的工作。最值得注意的是,文件驱动程序通过线程池加速文件I/O,而网络I/O则没有使用线程池。
以上内容是从操作系统内核的角度描述的,下面我们来理清Erlang进程(隶属于用户空间)的概念。通过调用spawn(fun worker/0)可以构建一个新的进程,Erlang系统会在用户空间分配进程控制块。一个进程通常需要大约600多个字节,而且32位系统和64位系统会有不同。可运行的进程放在调度器的运行队列中,之后获得时间片的时候就可以运行。
在深入描述单个调度器之前,我先简要地描述一下迁移(migration)的工作原理。每过一段时间,调度器就会通过一个非常复杂的过程在调度器之间迁移一些进程。这种启发式迁移的目标就是为了平衡多个调度器的负载,使得所有的核心都能得到充分的利用。这个平衡算法还要考虑工作量是否足够大,是否大到需要启动一些新的调度器。如果没那么大的话,那么最好让那些调度器保持关闭状态,因为反正那些线程也没有工作可做。关闭调度器意味着处理器核心可以进入节能状态,甚至关闭核心。没错,Erlang会尽可能地节省电源消耗。如果调度器做完了工作,还会从其他调度器“窃取”工作(work-steal)。细节请参见[1]。
重要:在R15中,调度器的启动和停止是有延迟(lagged)的。因为Erlang/OTP深知启动或停止一个调度器的开销非常大,所以不是真正需要的话是不会采取行动的。假设某一个调度器现在无事可做,那么系统不会立即将这个调度器设置为睡眠状态,而是会自旋等待一阵子,期待有任务会立即到达。如果有任务到达,那么调度器可以以低延迟立即开始处理任务。换句话说,不能使用top(1)这类工具或通过操作系统内核来测量系统执行的效率,而是必须使用Erlang系统的内部调用来测量。正因为这个原因,有不少人错误地认为R15不如R14高效。
每一个调度器都运行两类作业:进程作业和port作业。这些作业运行的时候是带有优先级的,就像操作系统内核一样,因此也会面对和操作系统内核一样的担忧和启发式调度。进程可以标记高优先级和低优先级等优先级。进程作业执行一个进程一小段时间。port作业考虑的是port。如果你不知道port是什么,我简单解释一下port:port就是Erlang中一种和外部世界通信的机制。文件、网络套接字、和其他程序之间建立的管道,在Erlang中都是通过port实现的。开发者可以在Erlang系统中添加“port驱动程序”来支持新的port类型,不过这就要求编写C语言代码了。调度器还要对网络套接字进行轮询(polling),这样才能从网络中读取数据。
进程和端口都有一个“reduction预算”,即2000个reduction。系统中的任何操作都要消耗reduction。这些操作包括循环中的函数调用、BIF(内建函数)的调用、进程中堆的垃圾回收[注1]、存取ETS和发送消息等(发送消息要考虑接收者的邮箱大小,邮箱越大发送的代价越高)。顺便提一下,会扣除reduction的地方遍及整个Erlang系统。比如Erlang的正则表达式库就做了修改,即使这个库是用C语言编写的,其中也添加了扣除reduction的相关代码。因此如果你有一个非常耗时的正则表达式,那么这个正则表达式的操作也会扣除reduction,所以在运行的过程中有可能会被抢占好几次。port也不例外!在一个port上执行I/O操作会消耗reduction,发送分布式消息也会消耗reduction,不胜枚举。开发者花了大量时间确保系统中产生的每一步进度都会消耗reduction[注2]。
实际上,这就是为什么我说Erlang是真正能够实现抢占式多任务并且能真正做好软实时的少数语言之一的原因。Erlang更看重的是低延迟而不是单纯的吞吐量,这在程序设计语言运行时中是不多见的。
准确地说,抢占(preemption)[2]指的是调度器能够强制剥夺任务的执行。所有基于协作(cooperation)的多任务都是做不到抢占的,例如Python的twisted库、Node.js和LWT(Ocaml)等。但是更有意思的是,Go(golang.org)和Haskell(GHC)也都不是完全抢占式的。Go只有在通信的时候会发生上下文切换,因此一个密集的循环就会霸占整个处理器核心。GHC会在内存分配的时候发生切换(不得不承认内存分配是Haskell程序中一个非常频繁的操作)。这些系统的问题在于,将处理器核心霸占一段时间的后果就是影响系统的响应延迟——想象一下这两种语言执行数组操作的时候的情景。
这就引出了软实时(soft-realtime)[3]的概念,软实时指的是如果无法满足时间截止线需求的时候会导致系统服务水准降级(而不是整个失败)。假设在运行队列中有500有100个进程。第一个进程正在做一个耗时50毫秒的数组操作。在Go或Haskell/GHC[注3]中,这意味着任务2-100都需要至少50ms。而在Erlang中则不同,任务1有2000个reduction的预算,相当于大约1ms的时间。然后用完reduction预算后,任务1会被放回运行队列,这样任务2-任务100就有机会运行。这自然意味着所有的任务都有公平的时间份额。
Erlang是为保证低延迟软实时的特性而精心打造的。2000的reduction预算很低,会导致很多小的上下文切换。耗时长的BIF在计算过程中被抢占的代价非常高昂。但是这样可以保证Erlang在系统负载更高的情况下能够优雅地降级。对于像Ericsson这样在乎低延迟的公司来说,这也意味着别无选择了。你不可能神奇地找到另外一种为吞吐量打造的语言同时也获得低延迟的好处,你必须为之付出努力。如果低延迟对你来说很重要,那么平心而论,不选Erlang反而显得很奇怪了。
[1] "Characterizing the Scalability of Erlang VM on Many-core Processors"http://kth.diva-portal.org/smash/record.jsf?searchId=2&pid=diva2:392243
[2] http://en.wikipedia.org/wiki/Preemption_(computing)
[3] http://en.wikipedia.org/wiki/Real-time_computing
[注1] 进程堆是每个进程私有的,因此一个进程不会对其他进程的GC时间造成太大影响。
[注2] 这段话也点明了为什么要小心耗时长的NIF的原因。NIF默认不会被抢占,而且也不会贡献reduction计数器。因此耗时长的NIF会引入系统延迟。
[注3] 这里考虑单核心的情况,多核心能在一定程度上“掩盖”单核心的这个问题,但是问题依然存在。
(2013年1月14日对本文稍有更新)