Erlang调度器的细节及其重要性

有一些基本的特性使得Erlang成为一个软实时平台。其中之一是它的垃圾回收机制,我已经在我上一篇文章《Erlang垃圾回收细节及其重要性》里谈到了这点。另一个特性就是它的调度机制,这个特性值得我们好好研究一下。本文我将解释它的历史、现状,以及控制和监督API。

什么是调度

通常来说,调度就是一种分派工作给工作者的机制。所谓的工作可能是一个算数运算、字符串处理或者数据抽取,而工作者是一些资源,比如像Green Thread这样虚拟的资源或者像Native Thread这样的物理资源。调度器以一种方式执行调度活动,最大限度地提高吞吐量和公平性,最大限度地降低响应时间和延时。调度是像操作系统和虚拟机这样的多任务系统的重要组成部分,它被分为两种类型:

  • 抢占式:一个抢占式调度器在执行的任务间进行上下文切换,它有权力抢占(中断)任务并且在不需要被抢占任务的配合下的稍后恢复执行它们。实现这样的功能是基于如下几个因素,比如:任务的优先级,时间切片或者规约数。
  • 协作式:一个协作式调度器需要任务协作来进行上下文切换。在这种方式下,调度器简单地让任务周期性地或者空闲地时候自愿地释放控制权,然后启动一个新的任务并且再一次等待它自愿地归还控制权。

现在的问题是,哪一种调度机制适合软实时系统,也就是这个系统必须在指定的时间内响应。协作式调度系统不能满足软实时系统的要求,因为其运行的任务可能永远也不会返还控制权或者在规定时限后返还控制权。所以软实时系统通常采用抢占式调度。

Erlang的调度

Erlang作为一个多任务软实时平台采用的就是抢占式调度。Erlang调度器的职责就是选择一个进程并执行它的代码。它也处理垃圾回收和内存管理。如何选择一个进程来执行是基于每个进程可配置的优先级,并且同一优先级的进程是轮询地被调度的。另外,执行中的进程被抢占的因素是基于自上次该进程被选中执行后一定数量的规约数而不管它的优先级如何。规约数是每个进程的一个计数器,一般每调用一次函数,它就加一。当一个进程的计数器达到最大规约数时,调度器就会抢占进程和进行上下文切换。例如,在Erlang/OTP R12B 计数器的最大值是2000规约数。

Erlang的任务调度有很长的发展历史。它随着时间而改变。这些改变受Erlang的SMP(对称多处理器)特性的改变而被影响。

R11B之前的调度

在R11B之前,Eralng还不支持SMP,因此它只有一个调度器运行在操作系统主进程的线程里,并且相应的只有一个运行队列。调度器从运行队列选择可运行的Erlang进程和IO任务来执行。

这种方式不需要锁数据结构,但是这么写的应用无法利用并行的好处。

R11B和R12B的调度

SMP支持被加入Erlang虚拟机里,所以它可以有1到1024个运行在操作系统进程的线程里的调度器。然而,这个版本的调度器只能从一个共用运行队列里选取可执行任务。

由于这种方式造成并行,使得所有共享数据结构都要用锁保护起来。例如运行队列本身就是一个必须被保护起来的共享数据结构。虽热锁会造成一些性能损失,但是新的调度器在多核处理器上带来的性能提升还是很可观的。

在这个版本里的一些瓶颈如下:

  • 当调度器增加后,共用运行队列成为瓶颈。
  • 增加ETS表相关的锁,同时也影响到Mnesia。
  • 当许多进程同时给一个进程发送消息的时候增加锁的冲突。
  • 一个进程等待获取一个锁的时候会阻塞它的调度器。

然而,每一个调度器分配一个运行队列的方案在下一个版本被选择来解决这些瓶颈。

R13B后的调度

在这个版本,每个调度器有它自己的运行队列。在多核多调度器的系统里,这将减少锁冲突数量并且提升系统整体性能。

这种方式在访问运行队列时锁冲突解决了,不过却引入了一些新问题:

  • 如何在运行队列中分配任务做到公平?
  • 如果一个调度器被分配了过多的任务而另外的调度器却很清闲,这个问题如何解决?
  • 基于什么样的命令一个调度器可以从一个过载的调度器偷任务?
  • 要是我们启动了很多调度器,但是却很少任务,如何处理?

