vlambda博客
学习文章列表

【译】Erlang调度器的一些细节

Erlang之所以是软实时系统,是因为有一些重要的隐含特征。其中之一是我在我的上一篇文章,Erlang Garbage Collection Details and Why It Matters中提到的垃圾回收机制,而另一个值得一提的就是调度器机制。在本文中我将讲解它的历史、现状,以及用于控制与监测的API。

什么是调度

一般来说,调度即是将工作分发给工作者的机制。所谓的工作,可以是数学运算、字符串操作或数据析取;而工作者可以是虚拟的绿色线程( Green Threads)或是物理的 操作系统线程( Native Threads)。调度器需要在完成调度工作时确保最大化吞吐量和公平性,同时最小化响应时间和延迟。调度器作为操作系统和虚拟机这样的多任务系统的重要组成部分,主要分为以下两种:

  • 抢占式:抢占式调度器有权力中断任务并在稍后使它们继续进行,且无需被中断任务的协作。抢占式调度需要考虑考虑任务的优先级、时间片和归约数(reduction)。

  • 协作式:协作式调度器在进行上下文切换时需要任务的协作。这种调度器只需要等待执行中的任务结束或自行释放控制权,然后再开始下一个任务。

那么对于必须对请求在特定时间内进行响应的实时系统,哪一种调度机制更适合呢?协作式调度不能满足实时系统的需求,因为在协作式调度机制下,任务有可能永远都不会返回。所以实时系统通常使用抢占式调度。

Erlang中的调度

作为一个实时多任务平台,Erlang采用抢占式调度。Erlang调度器的职责是选择一个Erlang进程并执行它。同时它也负责垃圾回收和内存管理。对进程的选择基于各个进程独立可调整的优先级,对同一优先级的进程使用轮询(round-robin fashion)调度策略。另一方面,调度器还需要根据归约数(reduction)来中断运行中的进程。归约数(reduction)是一个通常会随着每次函数调用增长的计数器,当它达到最大值时,调度器便会中断运行中的进程并进行上下文切换。例如,在Erlang/OTP R12B中,该最大值默认为2000。

Erlang的任务调度机制已经有久远的历史,它也在随着时间不断地改进。这些改进与Erlang针对SMP(对称多处理结构)的变化有关。

R11B以前的调度

在R11B之前,Erlang并不支持SMP,所以只存在一个运行在操作系统主进程上的调度器,相应的,也只有一个运行队列(Run Queue)。调度器从运行队列中选出Erlang进程或IO任务并进行执行。


这种方式无需对数据结构加锁,应用也无法享受并发带来的好处。

R11B及R12B的调度

由于Erlang虚拟机对SMP的支持,在每一个操作系统的线程中都可以运行一个调度器,调度器的总数为1到1024个。不过,所有的调度器都从同一个运行队列中获取任务。

                   

由于存在并行部分,所有共享的数据结构都需要加锁保护。例如,运行队列本身作为一个共享的数据结构,就必须受到保护。尽管锁会降低性能,但新调度器在多核系统上的性能提升还是非常让人感兴趣。

       该调度器已知的性能瓶颈有以下几点:

  • 随着调度器数量的增加,共享的运行队列会成为瓶颈。

  • 增加了ETS和Mnesia中锁的复杂性。

  • 当多个进程向一个进程发送消息时,增加了发生锁冲突的可能。

  • 一个等待获取锁的进程将会阻塞它的调度器。

不过,在下个版本引入了调度器独立的运行队列后,这些瓶颈得到了解决。

R13B以后的调度

在该版本以后,每一个调度器都拥有自己的运行队列。这减少了在多核系统中运行大量调度器时造成的锁冲突,同时还提高了总体性能。


虽然目前的方式解决了锁冲突的问题,但同时又带来了如下的担忧:

  • 多个运行队列之间的任务分配是否足够公平?

  • 若出现一个调度器超负荷运转而其他调度器相对空闲的情况,应如何解决?

  • 空闲调度器应遵循什么规则,从忙的调度器处窃取任务?

  • 若任务的数量相对于调度器的数量过少,应如何解决?

