从源码解读 Node 事件循环

Node 是为构建实时 Web 应用而诞生的,可让 JavaScript 运行在服务端的平台。它具备事件驱动、单线程、异步 I/O 等特性。这些特性不只带来了巨大的性能提高,有效的解决了高并发问题,还避免了多线程程序设计的复杂性。javascript

本文主要讨论的是 Node 中实现异步 I/O 和事件驱动程序设计的基础——事件循环。html

事件循环是 Node 的执行模型

事件循环是 Node 自身的执行模型,Node 经过事件循环的方式运行 JavaScript 代码(初始化和回调),并提供了一个线程池处理诸如文件 I/O 等高成本任务。前端

在 Node 中,有两种类型的线程:一个事件循环线程(也称为主循环、主线程、事件线程等),它负责任务的编排;另外一个是工做线程池中的 K 个工做线程(也被称为线程池),它专门处理繁重的任务。java


看到这里,可能有同窗会有疑问,文章开头说 Node 是单线程的,为何又存在两种类型的线程呢? 事实上,Node 的单线程指的是自身 JavaScript 运行环境的单线程,Node 并无给 JavaScript 执行时建立新线程的能力,最终的操做,是经过底层的 libuv 及其带来的事件循环来执行的。这也是为何 JavaScript 做为单线程语言,能在 Node 中实现异步操做的缘由。二者并不冲突。 下图展现了异步 I/O 中线程的调用模型,能够看到,对于主线程来讲,一直都是单线程执行的。 node

image
【参考文章:Node.js 探秘:初识单线程的 Node.js — 凌恒


Node 的工做线程池是在 libuv 中实现的,它对外提供了通用的任务处理 API — uv_queue_worklinux

image
工做线程池被用于处理一些高成本任务。包括一些操做系统并无提供非阻塞版本的 I/O 操做,以及一些 CPU 密集型任务,如:

  1. I/O 密集型任务
    • DNS:用于 DNS 解析的模块,dns.lookup(), dns.lookupService()
    • 文件系统:全部文件系统 API,除了 fs.FSWatcher() 和显式调用 API 如 fs.readFileSync() 以外
  2. CPU 密集型任务
    • Crypto:用于加密的模块
    • Zlib:用于压缩的模块,除了那些显式同步调用的 API 以外

当调用这些 API 时,会进入对应 API 与 C++ 桥接通信的 Node C++ binding 中,从而向工做线程池提交一个任务。为了达到较高性能,处理这些任务的模块一般都由 C/C++ 编写。git

image

上图描述了 Node 的运行原理,从左到右,从上到下,Node 被分为了四层:github

  • 应用层。JavaScript 交互层,常见的就是 Node 的模块,如 http,fs
  • V8 引擎层。利用 V8 来解析 JavaScript 语法,进而和下层 API 交互
  • Node API 层。为上层模块提供系统调用,和操做系统进行交互
  • libuv 层。跨平台的底层封装,实现了事件循环、文件操做等,是 Node 实现异步的核心。它将不一样的任务分配给不一样的线程,造成一个事件循环(event loop),以异步的方式将任务的执行结果返回给 V8 引擎

基于事件循环能够构造高性能服务器

经典的服务器模型有如下几种:web

  • 同步式。一次只能处理一个请求,其余请求都处于等待状态。
  • 每进程/每请求。会为每一个请求启动一个进程,这样就能够同时处理多个请求,但因为系统资源有限,不具有扩展性。
  • 每线程/每请求。会为每一个请求启动一个线程,虽然线程比进程轻量,可是对于大型站点而言,依然不够。由于每一个线程都要占用必定内存,当大并发请求到来时,内存将会很快耗光。
  • 事件驱动。经过事件驱动的方式处理请求,无需为每一个请求建立额外的线程,能够省去建立和销毁线程的开销,同时操做系统在调度任务时由于线程较少,上下文切换的代价较低。这种模式被不少平台所采用,如 Nginx(C)、Event Machine(Ruby)、AnyEvent(Perl)、Twisted(Python),以及本文讨论的 Node。

事件驱动的实质,是经过主循环加事件触发的方式来运行程序,这种执行模型被称为事件循环。经过事件驱动,能够构建高性能服务器。数据库

