关于前端:深入nodejs的eventloop

38次阅读

共计 12053 个字符,预计需要花费 31 分钟才能阅读完成。

此处如无非凡指出的话,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 最大不同的中央。

正文完
 0