Erlang/OTP 17.0-rc1 新引入的"脏调度器"浅析

时间:2022-06-01 21:23:41

最近在做一些和 NIF 有关的事情,看到 OTP 团队发布的 17 rc1 引入了一个新的特性“脏调度器”,为的是解决 NIF 运行时间过长耗死调度器的问题。本文首先简单介绍脏调度器机制的用法,然后简要分析虚拟机中的实现原理,最后讨论了一下脏调度器的局限性。

脏调度器机制的用法

了解 NIF 的同学都知道,在 Erlang 虚拟机的层面,NIF 调用是不会被抢占的,在执行 NIF 的时候调度器线程的控制权完全被 NIF 调用接管,因此除非 NIF 调用的代码主动交出控制权,否则调度器线程会一直执行 NIF 调用的代码。这实际上变成了协程式的调度,因此运行时间过长的 NIF 会影响其所在的调度器上的所有其他进程的调度。

之前对于这种长时运行 NIF 的一种解决方法是可以使用官方提供的 enif_consume_timeslice 调用,这种方法还是要让 NIF 代码自己在恰当的地方调用这个 api,然后根据 enif_consume_timeslice 返回的结果判断是否需要放弃控制权,因此实际上还是协程的模式。协程式调度和抢占式调度混合在一起本来就是坏味道,如果通过判断发现已经用完时间片,程序员必须自己手工保存断点以及下一次恢复断点;而且这里还要自己估计时间片,把 timeslice 和虚拟机中本来就很模糊的规约(reduction)混在一起,味道也不好闻。

那么 R17 通过引入“脏调度器”从一定程度上解决了这个问题。脏调度器本质上和普通调度器是一样的,也是运行在虚拟机中的调度器线程,但是这种调度器专门运行长时运行的 NIF,R17 允许将长时运行的 NIF 直接丢到脏调度器上去跑。通过调用 enif_schedule_dirty_nif 将需要长时运行的 NIF 函数丢到脏调度器上。长时运行的函数返回的时候要调用 enif_schedule_dirty_nif_finalizer 函数,表示从脏调度器返回到了普通调度器。

下面看一个简单的例子,比如下面这个简单霸道的 NIF:

 static ERL_NIF_TERM
io_work(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[])
{
int i;
int Number;
enif_get_int(env, argv[], &Number);
for (i = ; i < ; ++i) {
sleep();
printf("nif process number %d\n", Number);
}
return enif_make_atom(env, "ok");
}

io_work 函数显然会运行很长时间(远长于官方文档建议的 1ms)。

利用 R17 新引入的脏调度器,这个 NIF 可以这么写:

 #include "erl_nif.h"
#include <unistd.h>
#include <stdio.h> static int
load(ErlNifEnv* env, void** priv, ERL_NIF_TERM load_info)
{
return ;
} static ERL_NIF_TERM
dirty_io_work(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[])
{
int i;
int Number;
enif_get_int(env, argv[], &Number);
for (i = ; i < ; ++i) {
sleep();
printf("nif process number %d\n", Number);
}
return enif_schedule_dirty_nif_finalizer(env,
enif_make_atom(env, "ok"),
enif_dirty_nif_finalizer);
} static ERL_NIF_TERM call_dirty_io_work(
ErlNifEnv* env,
int argc,
const ERL_NIF_TERM argv[])
{
return enif_schedule_dirty_nif(env,
ERL_NIF_DIRTY_JOB_IO_BOUND,
dirty_io_work, argc, argv);
} static ErlNifFunc io_nif_funcs[] =
{
{"call_dirty_io_work", , call_dirty_io_work}
}; ERL_NIF_INIT(io_nif, io_nif_funcs, load, NULL, NULL, NULL)

这段代码将长时运行的工作放在 dirty_io_work 函数中,Erlang 模块调用 call_dirty_io_work 函数,这个函数转而调用 enif_schedule_dirty_nif 函数,将 dirty_io_work 函数传入,call_dirty_io_work 立即返回,dirty_io_work 函数进入脏调度器等待调度执行。dirty_io_work 函数在返回的时候调用 enif_schedule_dirty_nif_finalizer 将实际的结果返回给原调用者。

enif_schedule_dirty_nif() 函数还接受一个参数 type,表示要调度的 NIF 的类型:CPU 密集型或 IO 密集型。后面可以看出,根据不同的类型,NIF 会被不同类型的脏调度器调用。

下面简单分析一下脏调度器机制的工作原理。

工作原理浅析

OTP 团队实现的脏调度器机制实际上很简单,脏调度器是普通调度器之外的调度器线程。从位于 erts/emulator/beam/erl_nif.c 的 enif_schedule_dirty_nif 函数开始:

Erlang/OTP 17.0-rc1 新引入的"脏调度器"浅析