既然不少平台都采用了事件驱动的模式,为何 Ryan Dahl 恰恰选了 JavaScript 呢?在开发 Node 时,Ryan Dahl 曾经评估过多种语言。最终结论为:C 的开发门槛高,能够预见不会有太多开发者将其做为平常的业务开发;Lua 自身已经包含不少阻塞 I/O 库,为其构建非阻塞 I/O 库也没法改变人们继续使用阻塞 I/O 库的习惯;Ruby 的虚拟机性能不够高。相比之下,JavaScript 比 C 开发门槛低,比 Lua 历史包袱少,在浏览器中已经有普遍的事件驱动应用,V8 引擎又具备超高性能,因而,Javascript 就成为了 Node 的开发语言。

例如,使用 Node 进行数据库查询:

db.query('SELECT * from some_table', function(res) {
  res.output();
});
复制代码

进程在执行到db.query 的时候,不会等待结果返回,而是直接继续执行后面的语句,直到进入事件循环。当数据库查询结果返回时,会将事件发送到事件队列,等到线程进入事件循环之后,才会调用以前的回调函数继续执行后面的逻辑。

固然,这种事件驱动开发模式的弊端也是显而易见的,它不符合常规的线性思路,须要把一个完整的逻辑拆分为一个个事件,增长了开发和调试的难度。

下面是多线程阻塞式 I/O 和单线程事件驱动的异步式 I/O 的对比:

image

【表格来自于 《Node.js 开发指南》 — byvoid】

基于事件循环能够实现异步任务调度

事件循环的使用场景能够分为异步 I/O 和非 I/O 的异步操做两种。

异步 I/O 的主旨是使 I/O 操做与 CPU 操做分离,从而非阻塞的调用底层接口。如前所述,常见的使用场景有网络通讯、磁盘 I/O、数据库访问等。固然,Node 也提供了部分同步 I/O 方式,如fs.readFileSync,但 Node 并不推荐用户使用它们。

非 I/O 的异步操做有定时器,如 setTimeoutsetInterval,以及 process.nextTicksetImmediatepromise

setTimeoutsetIntervalpromise 与浏览器中的 API 一致,在此再也不赘述。

process.nextTick 的功能是为事件循环设置一项任务, Node 会在下一轮事件循环时调用 callback。

为何不能在当前循环执行完这项任务,而要交给下次事件循环呢?咱们知道,一个 Node 进程只有一个主线程,在任什么时候刻都只有一个事件在执行。若是这个事件占用大量 CPU 时间,事件循环中的下一个事件就要等待好久。使用 process.nextTick() 能够把复杂的工做拆散,变成一个个较小的事件。例如:

function doSomething(args, callback) {
  somethingComplicated(args);
  process.nextTick(callback);
}
doSomething(function onEnd() {
  compute();
});
复制代码

假设 compute()somethingComplicated() 是两个较为耗时的函数,调用 doSomething() 时会先执行 somethingComplicated(),若是不使用 process.nextTick,会当即调用回调函数,在 onEnd() 中会执行 compute(),从而会占用较长 CPU 时间,阻塞其余事件的处理。而经过 process.nextTick 会把上面耗时的操做拆分至两次事件循环,减小了每一个事件的执行时间,避免阻塞其余事件。

另外,须要注意的是,虽然定时器也能将任务拆分至下一次事件循环处理,但并不建议用其代替 process.nextTick(fn),由于定时器的处理涉及到最小堆操做,时间复杂度为 O(lg(n)),而 process.nextTick 只是把回调函数放入队列之中,时间复杂度为 O(1),更加高效。

setImmediate()process.nextTick() 相似,也是将回调函数延迟执行。不过 process.nextTick 会先于 setImmediate 执行。由于 process.nextTick 属于 microtask,会在事件循环之初就执行;而 setImmediate 在事件循环的 check 阶段才会执行。这部分将在下一小节详述。

事件循环的执行机制

Node 中的事件循环是在 libuv 中实现的,libuv 在 Node 中的地位以下图:

image

【图片来自《深刻浅出 Node.js》 — 朴灵】

