一、NIF的误用问题
使用NIF是很危险的,一不小心它就会搞垮你的erlang VM,还会堵塞erlang调度器使VM进入假死状态。
平均每20个使用NIF的项目,就有19个滥用了NIF。参考:
NIF Abuse
NIF官方手册其实有所提示:
引用
Avoid doing lengthy work in NIF calls as that may degrade the responsiveness of the VM. NIFs are called directly by the same scheduler thread that executed the calling Erlang code. The calling scheduler will thus be blocked from doing any other work until the NIF returns
在
例证NIF使用的误区一文中也有提醒使用NIF要小心。
官方手册建议我们得把每个NIF函数调用的时间控制在1ms以内。
参考
引用
It is hard to give an exact maximum amount of time that a native function is allowed to work, but as a rule of thumb a well behaving native function should return to its caller before a millisecond has passed.
二、一些基本原理和简单解释:调度器抢占与reductions计数
因为Erlang是
软实时系统,其调度器有抢占其它erlang进程的能力。erlang给每个进程分配reductions(默认值是2000),对应普通Erlang函数,每执行一次函数调用会记一次reduction,调度器由此估算进程的执行时间。
参考
how Erlang does scheduling
引用
Both processes and ports have a "reduction budget" of 2000 reductions. Any operation in the system costs reductions. This includes function calls in loops, calling built-in-functions (BIFs), garbage collecting heaps of that process[n1], storing/reading from ETS, sending messages (The size of the recipients mailbox counts, large mailboxes are more expensive to send to). This is quite pervasive, by the way. The Erlang regular expression library has been modified and instrumented even if it is written in C code. So when you have a long-running regular expression, you will be counted against it and preempted several times while it runs. Ports as well! Doing I/O on a port costs reductions, sending distributed messages has a cost, and so on. Much time has been spent to ensure that any kind of progress in the system has a reduction cost.
引用
This is also why one must beware of long-running NIFs. They do not per default preempt, nor do they bump the reduction counter. So they can introduce latency in your system.
2.1 实验:对erlang进程的reductions计数
通过实验可以验证基本上一个普通erlang函数的调用计为一次reduction。
测试代码如下:
-module(foo).
-compile(export_all).
sum(L) ->
sum(L, 0).
sum([], Acc) ->
Acc;
sum([H|Tail], Acc) ->
sum(Tail, H + Acc).
测试使用了bif函数process_info/2,它提供了查询erlang进程Pid当前reductions计数:
process_info(Pid, reductions).
我在一台2007年MacBook和一台台式机上测试,执行一次普通erlang函数调用的时间应该都小于1微秒(10^-6sec):
Erlang R16B01 (erts-5.10.2) [source] [64-bit] [smp:2:2] [async-threads:10] [kernel-poll:false] [systemtap]
Eshell V5.10.2 (abort with ^G)
1> timer:tc(foo, sum, [[]]).
{1,0}
2> timer:tc(foo, sum, [[1,2,3,4,5,6,7]]).
{1,28}
3> timer:tc(foo, sum, [[1,2,3,4,5,6,7]]).
{1,210}
4> timer:tc(foo, sum, [[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20]]).
{1,210}
5> L = lists:seq(1, 100).
[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,
23,24,25,26,27,28,29|...]
6> timer:tc(foo, sum, [L]).
{2,5050}
9> P = spawn(fun() -> receive Any -> go end, foo:sum(L), receive Any2 -> ok end end).
<0.47.0>
10> process_info(P, reductions).
{reductions,17}
12> P ! go.
go
13> process_info(P, reductions).
{reductions,225}
27> f().
28> L = lists:seq(1, 1000).
29> timer:tc(foo, sum, [L]).
{12,500500}
33> P = spawn(fun() -> receive Any -> go end, foo:sum(L), receive Any2 -> ok end end).
<0.76.0>
34> process_info(P, reductions).
{reductions,17}
35> P ! go.
go
36> process_info(P, reductions).
{reductions,1080}
顺便推算一下
foo:sum([1...1000])大概用掉了1000个reductions,耗时12微秒
估算100个reductions对应1微秒。(不同的处理器上结果差别可能很大,这个是很粗糙的推测,不适合做定量分析。)
一个reduction大致等于一次普通erlang函数调用。实际上由于不同函数执行时间不同,这种计算方法是很粗略的。
以上是对普通erlang函数(翻译成opcode由虚拟机执行的函数)的计算方法,对于IO操作和bif函数调用,reductions的计算又有不同。此外bif又有独特的trap机制保证其宿主进程(即调用进程)能随时被抢占。
2.2 erlang调度原理:通过reductions给进程分配执行时间片
当一个erlang进程被调度执行时会赋给固定数量的reductions,默认是2000,这个进程就一直执行,直到:
- 消耗(consume)掉所有reductions,
- 该进程要等待接受消息而暂停。
BTW:第二种情况下如果消息到达或超时的话该进程将重新进入调度,也就是排到运行队列等待被执行。这也是Actor模型的标准运行模式,
详见Beyond Threads And Callbacks - Application Architecture Pros And Cons 之 Actor Model (1 - 1)
在调度器的运行队列中等待的进程由round-robin算法决定执行,该算法给每个erlang进程分配一个固定大小的时间片(time slice),在erlang中就是一定数量的reductions,这些erlang进程都有相同的执行优先度。
参考:Characterizing the Scalability of Erlang VM on Many-core Processors
第3.3.1节
2.3 NIF函数调用与reductions计数
NIF函数调用与普通erlang函数调用有所不同,调用了NIF函数的erlang进程有可能会干扰其它erlang进程的公平调度,这是因为:
1.erlang进程调用NIF函数时要一口气执行完,期间是不会被打断的,也即正在执行NIF函数的erlang进程不能被抢占,不但不能被抢占,而且还堵塞了erlang的调度器进程;
2.一次NIF函数的调用如果只计一个reduction可能不公平,一个NIF函数调用可能要比一个普通的erlang函数调用耗时多了。
对第一点,目前版本的erlang没有什么好办法,我们只能修改程序逻辑以减少NIF函数的工作量,将一次大的计算分解成许多小任务,每个小任务由单独的NIF实现。但是这样做还要考虑第2点。因为即使是分解成小任务的NIF函数可能也是比较耗时的。
可以改写
sleepy这个例子来证明一下。首先把休眠10秒的nif拆成10个休眠1秒的nif调用。
sleep() ->
lists:foreach(fun(_) -> slumber() end,
lists:seq(1, 10)).
slumber() ->
nif_error(?LINE).
static ERL_NIF_TERM
nifslumber(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) {
usleep(1000000); // 1 second = 1,000,000 microsecond
return enif_make_atom(env, "ok");
}
任务分解后你可能以为不会再有严重堵塞的情况发生了,但是实际测一下就会发现没什么不同,辛辛苦苦分解了任务好像都做了无用功。
仔细想一下调度所采用的时间片(即2000个reductions)就理解了,对于这种分解,reductions其实只是增加到10倍而已,如果原来一次nif函数调用消耗100个reductions,分解后也只是1000个reductions,远达不到2000。因此调用nif的进程由于没达到2000个reductions而不会被抢占。
另一个角度,通过调整nif函数slumber的执行时间,从1ns到100000ns,测试这些不同耗时的nif函数消耗的reductions,可以发现,尽管nif执行的时间不同,但是其消耗的erlang进程reductions却总是一样。这显然是不太合理的。
2.4 补救办法:让NIF函数“正确”计量进程reductions
这时候,一个NIF API就有了用武之地,enif_consume_timeslice是R16B新增加,它用于帮助NIF函数重新估算reductions。
enif_consume_timeslice函数表示当前调用NIF函数的进程消耗的时间片比例,我理解完整的一个时间片对应着2000 reductions(或者是1ms?)。
该函数的第二个参数是个百分比整数,取值区间在[1,100],例如如果是10,表示消耗了2000*10%=200个reductions。
另外,enif_consume_timeslice重新计算的timeslice是积累的:它计算的是从上次调用到本次调用这段时间内所消耗的timeslice。
其返回值是0/1(布尔值?)。表示当前调用enif_consume_timeslice时,对应的erlang进程所分配的时间片是否已耗完。如果耗完则nif调用应该赶紧退出调用,好让调度器进行抢占并安排其它进程执行。
实验验证:
static ERL_NIF_TERM
nifslumber(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) {
usleep(1000000); // one second
int isExhausted = enif_consume_timeslice(env, 100);
return enif_make_atom(env, "ok");
}
休眠时间一秒,远超1ms,因此可看成100%
执行修改后的sleepy会发现情况有所改善。
这种在nif实现过程中重新估算reductions的思路,与IO的调度思路是类似的。
http://www.cnblogs.com/me-sa/archive/2013/01/08/2850910.html 写道
“IO也是公平调度的,把IO的处理量换算成reduction,算在宿主进程的时间片里面。”
三. 另一种解决办法:nif与OS线程
以上都是在NIF中执行耗时计算时,如何尽量避免对Elrang VM不良影响的思路。
另一种解决办法是干脆避免在Erlang进程中掉用NIF直接执行耗时的运算,改成通过OS线程执行这些计算,从而避免Erlang调度器堵塞。
在
NIF Abuse一文中作者给出了使用NIF的建议:
引用
As a NIF you have to either respond asynchronously through an internal thread, or you have to cooperate and be ready to yield.
大致思路就是将耗时的NIF计算放在一个单独的OS线程中执行,这个线程虽然不能接受Erlang时间发来的消息,但是可以发消息给Erlang进程。这样我们可以在Erlang进程中启动一个OS线程,并等待OS将计算结果以消息的方式发送过来。如前所述,等待消息的Erlang进程会被Erlang调度器抢占,也不会有堵塞调度器的问题。
3.1 例子
enif_thread_create(...
发送消息要注意的是新建一个env
ErlNifEnv *msgenv = enif_alloc_env();
enif_send(NULL, pid, msgenv, msg);
发送完要清除env
enif_clear_env(env);
另外,官方文档中强调,创建的OS线程要join,否则在NIF动态库unload的时候VM会崩溃。
相关
讨论.
引用
You do not have to join a created thread before the nif returns. If the
VM is crashing when a thread-creating nif returns then you are probably
doing something wrong.
"driver unloaded" correspond to "NIF library unloaded". That is quite
natural. Bad things will happen if a dynamic library is unloaded (driver
or nif) while existing threads execute code in that library. A NIF
library is only unloaded as the result of a module upgrade where the
old module gets purged OR if you replace a NIF library by making
repeated calls to erlang:load_nif from the same module.
A safe way to make sure that your thread is joined before the library is
unloaded is to create a resource object that acts as a handle to your
thread. The destructor of the resource can then do join.
Resource objects has a protection mechanism that postpone the unloading
of a nif library until the last resource object with a destructor in
that library is garbage collected. Maybe nif-created threads should have
a similar protection mechanism. Have to think about that...
/Sverker, Erlang/OTP
一个
例子
注意:名词erlang进程和OS线程的区别。
四、我的小结
感觉enif_consume_timeslice这个新引入的API还是没能彻底解决进程调度/抢占的问题,而且会带来新问题:
1. 重估NIF原生函数的reduction是个技术细活;
2. NIF原生函数实现中如果调用了第三方库的函数,这种情况就很难重估reductions;
3. 干扰了业务逻辑代码的正常实现逻辑,给开发增加复杂度。
启动OS线程异步计算的解决方案似乎不错,不过却失去了Erlang大并发进程的能力。
也许最终解决方案还是使用
Native Process,但是Native Process的支持被一再推迟了,原先(2011)以为R15(2012)就会支持,今年(2013)的最新消息是要到
R18才会实现,时间大概是后年(2015)。
参考资料
"Characterizing the Scalability of Erlang VM on Many-core Processors"
时间片(time slice)的名词解释
引用
The period of time for which a process is allowed to run uninterrupted in a pre-emptive multitasking operating system. Generally you want your program to use it's entire time slice and not do anything that gives up control of the CPU while you have it.
http://highscalability.com/blog/2013/3/18/beyond-threads-and-callbacks-application-architecture-pros-a.html