这个函数设置当前进程的标志 ERTS_PSFLG_DIRTY_CPU_PROC 或 ERTS_PSFLG_DIRTY_IO_PROC,说明当前进程应该在某种脏调度器上执行。然后一次性将当前进程的规约配额全部清零,说明这个进程很快就要让出调度器了。最后就是设置了一些和虚拟机代码相关的状态,改变虚拟机的执行状态。注意最后将 proc->freason 设置为 TRAP,之后虚拟机会利用到这个标志,虽然最后 return 了一个 THE_NON_VALUE,但是放心,最后返回到原调用者的不会是这个值。

enif_schedule_dirty_nif 函数返回之后会返回上一级调用的函数,通常就是被调用的 NIF,例如上面例子中的 call_dirty_io_work,后者返回之后会将控制权返回给虚拟机,那么返回的位置必然是虚拟机(即一个调度器线程)中处理 call_nif 指令的位置:

Erlang/OTP 17.0-rc1 新引入的"脏调度器"浅析

NIF 返回到虚拟机中的时候返回到上图中 1 的位置,然后在使用脏调度器的时候,2 中的 if 条件会满足,因此这里设置了一些代码相关的东西,最后跳转到

Erlang/OTP 17.0-rc1 新引入的"脏调度器"浅析

正常 NIF 的情况下,会满足 if 的第一个条件:设置返回结果,设置下一条指令并跳转执行。但是对于使用脏调度器的情况,满足的是 else if 的条件,这里执行的就不是下一条指令了,而是当前进程中设置的 c_p->i,指令,这个指令是之前在 enif_schedule_dirty_nif 函数中设置的,实际上就是表示执行要被脏调度的那个 NIF 函数的指令。因此执行到上图中的 3472 行的 Dispatch() 之前的时候,当前进程的状态是:

  • 进程上下文已经准备好要执行 call_nif 调用另一个 NIF 了
  • 当前的规约配额已经归零
  • 设置了 ERTS_PSFLG_DIRTY_CPU_PROC 或 ERTS_PSFLG_DIRTY_IO_PROC 标志,表示进程需要在脏调度器上执行

好了,接下来就是 Dispatch() 了,分发下一条要执行的指令。在 Dispatch() 的时候,发现当前线程的规约配额用完,所以准备调度下一个进程。调度下一个进程的时候得把当前进程调度出吧,这时流程就进入了庞杂的调度函数:位于 erts/emulator/beam/erl_process.c 的 schedule() 函数。schedule() 函数调用 schedule_out_process() 函数处理的是调度出一个进程需要的操作。schedule_out_process() 函数会通过 check_enqueue_in_prio_queue() 函数判断是否需要转移进程所在的队列。check_enqueue_in_prio_queue() 函数中有一个判断:

Erlang/OTP 17.0-rc1 新引入的"脏调度器"浅析

根据进程的ERTS_PSFLG_DIRTY_CPU_PROC 或 ERTS_PSFLG_DIRTY_IO_PROC 状态打上新的标签:ERTS_PSFLG_DIRTY_CPU_PROC_IN_Q 或 ERTS_PSFLG_DIRTY_IO_PROC_IN_Q,表示进程应该进入 CPU 密集型的脏调度器运行队列或 IO 密集型的脏调度器运行队列。回到 schedule_out_process() 函数:

Erlang/OTP 17.0-rc1 新引入的"脏调度器"浅析

在 1 处,根据进程的标签判断要进入的队列,ERTS_DIRTY_CPU_RUNQ 和 ERTS_DIRTY_IO_RUNQ 宏分别表示 CPU 密集型任务的队列和 IO 密集型任务的队列。虽然虚拟机中可以有多个 CPU 密集型脏调度器和 IO 密集型脏调度器,但是两类队列分别只有一个,即一类脏调度器分享同一个队列。由于脏调度器通常运行长时间的任务,因此访问运行队列的频次要低得多,所以可以只共享一个队列。为此,ErtsRunQueue_ 数据结构中还增加了一个字段 sleepers 用于表示睡了这个队列的调度器列表。最后,在 2 处,当前进程被加入了相应的脏运行队列,如果脏调度器在睡觉的话,此时会被唤醒。

下面兵分两路,一路是“当前进程”原来所在的普通调度器,另一路是某一个被唤醒的脏调度器。

在普通调度器中,之前的“当前进程”被调度出了,那么这个调度器可以像以前一样,挑选下一个正常的进程继续执行。后面还有一个判断:

Erlang/OTP 17.0-rc1 新引入的"脏调度器"浅析

如果发现有需要脏调度器的进程遗留在普通调度器的队列中的时候,则忽略这个进程。以上两个方面保证了普通调度器能够正常运行,不会被长时运行的 NIF 耗死。

