Erlang调度器详解
Erlang之所以是软实时系统,是因为有一些重要的隐含特征。其中之一是我在我的上一篇文章,Erlang Garbage Collection Details and Why It Matters中提到的垃圾回收机制,而另一个值得一提的就是调度器机制。在本文中我将讲解它的历史、现状,以及用于控制与监测的API。
什么是调度
-
抢占式:抢占式调度器有权力中断任务并在稍后使它们继续进行,且无需被中断任务的协作。抢占式调度需要考虑考虑任务的优先级、时间片和归约数(reduction)。
- 协作式:协作式调度器在进行上下文切换时需要任务的协作。这种调度器只需要等待执行中的任务结束或自行释放控制权,然后再开始下一个任务。
作为一个实时多任务平台,Erlang采用抢占式调度。Erlang调度器的职责是选择一个Erlang进程并执行它。同时它也负责垃圾回收和内存管理。对进程的选择基于各个进程独立可调整的优先级,对同一优先级的进程使用轮询(round-robin fashion)调度策略。另一方面,调度器还需要根据归约数(reduction)来中断运行中的进程。归约数(reduction)是一个通常会随着每次函数调用增长的计数器,当它达到最大值时,调度器便会中断运行中的进程并进行上下文切换。例如,在Erlang/OTP R12B中,该最大值默认为2000。
Erlang的任务调度机制已经有久远的历史,它也在随着时间不断地改进。这些改进与Erlang针对SMP(对称多处理结构)的变化有关。
R11B以前的调度
在R11B之前,Erlang并不支持SMP,所以只存在一个运行在操作系统主进程上的调度器,相应的,也只有一个运行队列(Run Queue)。调度器从运行队列中选出Erlang进程或IO任务并进行执行。
Erlang VM
+--------------------------------------------------------+
| |
| +-----------------+ +-----------------+ |
| | | | | |
| | Scheduler +--------------> Task # 1 | |
| | | | | |
| +-----------------+ | Task # 2 | |
| | | |
| | Task # 3 | |
| | | |
| | Task # 4 | |
| | | |
| | Task # N | |
| | | |
| +-----------------+ |
| | | |
| | Run Queue | |
| | | |
| +-----------------+ |
| |
+--------------------------------------------------------+
这种方式无需对数据结构加锁,应用也无法享受并发带来的好处。
R11B及R12B的调度
由于Erlang虚拟机对SMP的支持,在每一个操作系统的线程中都可以运行一个调度器,调度器的总数为1到1024个。不过,所有的调度器都从同一个运行队列中获取任务。
Erlang VM
+--------------------------------------------------------+
| |
| +-----------------+ +-----------------+ |
| | | | | |
| | Scheduler # 1 +--------------> Task # 1 | |
| | | +---------> | |
| +-----------------+ | +----> Task # 2 | |
| | | | | |
| +-----------------+ | | | Task # 3 | |
| | | | | | | |
| | Scheduler # 2 +----+ | | Task # 4 | |
| | | | | | |
| +-----------------+ | | Task # N | |
| | | | |
| +-----------------+ | +-----------------+ |
| | | | | | |
| | Scheduler # N +---------+ | Run Queue | |
| | | | | |
| +-----------------+ +-----------------+ |
| |
+--------------------------------------------------------+
由于存在并行部分,所有共享的数据结构都需要加锁保护。例如,运行队列本身作为一个共享的数据结构,就必须受到保护。尽管锁会降低性能,但新调度器在多核系统上的性能提升还是非常让人感兴趣。
该调度器已知的性能瓶颈有以下几点:
- 随着调度器数量的增加,共享的运行队列会成为瓶颈。
-
增加了ETS和Mnesia中锁的复杂性。
- 当多个进程向一个进程发送消息时,增加了发生锁冲突的可能。
-
一个等待获取锁的进程将会阻塞它的调度器。
不过,在下个版本引入了调度器独立的运行队列后,这些瓶颈得到了解决。
R13B以后的调度
在该版本以后,每一个调度器都拥有自己的运行队列。这减少了在多核系统中运行大量调度器时造成的锁冲突,同时还提高了总体性能。
Erlang VM
+--------------------------------------------------------+
| |
| +-----------------+-----------------+ |
| | | | |
| | Scheduler # 1 | Run Queue # 1 <--+ |
| | | | | |
| +-----------------+-----------------+ | |
| | |
| +-----------------+-----------------+ | |
| | | | | |
| | Scheduler # 2 | Run Queue # 2 <----> Migration |
| | | | | Logic |
| +-----------------+-----------------+ | |
| | |
| +-----------------+-----------------+ | |
| | | | | |
| | Scheduler # N | Run Queue # N <--+ |
| | | | |
| +-----------------+-----------------+ |
| |
+--------------------------------------------------------+
虽然目前的方式解决了锁冲突的问题,但同时又带来了如下的担忧:
- 多个运行队列之间的任务分配是否足够公平?
- 若出现一个调度器超负荷运转而其他调度器相对空闲的情况,应如何解决?
- 空闲调度器应遵循什么规则,从忙的调度器处窃取任务?
- 若任务的数量相对于调度器的数量过少,应如何解决?
这些担忧促使Erlang开发团队引入了一个新的概念以使得调度更加的高效和公平,迁移逻辑(Migration Logic)。迁移逻辑利用从系统中收集的统计数据,控制和平衡了运行队列。
用于控制与监测的API
调度器线程
$ erl +S MaxAvailableSchedulers:OnlineSchedulers
最大调度器数只能在启动时设定,而可用调度器数还可以在运行时进行调整。例如,我们启动模拟器时令最大调度器数为16,可用调度器数为8。
$ erl +S 16:8
随后在终端中,我们可以以下面的方式改变可用调度器数。
> 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标志可以以百分比形式设定以上参数。
进程优先级
PID = spawn(fun() ->
process_flag(priority, high),
%% ...
end).
优先级可以是基元
low | normal | high | max 中的一个,优先级默认为
normal,而
max
是为Erlang运行时系统保留的,原则上不应在其他情况下被使用。
运行队列的统计数据
%% 一切就绪
> 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时间,要实现实时系统,这一点是非常必要的特性。