Erlang调度器的一些细节以及它重要的原因(译文)

已获得原作者授权翻译,感谢原作者的总结和撰写。

原作者:Hamidreza Soleimani

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

渣翻:苦恼的山城号

如需转载请附上原文链接。水平有限,如有错漏,恳请斧正。

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任务并进行执行。

                         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

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

调度器线程

最大调度器数(MaxAvailableSchedulers)和可用调度器数(OnlineSchedulers)(译注:该数值即相当于运行的核心线程数),可以在用erl启动Erlang
模拟器时,在+S标志后传入用冒号分隔的两个参数进行调整。
$ 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标志可以以百分比形式设定以上参数。

进程优先级

前文已经提到,调度器基于进程优先级来选择执行的进程。优先级可以通过调用erlang:process_flag/2来进行设定。
PID = spawn(fun() ->
   process_flag(priority, high),
   %% ...
   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一定是一个值得考虑的选择。

资料来源


阅读更多

没有更多推荐了,返回首页