另一路就进入脏调度器了。脏调度器实际上复用的就是普通调度器的代码,即位于 erts/emulator/beam/beam_emu.c 中的 process_main() 函数。这一路的操作和正常调度器的操作一样,从运行队列中取出要执行的进程。当然就是之前 enqueue 进去的那个“当前进程”了。记得我们之前提到的这个进程的状态:要执行一条 call_nif 指令。那么脏调度器拿到这个进程之后,首先开始分发指令就是 call_nif 指令,之后执行这条指令,执行的时候正常调用之前指定的 NIF。不论这个 NIF 运行时间有多长有多复杂,都没关系,因为在独立的调度器,也就是操作系统进程中运行,操作系统可以保证其他普通调度器线程被调度执行,而其他普通调度器线程也能正常运行。

脏调度器运行完长 NIF 之后,通过 enif_schedule_dirty_nif_finalizer() 函数执行上述相反的过程,把“当前进程”变成普通进程,丢回普通调度器执行。

综上可以看出,脏调度器机制简单地说就是提供了一个场所让运行时间很长的进程运行,在这个环境中,进程可以*运行,不会被抢占。

根据 github 上的 commit 记录

Currently only NIFs are able to access dirty scheduler
functionality. Neither drivers nor BIFs currently support dirty
schedulers. This restriction will be addressed in the future.

目前只有 NIF 能访问脏调度器,未来会允许 BIF 和驱动也能访问脏调度器。因此可以想象未来也许会出现不会被抢占的普通 Erlang 进程。

局限性

这个简单的实现目前有一定的局限性。首先,目前虽然区分了 CPU 密集型计算和 I/O 密集型计算的脏调度器,但是这两个调度器运行的代码完全是一样的,只是用了不同的运行队列而已,调度策略都是一样的。但是这反映了未来的一个优化方向。

此外,从以上对脏调度器原理的浅析,我们可以发现脏调度器有一点过去 batch 操作系统的感觉,脏调度器必须完整处理完一个任务才会处理下一个任务。因此,如果当前所有脏调度器都被占用满了,那么新的脏任务就不能及时得到调度。下面用一个例子来演示。

这个例子的 NIF 部分就是本文最开始部分的那个 C 语言代码。略去 NIF 调用的桩,下面是主模块的代码:

 -module(io_nif_test).
-export([start/0, heart/0]). start() ->
io:format("Starting heartbeat.~n", []),
{ok, _} = timer:apply_interval(500, io_nif_test, heart, []),
timer:sleep(500),
io:format("Stress dirty io schedulers~n", []),
NormalCount = erlang:system_info(schedulers),
DirtyIOCount = erlang:system_info(dirty_io_schedulers),
io:format("There are ~w normal schedulers and ~w dirty io schedulers~n",
[NormalCount, DirtyIOCount]),
IOProcessCount = DirtyIOCount + 1,
io:format("Create ~w IO NIF processes~n", [IOProcessCount]),
lists:foreach(
fun(Number) -> spawn(fun() -> io_nif:call_dirty_io_work(Number) end) end,
lists:seq(1, IOProcessCount)
),
receive
stop_me -> ok
end,
timer:sleep(1000). heart() ->
io:format("Tick~n", []).

这段代码的主进程每半秒钟会心跳一下,我们可以通过心跳看出调度器是不是卡死了。然后根据 io 脏调度器的数目,创建多于一个这个数目的 NIF 进程,也就是说会有一个 NIF 进程得不到及时调度。下面将普通调度器和 io 脏调度器都设置为 1 的运行结果:

erl +S 1:1 +SDio 1 -noshell -s io_nif_test start -s init stop
Starting heartbeat.
Stress dirty io schedulers
There are 1 normal schedulers and 1 dirty io schedulers
Tick
Create 2 IO NIF processes
Tick
Tick
nif process number 2
Tick
Tick
nif process number 2
Tick
Tick
nif process number 2
Tick
Tick
nif process number 2
Tick
Tick
nif process number 2
Tick
Tick
nif process number 1
Tick
Tick
nif process number 1
Tick
Tick
nif process number 1
Tick
Tick
nif process number 1
Tick
Tick
nif process number 1
Tick
Tick
Tick
Tick
Tick
Tick
Tick
Tick

果然,进程 2 全部执行完之后才轮到进程 1 执行。将 io 脏调度器设置为 2:

/Users/zhengsyao/programs/ErlangInstall/otp_17rc1/bin/erl +S 1:1 +SDio 2 -noshell -s io_nif_test start -s init stop
Starting heartbeat.
Stress dirty io schedulers
There are 1 normal schedulers and 2 dirty io schedulers
Tick
Create 3 IO NIF processes
Tick
Tick
nif process number 3
nif process number 2
Tick
Tick
nif process number 3
nif process number 2
Tick
Tick
nif process number 3
nif process number 2
Tick
Tick
nif process number 3
nif process number 2
Tick
Tick
nif process number 3
nif process number 2
Tick
Tick
nif process number 1
Tick
Tick
nif process number 1
Tick
Tick
nif process number 1
Tick
Tick
nif process number 1
Tick
Tick
nif process number 1
Tick
Tick
Tick

2 个 io 脏调度器,创建了 3 个 nif 进程。进程 3 和 2 立即都得到了调度,而进程 1 则在其中一个进程运行完之后得到了调度。