这些问题使得Erlang开发团队引入一个概念使得调度公平和高效,这个概念就是迁移逻辑。它尝试在基于从系统收集来的统计数据上控制和平衡运行队列。

然而我们不应该让我们的调度一直维持现状,因为它很可能在将来的版本变得更好。

控制和监督API

有一些Erlang模拟器启动标志和一些内部控制和监督函数与调度器行为有关。

调度线程

当用erl启动脚本启动Erlang模拟器的时候,可以通过给+S标志传递两个用冒号分割的数字来指定最大可用调度线程数和在线调度线程数。

1
$ erl +S MaxAvailableSchedulers:OnlineSchedulers

最大可用调度线程数只能在启动的时候指定而且在运行时是固定不变的,但是在线调度线程数可以在启动和运行时被指定和修改。例如我们可以在启动一个模拟器的时候指定16个最大调度线程和8个在线调度线程。

1
$ erl +S 16:8

然后在shell里在线调度线程可以被修改,如下:

1
2
3
4
> erlang:system_info(schedulers). %% => returns 16
> erlang:system_info(schedulers_online). %% => returns 8
> erlang:system_flag(schedulers_online, 16). %% => returns 8
> erlang:system_info(schedulers_online). %% => returns 16

另外,使用+SP标志可以用百分比的方式设置这两个值。

进程优先级

如前所述调度器基于进程的优先级来选择它们来执行。优先级可以在进程内通过调用erlang:process_flag/2函数来设置。

1
2
3
PID = spawn(fun() ->
%% ...
end).

优先级可以是 low、normal、high、max 这些原子中的任何一个。默认优先级是normal,max优先级是保留给Erlang运行时内部使用不应被一般进程使用。

运行队列统计

如前所述运行队列持有准备好执行但未被调度器选中执行的进程。可以通过调用erlang:statistics(run_queue)获取在所有可用运行队列已经准备好可运行的进程数。作为一个真实例子,让我启动Erlang模拟器,给它4个在线调度器,并且给它们10个非常消耗CPU的并发进程。这些进程计算一个很大数字的素数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
%% 就绪
> erlang:statistics(online_schedulers). %% => 4 译者注:此处的函数有误,应该是 erlang:system_info(schedulers_online).
> erlang:statistics(run_queue). %% => 0
%% 并发创建10个重型进程
> [spawn(fun() -> calc:prime_numbers(10000000) end) || _ <- lists:seq(1, 10)].
%% 运行队列中还有任务要做
> erlang:statistics(run_queue). %% => 8
%% Erlang shell依然可以响应,非常棒!
> calc:prime_numbers(10). %% => [2, 3, 5, 7]
%% 等一会儿
> erlang:statistics(run_queue). %% => 4
%% 等一会儿
> erlang:statistics(run_queue). %% => 0

因为并发进程数大于在线调度器,这将花些时间让调度器执行运行队列里的进程并最终清空运行队列。有趣的是,创建了这些重型进程后,Erlang模拟器任然因为它的抢占式调度可以响应其他请求。Erlang的抢占式调度不会让这些重型进程消耗掉所有运行时,其他轻量并且重要的进程也可以被执行,这个特性在实现一个软实时系统的时候是非常棒的。

结论

虽然实现一个抢占式调度系统可能很复杂,但是在Erlang里这些不是开发者的责任,因为抢占式调度特性已经在Erlang虚拟机里。另一方面,当在一个软实时系统里系统以高水平的公平性和即时的响应需要扩展到所有处理资源的时候,跟踪、平衡、执行、迁移和抢占进程这些额外的处理成本是完全可负担的。顺便值得一提的是,完全抢占式调度是几乎所有操作系统都支持的特性,但在高层次的平台,语言或库里,Erlang虚拟机几乎是唯一完全抢占式调度的,因为JVM依赖于操作系统的调度器,CAF这个C++ actor库用协作式调度,Go也不是完全抢占式调度,还有诸如Python的Twisted,Ruby的Event Machine和Nodejs也不是完全抢占式调度的。这并不意味着对于所有的挑战这都是最好的选择,而是说我们如果要实现一个低延时的软实时系统,Erlang是一个好的选择。

资源

原文链接: https://hamidreza-s.github.io/erlang/scheduling/real-time/preemptive/migration/2016/02/09/erlang-scheduler-details.html