阅读 498

nodejs event loop

此处如无特殊指出的话,event loop的语境都是指nodejs

本文研究所用的nodejs环境是:操作系统window10 + nodejs版本号为v12.16.2

什么是event loop?

event loop是指由libuv提供的,一种实现非阻塞I/O的机制。具体来讲,因为javascript一门single-threaded编程语言,所以nodejs只能把异步I/O操作的实现(非阻塞I/O的实现结果的就是异步I/O)转交给libuv来做。因为I/O既可能发生在很多不同操作系统上(Unix,Linux,Mac OX,Window),又可以分为很多不同类型的I/O(file I/O, Network I/O, DNS I/O,database I/O等)。所以,对于libuv而言,如果当前系统对某种类型的I/O操作提供相应的异步接口的话,那么libuv就使用这些现成的接口,否则的话就启动一个线程池来自己实现。这就是官方文档所说的:“事件循环使Node.js可以通过将操作转移到系统内核中来执行非阻塞I / O操作(尽管JavaScript是单线程的)”的意思。

The event loop is what allows Node.js to perform non-blocking I/O operations — despite the fact that JavaScript is single-threaded — by offloading operations to the system kernel whenever possible.

nodejs的架构

在继续讨论nodejs event loop之前,我们不妨来看看nodejs的架构图:

从上面的架构图,你可以看出,libuv是位于架构的最底层的。而我们所要讲得event loop的实现是由libuv来提供的。现在,你的脑海里面应该有一幅完整的画面,并清楚地知道event loop到底处在哪个位置了。

这里值得强调的一点是,无论是chrome浏览器中的还是nodejs中的event loop,其实都不是由v8引擎来实现的。

关于event loop几个误解

误解1:event loop和用户代码分别跑在不同的线程上

经常听到这样的说法,用户的javascript代码跑在主线程上,nodejs其余的javascript代码(不是用户写的)跑在event loop的这个线程上。每一次当有异步操作发生的时候,主线程会把I/O操作的实现交给event loop线程。当异步I/O有了结果之后,event loop线程就会把结果通知主线程,主线程就会去执行用户注册的callback函数。

真相

不管是用户写的还是nodejs本身内置的javascript代码(nodejs API),所有的javascript代码都运行在同一个线程里面。在nodejs的角度看来,所有的javascript代码要么是同步代码,要么就是异步代码。或许我们可以这样说,所有的同步代码的执行都是由v8来完成的,所有异步代码的执行都是由libuv提供的event loop功能模块来完成的。那event loop与v8是什么关系呢?我们可以看看下面的源代码:

Environment* CreateEnvironment(Isolate* isolate, uv_loop_t* loop, Handle<Context> context, int argc, const char* const* argv, int exec_argc, const char* const* exec_argv) {
  HandleScope handle_scope(isolate);

  Context::Scope context_scope(context);
  Environment* env = Environment::New(context, loop);

  isolate->SetAutorunMicrotasks(false);

  uv_check_init(env->event_loop(), env->immediate_check_handle());
  uv_unref(reinterpret_cast<uv_handle_t*>(env->immediate_check_handle()));
  uv_idle_init(env->event_loop(), env->immediate_idle_handle());
  uv_prepare_init(env->event_loop(), env->idle_prepare_handle());
  uv_check_init(env->event_loop(), env->idle_check_handle());
  uv_unref(reinterpret_cast<uv_handle_t*>(env->idle_prepare_handle()));
  uv_unref(reinterpret_cast<uv_handle_t*>(env->idle_check_handle()));

  // Register handle cleanups
  env->RegisterHandleCleanup(reinterpret_cast<uv_handle_t*>(env->immediate_check_handle()), HandleCleanup, nullptr);
  env->RegisterHandleCleanup(reinterpret_cast<uv_handle_t*>(env->immediate_idle_handle()), HandleCleanup, nullptr);
  env->RegisterHandleCleanup(reinterpret_cast<uv_handle_t*>(env->idle_prepare_handle()), HandleCleanup, nullptr);
  env->RegisterHandleCleanup(reinterpret_cast<uv_handle_t*>(env->idle_check_handle()), HandleCleanup, nullptr);

  if (v8_is_profiling) {
    StartProfilerIdleNotifier(env);
  }

  Local<FunctionTemplate> process_template = FunctionTemplate::New(isolate);
  process_template->SetClassName(FIXED_ONE_BYTE_STRING(isolate, "process"));

  Local<Object> process_object = process_template->GetFunction()->NewInstance();
  env->set_process_object(process_object);

  SetupProcessObject(env, argc, argv, exec_argc, exec_argv);
  LoadAsyncWrapperInfo(env);

  return env;
}
复制代码