Node 不是一个从零开始开发的 JavaScript 运行时,它是“站在巨人肩膀上”进行一系列拼凑和封装获得的结果。V8(Chrome V8)是 Node 的 JavaScript 引擎,由谷歌开源,以 C++ 编写,具备高性能和跨平台的特性,同时也用于 Chrome 浏览器。libuv 是专一于异步 I/O 的跨平台类库,实际上它主要就是为 Node 开发的。基于不一样平台的异步机制,如 epoll / kqueue / IOCP / event ports,libuv 实现了跨平台的事件循环。做为一个在操做系统之上的中间层,libuv 使开发者不用本身管理线程就能轻松的实现异步。

下图是官方文档中给出的 libuv 结构图:

image

能够看出,除了事件循环外,libuv 还提供了计时器、网络操做、文件操做、子进程等功能。

在 Node 中,就是直接使用 libuv 中的事件循环:

github.com/nodejs/node…

image

下面是 libuv 中事件循环的详细流程:

image

如上图所示,libuv 中的事件循环主要有 7 个阶段,它们按照执行顺序依次为:

  • timers 阶段:这个阶段执行 setTimeoutsetInterval 预约的回调函数;
  • pending callbacks 阶段:这个阶段会执行除了 close 事件回调、被 timers 设定的回调、setImmediate 设定的回调以外的回调函数;
  • idle、prepare 阶段:供 node 内部使用;
  • poll 阶段:获取新的 I/O 事件,在某些条件下 node 将阻塞在这里;
  • check 阶段:执行 setImmediate 设定的回调函数;
  • close callbacks 阶段:执行 socket.on('close', ...) 之类的回调函数

除了 libuv 中的七个阶段外,Node 中还有一个特殊的阶段,它通常被称为 microtask,它由 V8 实现,被 Node 调用。包括了 process.nextTickPromise.resolve 等微任务,它们会在 libuv 的七个阶段以前执行,并且 process.nextTick 的优先级高于 Promise.resolve。值得注意的是,在浏览器环境下,咱们常说事件循环中包括宏任务(macrotask 或 task)和微任务(microtask),这两个概念是在 HTML 规范中制定,由浏览器厂商各自实现的。而在 Node 环境中,是没有宏任务这个概念的,至于前面所说的微任务,则是由 V8 实现,被 Node 调用的;虽然名字相同,但浏览器中的微任务和 Node 中的微任务实际上不是一个东西,固然,不排除它们间有相互借鉴的成分。

让咱们经过 libuv 中控制事件循环的核心代码,近距离观察这几个阶段。在 libuv v1.x 版本中,事件循环的核心函数 uv_run() 分别在 src/unix/core.csrc/win/core.c 中:

github.com/libuv/libuv…

int uv_run(uv_loop_t* loop, uv_run_mode mode) {
  int timeout;
  int r;
  int ran_pending;

	// 判断事件循环继续仍是启动新一轮循环
  r = uv__loop_alive(loop);
  if (!r)
    uv__update_time(loop);

  while (r != 0 && loop->stop_flag == 0) {
    uv__update_time(loop);
    uv__run_timers(loop);  // timers 阶段
    ran_pending = uv__run_pending(loop);  // pending 阶段
    uv__run_idle(loop);  // idle 阶段
    uv__run_prepare(loop);  // prepare 阶段

    timeout = 0;
    if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
      timeout = uv_backend_timeout(loop);

    uv__io_poll(loop, timeout);  // poll 阶段
    uv__run_check(loop);  // check 阶段
    uv__run_closing_handles(loop);  // close 阶段

    if (mode == UV_RUN_ONCE) {
      /* UV_RUN_ONCE implies forward progress: at least one callback must have * been invoked when it returns. uv__io_poll() can return without doing * I/O (meaning: no callbacks) when its timeout expires - which means we * have pending timers that satisfy the forward progress constraint. * * UV_RUN_NOWAIT makes no guarantees about progress so it's omitted from * the check. */
      uv__update_time(loop);
      uv__run_timers(loop);
    }

    r = uv__loop_alive(loop);
    if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
      break;
  }

  /* The if statement lets gcc compile it to a conditional store. Avoids * dirtying a cache line. */
  if (loop->stop_flag != 0)
    loop->stop_flag = 0;

  return r;
}
复制代码

从上述代码中能够清楚的看到 timers、pending、idle、prepare、poll、check、close 这七个阶段的调用。下面,让咱们详细看看这几个阶段。