这些担忧促使Erlang开发团队引入了一个新的概念以使得调度更加的高效和公平,迁移逻辑(Migration Logic)。迁移逻辑利用从系统中收集的统计数据,控制和平衡了运行队列。

调度机制以后也不会一成不变,它将在今后的版本中不断改进。

用于控制与监测的API

可以使用虚拟机参数(emulator flags)和一些内置的方法控制和监测调度行为。

调度器线程

最大调度器数(MaxAvailableSchedulers)和可用调度器数(OnlineSchedulers)(译注:该数值即相当于运行的核心线程数),可以在用erl启动Erlang虚拟机时,在+S标志后传入用冒号分隔的两个参数进行调整。

$ erl +S MaxAvailableSchedulers:OnlineSchedulers

最大调度器数只能在启动时设定,而可用调度器数还可以在运行时进行调整。例如,我们启动虚拟机时令最大调度器数为16,可用调度器数为8。

$ erl +S 16:8

随后在终端中,我们可以以下面的方式改变可用调度器数。


  
    
    
  
  1. > erlang:system_info(schedulers). %% => returns 16

  2. > erlang:system_info(schedulers_online). %% => returns 8

  3. > erlang:system_flag(schedulers_online, 16). %% => returns 8

  4. > erlang:system_info(schedulers_online). %% => returns 16

另外,使用 +SP标志可以以百分比形式设定以上参数。

进程优先级

前文已经提到,调度器基于进程优先级来选择执行的进程。优先级可以通过调用erlang:process_flag/2来进行设定。


  
    
    
  
  1. PID = spawn(fun() ->

  2. process_flag(priority, high),

  3. %% ...

  4. end).

优先级可以是基元 low | normal | high | max 中的一个,优先级默认为 normal,而 max是为Erlang运行时系统保留的,原则上不应在其他情况下被使用。

运行队列的统计数据

前面已经提到过,运行队列中存放了已经就绪但尚未被调度器执行的进程。我们可以调用erlang:statistics(run_queue)来获取队列中就绪进程的总数。

作为示例,我们将启动一个可用调度器数为4的虚拟机,然后为他们同时分配10个会使得CPU使用率飙升的进程,它们将会计算大数10000000之前的素数。 

%% 一切就绪> erlang:statistics(online_schedulers). %% => 4> erlang:statistics(run_queue). %% => 0
%% 同时创建10个计算进程> [spawn(fun() -> calc:prime_numbers(10000000) end) || _ <- lists:seq(1, 10)].
%% 运行队列中尚有未完成的进程> erlang:statistics(run_queue). %% => 8
%% 终端没有被阻塞,太好了!> calc:prime_numbers(10). %% => [2, 3, 5, 7]
%% 稍等一会> erlang:statistics(run_queue). %% => 4
%% 稍等一会> erlang:statistics(run_queue). %% => 0


由于同时运行的进程数要多于当前可用调度器数,调度器要执行完所有进程并清空运行队列是需要一些时间的。有趣的是,正是由于抢占式的调度策略,在创建这些了这些任务繁重的进程之后,Erlang终端并没有被阻塞。调度器没有放任某些进程耗尽其他重要进程的CPU时间,要实现实时系统,这一点是非常必要的特性。


总结

尽管开发一个抢占式调度系统是非常复杂的,但是在Erlang中,开发者是无需关心的。另一方面,Erlang作为一个高度公平且响应及时的实时系统,它带来的额外的进程运行开销也是可以接受的。另外值得一提的是,完全抢占式调度是几乎所有操作系统都会支持的特性。但是可以断言的是,在更高级的平台、语言或库中,相比于依赖操作系统调度器实现的JVM,使用协作式调度的CAF,非完全抢占的Golang、Python Twisted、Ruby Event Machine和Node.JS说,Erlang几乎是独一无二的存在。虽然这并不意味着Erlang是所有情况下的最佳选择,但是在我们希望实现一个实时系统时,Erlang一定是一个值得考虑的选择。