可以看到,nodejs在创建v8环境的时候,会把libuv默认的event loop作为参数传递进去的。event loop是被v8所使用一个功能模块。因此,我们可以说,v8包含了event loop

对于这个单一的线程,有些人称之为v8线程,有些人称之为event loop线程,还有些人称之为node线程。鉴于nodejs大多时候都被称为javascript的运行时,所以,我更倾向于称之为“node线程”。不过,需要重申一次的是:“无论它叫什么,本质都是一样的。那就是它们都是指所有javascript运行所在的那一个线程。”

误解2:所有的异步操作都是交给libuv的线程池(thread pool)来实现的

异步操作,比如像文件系统的读写,发出HTTP请求或者对数据库进行读写等等都是load off给libuv的线程池来完成的。

真相

libuv确实会创建一个具有四个线程的线程池。但是,时至今日,许多操作系统已经向外提供一些实现异步I/O的接口了(例如:Linux上面的AIO),libuv内部会优先考虑使用这些现成的API接口来完成异步I/O。只有在特定情况下(某个操作系统对某种类型I/O没有提供相应的异步接口的时候),libuv才会使用线程池中的线程+轮询来实现异步I/O。

误解3: event loop就是一个stack或者queue

event loop会持续地以一种FIFO的方式遍历一个装满着异步task callback的队列,当这个task完成之后,event loop就会执行它相应的callback。

真相

event loop机制中确实是涉及到类似于队列的数据结构,但是并不是只有一个这种“队列”。实际上,event loop主要遍历的是不同的阶段(phase),每个阶段会有一个装着callback函数的队列与之相对应(称之为callback queue)。当执行到某个阶段的时候,event loop才会去遍历这个阶段所对应的callback queue。

event loop六个阶段

首先,我们从nodejs程序生命周期的角度来看看,event loop所处的位置:

上面的图中,mainline code指的就是我们nodejs的入口文件。入口文件被看作是同步代码,由v8来执行。在从上到下的解释/编译的过程中,如果遇到执行异步代码的请求的时候,nodejs就会把它交给event loop来执行。

在nodejs中,异步代码有很多类型,比如定时器,process.nextTick()和各种的I/O操作。上面的这张图把异步I/O单独拎出来,主要是因为在nodejs中,它占据异步代码的大半壁江山,处于十分重要的地位。这里的“Event Demultiplexer”其实指的就是由libuv中帮我们封装好的各个I/O功能模块的集合(可以查看上面的libuv架构图)。当Event Demultiplexer从操作系统中拿到I/O处理结果后,它就会通知event loop将相应的callback/handler入队到相应的队列中。

event loop是一个单线程,半无限的循环。之所以说它是“半无限”,是因为当没有任何任务(更多的异步I/O请求或者timer)要做的的时候,event loop会退出这个循环,整个nodejs程序也就执行完成了。

以上是event loop在整个nodejs程序生命周期里面的位置。当我们单独对event loop展开来看的的时候,实际上它主要是包括六个阶段:

  1. timers
  2. pending callbacks
  3. idle/prepare
  4. poll
  5. check。
  6. close callbacks

event loop会依次进入上述的每个阶段。每个阶段都会有一callback queue与之相对应。event loop会遍历这个callback queue,执行里面的每一个callback。直到callback queue为空或者当前callback的执行数量超过了某个阈值为止,event loop才会移步到下一个阶段。

1. timers

在这个阶段,event loop会检查是否有到期的定时器可以执行。如果有,则执行。调用setTimeout或者setInterval方法时传入的callback会在指定的延迟时间后入队到timers callback queue 。跟浏览器环境中的setTimeout和setInterval方法一样,调用时候传入的延迟时间并不是回调确切执行的时间。timer callback的执行时间点无法得到稳定的,一致的保证,因为它们的执行会受到操作系统调度层面和其他callback函数调用耗时的影响。所以,对传入setTimeout或者setInterval方法的延迟时间参数正确的期望是:在我指定的延迟时间后,nodejs啊,我希望你尽快地帮我执行我的callback。也就是说timer callback函数的执行只会比我们预定的时间的要晚,不会比我们预定的时间要早。