timers 阶段

github.com/libuv/libuv…

void uv__run_timers(uv_loop_t* loop) {
  struct heap_node* heap_node;
  uv_timer_t* handle;

  for (;;) {
    heap_node = heap_min(timer_heap(loop));  // 最小堆
    if (heap_node == NULL)
      break;

    handle = container_of(heap_node, uv_timer_t, heap_node);
    if (handle->timeout > loop->time)  // 若是遇到第一个还未到触发时间的事件回调,退出循环
      break;

    uv_timer_stop(handle);
    uv_timer_again(handle);
    handle->timer_cb(handle);
  }
}
复制代码

能够看出,timers 阶段使用的数据结构是最小堆。这个阶段会在事件循环的一个 tick 中不断循环,把超时时间和当前的循环时间(loop -> time)进行比较,执行全部到期回调;若是遇到第一个还未到期的回调,则退出循环,再也不执行 timers queue 后面的回调。

这里为何用最小堆而不用队列?由于 timeout 回调须要按照超时时间的顺序来调用,而不是先进先出的队列逻辑。因此这里用了最小堆。

pending 阶段

github.com/libuv/libuv…

static int uv__run_pending(uv_loop_t* loop) {
  QUEUE* q;
  QUEUE pq;
  uv__io_t* w;

  if (QUEUE_EMPTY(&loop->pending_queue))
    return 0;

  QUEUE_MOVE(&loop->pending_queue, &pq);

  while (!QUEUE_EMPTY(&pq)) {
    q = QUEUE_HEAD(&pq);
    QUEUE_REMOVE(q);
    QUEUE_INIT(q);
    w = QUEUE_DATA(q, uv__io_t, pending_queue);
    w->cb(loop, w, POLLOUT);
  }

  return 1;
}
复制代码

这里使用的是队列。一些应该在上轮循环 poll 阶段执行的回调,若是由于某些缘由不能执行,就会被延迟到这一轮循环的 pending 阶段执行。也就是说,这个阶段执行的回调都是上一轮残留的。

idle、prepare、check 阶段

这三个阶段都由同一个函数定义

github.com/libuv/libuv…

void uv__run_##name(uv_loop_t* loop) { \ uv_##name##_t* h; \ QUEUE queue; \ QUEUE* q; \ QUEUE_MOVE(&loop->name##_handles, &queue); \ while (!QUEUE_EMPTY(&queue)) { \ q = QUEUE_HEAD(&queue); \ h = QUEUE_DATA(q, uv_##name##_t, queue); \ QUEUE_REMOVE(q); \ QUEUE_INSERT_TAIL(&loop->name##_handles, q); \ h->name##_cb(h); \ } \ }
复制代码

这里用了宏以实现代码的复用,但同时也下降了可读性。这部分的逻辑和 pending 阶段很像,遍历队列,执行回调,直至队列为空。

poll 阶段

github.com/libuv/libuv…

poll 阶段较为复杂,一共有 400+ 行代码,这里只截取部分,完整逻辑请自行查看源码。