从技术上来说,poll阶段实际控制了timer callback执行的时间点。

2. pending callbacks

这个阶段主要是执行某些系统层级操作的回调函数。比如说,TCP发生错误时候的错误回调。假如一个TCP socket在尝试建立连接的时候发生了“ECONNREFUSED”错误,则nodejs需要将对应的错误回调入队到pending callback queue中,并马上执行,以此来通知操作系统。

3. idle/prepare

只供nodejs内部来用的阶段。对于开发者而言,几乎可以忽略。

poll

在进入轮询阶段之前,event loop会检查timer callback queue是否为空,如果不为空的话,那么event loop就会回退到timer阶段,依次执行所有的timer callback才回到轮询阶段。

进入轮询阶段后,event loop会做两件事:

  1. 根据不同的操作系统的实际情况来计算轮询阶段所应该占用event loop的时间长度。
  2. 对Event Demultiplexer进行轮询,并执行I/O callback queue里面的callback。

因为nodejs是志在应用于I/O密集型软件,所以,在一个event loop循环中,它会花费很大比例的时间在轮询阶段。在这个阶段,event loop要么处于执行I/O callback状态,要么处于轮询等待的状态。当然,轮询阶段占用event loop的时间也会是有个限度的。这就是第一件事情要完成的事-计算出有一个切合当前操作系统环境的适合的最大时间值。event loop退出当前轮询阶段有两个条件:

  1. 条件一:当前轮询阶段所占用的时间长度已经超过了nodejs计算出来的阈值。
  2. 条件二:当天I/O callback queue已经为空,并且immediate callback不为空。

一旦符合以上两个条件之中的一个,event loop就会退出轮询阶段,进入check阶段。

从上面的描述,我们可以看出,轮询阶段跟timer阶段和immediate阶段是有某种关系的。它们之间的关系可以用下面的流程图来体现:

check

正如上面给出的流程图所描述的那样,当poll处于空闲状态的时候(也就是I/Ocallback queue为空的时候),一旦event loop发现immediate callback queue有callback入队了,event loop就会退出轮询阶段,马上进入check阶段。

调用setImmediate()时传入的callback会被传入到immediate callback queue中。event loop会依次执行队列中的callback,直到队列为空,才会移步到下一个阶段。

setImmediate()实际上是执行在另一个阶段的timer。在内部实现里面,它是利用libuv的一个负责调度代码的接口来实现在poll阶段之后执行相应的代码。

close callback

执行那些注册在关闭事件上callback的阶段。比如说:socket.on('close',callback)。这种类型的异步代码比较少,就不展开阐述了。

更多细节

正如上面小节所解释的,这六个阶段里面,pending callbacks和idle/prepare这两个阶段是nodejs内部在使用的,只有四个阶段跟用户代码是相关的。我们的异步代码最终是被推入到这四个阶段所对应的callback queue里面的。所以event loop本身有着以下的几个队列:

  • timer callback queue
  • I/O callback queue
  • immediate callback queue
  • close callback queue

除了event loop的四个队列之外,还有两个队列值得我们注意:

  • nextTick callback queue。调用process.nextTick()时传入的callback会被入队到这里。
  • microtask callback queue。一个promise对象reslove或者reject时传入的callback会被入队到这里。

这两个队列虽然不属于event loop里面的,但是它们一样属于nodejs异步机制的一部分。如果以event loop机制所涉及的这六个队列为视角的话,event loop运行机制可以用下面的示意图来描述:

event loop示意图

process.nextTick()和Promise/then()

当nodejs程序的入口文件,也就是上图中的mainline code执行完毕后,在进入event loop之前是先后执行next tick callback和micortask callback的。有的技术文章将next tick callback归为microtask callback,两者是共存在一个队列里面,并强调它的优先级比诸如promise之类的其他microtask的优先级高。也有的技术文章强调两者是分别归属为不同的队列,nodejs先执行next tick queue,再执行microtask callback queue。无论是哪一种,所描述的运行结果都是一样的。显然,本文更赞成采用后者。

调用process.nextTick()后,callback会入队到next tick callback queue中。调用Promise/then()后,相应的callback会进入microtask callback queue中。即使这两个队列同时不为空,nodejs总是先执行next tick callback queue,直到整个队列为空后,才会执行microtask callback queue。当microtask callback queue为空后,nodejs会再次回去检查next tick callback queue。只有当这两个队列都为空的情况下,nodejs才会进入event loop。 认真观察的话,我们会发现,这两个队列的支持递归入队的特性跟浏览器的event loop中micrtask队列是一样的。从这个角度,有些技术文章把next tick callback称为microtask callback是存在合理性的。当对microtask callback无限递归入队时,会造成一个后果:event loop starvation。也即是会阻塞event loop。虽然,这个特性不会造成nodejs程序报调用栈溢出的错误,但是实际上,nodejs已经处于无法假死的状态了。所以,我们不推荐无限递归入队。

可以看出,next tick callback和microtask callback的执行已经形成了一个小循环,nodejs只有跳转这个小循环,才会进入event loop这个大循环。

setTimeout VS setImmediate()

当mainline code执行完毕后,nodejs也进入了event loop之后,假如此时timer callback queue和 immediate callback queue都不为空的时候,那应该先执行谁呢?你可能觉得肯定是执行timer callback queue啊。是的,正常情况下是会这样的。因为timer阶段在check阶段之前嘛。但是存在一种情况,是会先执行immediate callback queue,再执行timer callback queue。什么情况呢?那就是两者的入队动作发生在poll阶段(也可以说发生在I/O callback代码里面)。为什么?因为poll阶段处于idle状态后,event loop一旦发现你immediate callback queue有callback了,它就会退出轮询阶段,从而进入check阶段去执行所有的immediate callback。此处不会像进入poll阶段之前所发生阶段回退,即不会优先回退到timer阶段去执行所有的timer callback。其实,timer callback的执行已经是发生在下一次event loop里面了。综上所述,如果timer callback和immediate callback在I/O callback里面同时入队的话,event loop总是先执行后者,再执行前者。

假如在mainline code有这样代码:

// timeout_vs_immediate.js
setTimeout(() => {
  console.log('timeout');
}, 0);

setImmediate(() => {
  console.log('immediate');
});
复制代码

那一定是先打印“timeout”,后打印“immediate”吗?答案是不一定。因为timer callback的入队时间点有可能受到进程性能(机器上运行中的其他应用程序会影响到nodejs应用进程性能)的影响,从而导致在event loop进入timer阶段之前,timer callback没能如预期进入队列。这个时候,event loop就已经进入了下一个阶段了。所以,上面的代码的打印顺序是无法保证的。有时候是先打印“timeout”,有时候是先打印“immediate”:

$ node timeout_vs_immediate.js
timeout
immediate

$ node timeout_vs_immediate.js
immediate
timeout
复制代码

大循环 VS 小循环

大循环指的就是event loop,小循环就是指由next tick callback queue和microtask callback queue所组成的小循环。我们可以下这么一个结论:一旦进入大循环之后,每执行完一个大循环 callback之后,就必须检查小循环。如果小循环有callback要执行,则需要执行完所有的小循环calback之后才会回归到大循环里面。 注意,这里强调的是,nodejs不会把event loop中当前阶段的队列都清空之后才进入小循环,而是执行了一个callback之后,就进入了小循环了。关于这一点,官方文档是这么说的:

......This is because process.nextTick() is not technically part of the event loop. Instead, the nextTickQueue will be processed after the current operation is completed, regardless of the current phase of the event loop. Here, an operation is defined as a transition from the underlying C/C++ handler, and handling the JavaScript that needs to be executed.

注意:在node v11.15.0之前(不包括本身),在这一点上是不一样的。在这些版本里面,表现是:event loop执行完当前阶段callback queue里面的所有callback才会进入小循环。你可以在runkit上面验证一下。

为了帮助我们理解,请看下面代码:

setImmediate(() => console.log('this is set immediate 1'));