void uv__io_poll(uv_loop_t* loop, int timeout) {
	// ...
	// 处理观察者队列
  while (!QUEUE_EMPTY(&loop->watcher_queue)) {
    // ...
    if (w->events == 0)
      op = EPOLL_CTL_ADD;  // 新增监听事件
    else
      op = EPOLL_CTL_MOD;  // 修改事件
	
	// ...
  for (;;) {
    /* See the comment for max_safe_timeout for an explanation of why * this is necessary. Executive summary: kernel bug workaround. */
		// 计算好 timeout 以防 uv_loop 一直阻塞
    if (sizeof(int32_t) == sizeof(long) && timeout >= max_safe_timeout)
      timeout = max_safe_timeout;

    nfds = epoll_pwait(loop->backend_fd,
                       events,
                       ARRAY_SIZE(events),
                       timeout,
                       psigset);

    /* Update loop->time unconditionally. It's tempting to skip the update when * timeout == 0 (i.e. non-blocking poll) but there is no guarantee that the * operating system didn't reschedule our process while in the syscall. */
    SAVE_ERRNO(uv__update_time(loop));

    if (nfds == 0) {
      assert(timeout != -1);

      if (timeout == 0)
        return;

      /* We may have been inside the system call for longer than |timeout| * milliseconds so we need to update the timestamp to avoid drift. */
      goto update_timeout;
    }

    if (nfds == -1) {
      if (errno != EINTR)
        abort();

      if (timeout == -1)
        continue;

      if (timeout == 0)
        return;

      /* Interrupted by a signal. Update timeout and poll again. */
      goto update_timeout;
    }

    have_signals = 0;
    nevents = 0;

    assert(loop->watchers != NULL);
    loop->watchers[loop->nwatchers] = (void*) events;
    loop->watchers[loop->nwatchers + 1] = (void*) (uintptr_t) nfds;
    for (i = 0; i < nfds; i++) {
      pe = events + i;
      fd = pe->data.fd;

      /* Skip invalidated events, see uv__platform_invalidate_fd */
      if (fd == -1)
        continue;

      assert(fd >= 0);
      assert((unsigned) fd < loop->nwatchers);

      w = loop->watchers[fd];

      if (w == NULL) {
        epoll_ctl(loop->backend_fd, EPOLL_CTL_DEL, fd, pe);
        continue;
      }
      pe->events &= w->pevents | POLLERR | POLLHUP;
      if (pe->events == POLLERR || pe->events == POLLHUP)
        pe->events |=
          w->pevents & (POLLIN | POLLOUT | UV__POLLRDHUP | UV__POLLPRI);

      if (pe->events != 0) {
        /* Run signal watchers last. This also affects child process watchers * because those are implemented in terms of signal watchers. */
        if (w == &loop->signal_io_watcher)
          have_signals = 1;
        else
          w->cb(loop, w, pe->events);

        nevents++;
      }
    }
		// ...
}
复制代码

poll 阶段的任务是阻塞以等待监听事件的来临,而后执行对应的回调。其中阻塞是有超时时间,在某些条件下超时时间会被置为 0。此时,就会进入下一阶段,而本 poll 阶段未执行的回调会在下一循环的 pending 阶段执行。

close 阶段

github.com/libuv/libuv…

static void uv__run_closing_handles(uv_loop_t* loop) {
  uv_handle_t* p;
  uv_handle_t* q;

  p = loop->closing_handles;
  loop->closing_handles = NULL;

  while (p) {
    q = p->next_closing;
    uv__finish_close(p);
    p = q;
  }
}
复制代码

close 阶段的逻辑很是简单,就是循环关闭全部的 closing handles,其中的回调被 uv__finish_close 调用。

上面即是 libuv 关于事件循环七个阶段的简单源码解读。以前咱们还提到,在 microtask 中,存在着 process.nextTickPromise.resolve 等阶段。这部分已经超出了 libuv 的范围,咱们能够在 node 源码中找到它们的调用路径。

process.nextTick

github.com/nodejs/node…

const {
  // For easy access to the nextTick state in the C++ land,
  // and to avoid unnecessary calls into JS land.
  tickInfo,
  // Used to run V8's micro task queue.
  runMicrotasks,
  setTickCallback,
  enqueueMicrotask
} = internalBinding('task_queue');
// ...
// Should be in sync with RunNextTicksNative in node_task_queue.cc
function runNextTicks() {
  if (!hasTickScheduled() && !hasRejectionToWarn())
    runMicrotasks();
  if (!hasTickScheduled() && !hasRejectionToWarn())
    return;

  processTicksAndRejections();
}
// ...
function nextTick(callback) {
  if (typeof callback !== 'function')
    throw new ERR_INVALID_CALLBACK(callback);

  if (process._exiting)
    return;

  var args;
  switch (arguments.length) {
    case 1: break;
    case 2: args = [arguments[1]]; break;
    case 3: args = [arguments[1], arguments[2]]; break;
    case 4: args = [arguments[1], arguments[2], arguments[3]]; break;
    default:
      args = new Array(arguments.length - 1);
      for (var i = 1; i < arguments.length; i++)
        args[i - 1] = arguments[i];
  }

  if (queue.isEmpty())
    setHasTickScheduled(true);
  queue.push(new TickObject(callback, args));
}
复制代码

能够看到,nextTick 就是向 queue 队列压入 callback,而后 nextTick 调用后获得的队列被 runNextTick 使用,触发 runMicrotasks 函数,这个函数经过 internalBiding 绑定至 node_task_queue.cc 中的同名函数。最终,会触发 V8 中的 microtask 队列处理。

同理,promise 的相应调用路径能够从 github.com/nodejs/node… 中追踪获得,篇幅有限,就再也不赘述了。

浏览器事件循环 vs Node 事件循环

浏览器中的事件循环是 HTML 规范中制定的,由不一样浏览器厂商自行实现;而 Node 中则由 libuv 库实现。所以,浏览器和 Node 中的事件循环在实现原理和执行流程上都存在差别。

浏览器环境

在浏览器中,JavaScript 执行为单线程(不考虑 web worker),全部代码均在主线程调用栈完成执行。当主线程任务清空后才会去轮循任务队列中的任务。

异步任务分为 task(宏任务,也能够被称为 macrotask)和 microtask(微任务)两类。关于事件循环的权威定义能够在 HTML 规范文档中查到:html.spec.whatwg.org/multipage/w…

当知足执行条件时,task 和 microtask 会被放入各自的队列中,等待进入主线程执行,这两个队列被称为 task queue(或 macrotask queue)和 microtask queue。

  • task:包括 script 中的代码、setTimeoutsetIntervalI/O、UI render
  • microtask:包括 promiseObject.observeMutationObserver

不过,正如规范强调的,这里的 task queue 并不是是队列,而是集合(sets),由于事件循环的执行规则是执行第一个可执行的任务,而不是将第一个任务出队并执行。

详细的执行规则能够在 html.spec.whatwg.org/multipage/w… 查询,一共有 15 个步骤。

能够将执行步骤不严谨的概括为:

  1. 执行完主线程中的任务
  2. 清空 microtask queue 中的任务并执行完毕
  3. 取出 macrotask queue 中的一个任务执行
  4. 清空 microtask queue 中的任务并执行完毕
  5. 重复 三、4

进一步概括,就是:一个宏任务,全部微任务;一个宏任务,全部微任务...

Node 环境

Node 中的事件循环流程已经在前面详述(参见_事件循环的执行机制_一节),这里就再也不赘述了。一图以蔽之:

image

下面,让咱们看一些容易产生误解的状况:

process.nextTick 形成的 starve 现象

const fs = require('fs');

function addNextTickRecurs(count) {
  let self = this;
  if (self.id === undefined) {
    self.id = 0;
  }

  if (self.id === count) return;

  process.nextTick(() => {
    console.log(`process.nextTick call ${++self.id}`);
    addNextTickRecurs.call(self, count);
  });
}

addNextTickRecurs(Infinity);
setTimeout(console.log.bind(console, 'omg! setTimeout was called'), 10);
setImmediate(console.log.bind(console, 'omg! setImmediate also was called'));
fs.readFile(__filename, () => {
  console.log('omg! file read complete callback was called!');
});

console.log('started');
复制代码

在这段代码中,因为递归调用了 process.nextTicksetTimeoutsetImmediate 以及文件 I/O 的回调将永远不会获得执行。由于执行 libuv 中七个阶段前,会清空 microtask 中的任务。所谓的清空是指,执行完 microtask 队列中已有的任务以后,准备执行 libuv 中的任务以前,会再次确认 microtask 中的任务是否为空,若还有任务,会继续执行。因为递归调用了 process.nextTick,会不断往 microtask 中添加任务,从而形成了这种其余队列的饥饿(starve)现象。

固然,在 Node v0.12 以前,存在 process.maxTickDepth 属性,用于限制 process.nextTick 的执行深度。可是在 v0.12 以后,出于某些缘由,这个属性被移除了。此后只能建议开发者避免写出这种代码。

执行结果为:

started
process.nextTick call 1
process.nextTick call 2
process.nextTick call 3
...
复制代码

setTimeout vs setImmediate

setTimeoutsetImmediate 的回调哪一个会先执行呢?有同窗可能会说,我知道啊,setTimeout 属于 timers 阶段,setImmediate 属于 check 阶段,因此会先执行 setTimeout。错~,正确答案是,咱们没法保证它们的前后顺序。

setTimeout(function() {
  console.log('setTimeout')
}, 0);
setImmediate(function() {
  console.log('setImmediate')
});
复制代码

屡次执行这段代码,能够看到,咱们会获得两种不一样的输出结果。

这是由 setTimeout 的执行特性致使的,setTimeout 中的回调会在超时时间后被执行,可是具体的执行时间却不是肯定的,即便设置的超时时间为 0。因此,当事件循环启动时,定时任务可能还没有进入队列,因而,setTimeout 被跳过,转而执行了 check 阶段的任务。

换句话说,这种状况下,setTimeousetImmediate 不必定处于同一个循环内,因此它们的执行顺序是不肯定的。

事情到这里并无结束:

const fs = require('fs');

fs.readFile(__filename, () => {
    setTimeout(() => {
        console.log('timeout')
    }, 0);
    setImmediate(() => {
        console.log('immediate')
    })
});
复制代码

对于这种状况,immediate 将会永远先于 timeout 输出。

让咱们捋一遍这段代码的执行过程:

  1. 执行 fs.readFile,开始文件 I/O
  2. 事件循环启动
  3. 文件读取完毕,相应的回调会被加入事件循环中的 I/O 队列
  4. 事件循环执行到 pending 阶段,执行 I/O 队列中的任务
  5. 回调函数执行过程当中,定时器被加入 timers 最小堆中,setImmediate 的回调被加入 immediates 队列中
  6. 当前事件循环处于 pending 阶段,接下来会继续执行,到达 check 阶段。这是,发现 immediates 队列中存在任务,从而执行 setImmediate 注册的回调函数
  7. 本轮事件循环执行完毕,进入下一轮,在 timers 阶段执行 setTimeout 注册的回调函数

promise vs process.nextTick

promiseprocess.nextTick 组合使用的状况比较好理解,nextTick 会优于 promise 执行,microtask 会优于 7 个阶段执行,在执行 7 个阶段前,会进一步确认 microtask 队列是否为空。例如:

Promise.resolve().then(() => console.log('promise1 resolved'));
Promise.resolve().then(() => console.log('promise2 resolved'));
Promise.resolve().then(() => {
    console.log('promise3 resolved');
    process.nextTick(() => console.log('next tick inside promise resolve handler'));
});
Promise.resolve().then(() => console.log('promise4 resolved'));
Promise.resolve().then(() => console.log('promise5 resolved'));
setImmediate(() => console.log('set immediate1'));
setImmediate(() => console.log('set immediate2'));

process.nextTick(() => console.log('next tick1'));
process.nextTick(() => console.log('next tick2'));
process.nextTick(() => console.log('next tick3'));

setTimeout(() => console.log('set timeout'), 0);
setImmediate(() => console.log('set immediate3'));
setImmediate(() => console.log('set immediate4'));
复制代码

执行结果将为:

next tick1
next tick2
next tick3
promise1 resolved
promise2 resolved
promise3 resolved
promise4 resolved
promise5 resolved
next tick inside promise resolve handler
set timeout
set immediate1
set immediate2
set immediate3
set immediate4
复制代码

总结

本文介绍了 Node 中事件循环的做用、执行机制以及与浏览器中事件循环的区别。事件循环是事件驱动编程模式的基础,经过事件驱动模式,能够构建异步非阻塞的高性能服务器,很是适合 I/O 密集型 web 应用。在 Node 中,事件循环是由 libuv 实现的,uv_run() 函数中定义了事件循环的七个阶段。在 HTML 规范中,一样也对事件循环作了定义,并由各个浏览器厂商各自实现,实现原理和运行机制都与 Node 中的事件循环有必定的区别。同时,因为 Node 是在不断迭代的,目前最新已经到了 v12.6.0 版本,不一样版本间也会存在必定差别,因此本文也没法涵盖关于事件循环的全部内容。当咱们讨论关于事件循环的具体问题时,可能会发现许多与以前经验不符的现象。对于这些问题,首先要肯定 Node 版本;而后,多动手实验、多看源码、多读规范,造成本身的正确认识。

关注我

因为本人水平有限,若有纰漏或建议,欢迎留言。若是以为不错,欢迎点赞和关注“海致前端”公众号。我会保持一周一篇干货分享,欢迎你来一块儿交流。感谢你的阅读,让咱们一块儿进步。

相关文章
相关标签/搜索