setImmediate(() => {
  Promise.resolve().then(()=>{
    console.log('this is promise1 in setImmediate2');
  });
  process.nextTick(() => console.log('this is process.nextTick1 added inside setImmediate2'));
  Promise.resolve().then(()=>{
    console.log('this is promise2 in setImmediate2');
  });
  process.nextTick(() => console.log('this is process.nextTick2 added inside setImmediate2'));
  console.log('this is set immediate 2')
});

setImmediate(() => console.log('this is set immediate 3'));
复制代码

如果是一次性执行完所有的immediate callback才进入小循环的话,那么打印结果应该是这样的:

this is set immediate 1
this is set immediate 2
this is set immediate 3
this is process.nextTick1 added inside setImmediate2
this is process.nextTick2 added inside setImmediate2
this is promise1 in setImmediate2
this is promise2 in setImmediate2
复制代码

但是实际打印结果是这样的:

看到没,在执行完第二个immediate之后,小循环已经有callback在队列里面了。这时候,nodejs会优先执行小循环里面的callback。倘若小循环通过递归入队形成了无限循环的话,那么就会出现上面所提到的“event loop starvation”。上面的示例代码只是拿immediate callback做个举例而已,对于event loop其他队列里面的callback也是一样的,在这里就不赘述了。

也许你会好奇,如果在小循环的callback里面入队小循环callback(也就是说递归入队),那会怎样呢?也就是下面的代码的运行结果会是怎样呢?

process.nextTick(()=>{
  console.log('this is  process.nextTick 1')
});

process.nextTick(()=>{
  console.log('this is  process.nextTick 2')
  process.nextTick(() => console.log('this is process.nextTick added inside process.nextTick 2'));
});

process.nextTick(()=>{
  console.log('this is  process.nextTick 3')
});
复制代码

运行结果如下:

this is  process.nextTick 1
this is  process.nextTick 2
this is  process.nextTick 3
this is process.nextTick added inside process.nextTick 2
复制代码

可以看出,递归入队的callback并不会插队到队列的中间,而是被插入到队列的末尾。这个表现跟在event loop中被入队的表现是不一样的。这就是大循环和小循环在执行入队next tick callback和microtask callback时候的区别。

nodejs与browser中event loop的区别

这两者之间有相同点,也有差异点。再次强调,以下结论是基于node v12.16.2来得出的。

相同点

从运行机制的实质上来看,两者大体上是没有什么区别的。具体展开来说就是:如果把nodejs event loop中的mainline code和各个阶段中的callback都归纳为macrotask callback,把next tick callback和其他诸如Promise/then()的microtask callback都归纳为microtask callback的话,这两个event loop机制大体是一致的:都是先执行一个macrotask callback,再执行一个完整的microtask callback队列。microtask callback都具备递归入队的特性,无限递归入队都会产生“event loop starvation”后果。只有执行完microtask callback queue中的所有callback,才会执行下一个macrotask callback。

不同点

从技术细节来看,这两者还是有几个不同点:

  • 在nodejs event loop的实现中,没有macrotask的说法。
  • nodejs event loop 是按照阶段来划分的,具有六个阶段,对应六种类型的队列(其中两种是只供内部使用);而browser event loop不按照阶段划分,只有两种类型的队列,即macrotask queue和microtask queue。从另外一个角度我们可以这么理解:nodejs event loop有2个microtask队列,有4个macrotask队列;而浏览器event loop只有1个microtask队列,有1个macrotask队列。
  • 最大的不同,在于nodejs evnet loop有个轮询阶段。当evnet loop中所有队列都为空的时候,browser event loop会退出event loop(或者说处于休眠状态)。但是nodejs event loop不一样,它会持续命中轮询阶段,并且在那里等待处于pending状态的I/O callback。只有等待时间超出了nodejs计算出来的限定时间或者再也没有未完成的I/O任务的时候,nodejs才会退出event loop。这就是nodejs event loop跟browser event loop最大不同的地方。

参考资料

  1. The Node.js Event Loop, Timers, and process.nextTick();
  2. How is asynchronous javascript interpreted and executed in Node.js?;
  3. What you should know to really understand the Node.js Event Loop;
  4. Relationship between event loop,libuv and v8 engine;
  5. Introduction to the event loop in Node.js;
  6. Event Loop and the Big Picture;
  7. How does Node.js work?