【Node.js】Node.js 的 Event Loop 与异步 I/O

Posted by 西维蜀黍 on 2018-11-06, Last Modified on 2021-09-21

事件循环(Event Loop)

Node的执行模型称为事件循环(Event Loop)。在Node进程启动后,Node会用主线程来执行所有用户代码。当主线程对应的栈为空时(所有用户代码执行完毕了)Node会开始执行一个类似while(true)的事件循环(Event Loop)(从Node代码实现的角度来说,并不存在这样的while(true))。

每一次执行循环体的过程都称为一个Tick。每一个Tick的过程就是查看是否有需要处理的事件(分布于不同阶段中),每个阶段依次被执行,每个阶段中存在关联的回调函数。如果不再有事件处理,就退出Node进程。

浏览器采用了类似的机制。事件可能来自用户的点击或者加载某些文件时产生,而这些产生的事件都有对应的观察者。在Node中,事件主要来源于网络请求、文件I/O等,这些事件对应的观察者有文件I/O观察者、网络I/O观察者等。观察者将事件进行了分类。

事件循环是一个典型的生产者/消费者模型。异步I/O、网络请求等则是事件的生产者,源源 不断为Node提供不同类型的事件,这些事件被传递到对应的观察者那里,事件循环则从观察者那里取出事件并处理。

在Windows下,这个循环基于IOCP创建,而在*nix下则基于多线程创建。


对于I/O操作,Node使用了自己设计的libuv,libuv是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的API。具体来说,

事件循环的各个阶段(Phase)

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘

解释

  • 每一个框框都表示event loop中的一个阶段(phase)
  • 每一个阶段都有一个先进先出(FIFO)的回调函数(callback)队列(Queue)
  • 当event loop进入某一个阶段时,这个阶段中对应的回调函数队列中的回调函数会被依次执行,直到这个回调函数队列为空,或者达到了指定的最大执行次数。此后,event loop会完成这一阶段的执行,并进入下一个阶段

每一个阶段的含义

  • times:包括 setTimeout()setInterval()定时器调用的回调函数(callback)。这个回调函数会尽可能地在到达定时器指定的时间后被触发(因为可能会因其他回调函数的执行而导致这个定时器没有准时触发)
  • pending callbacks: 执行一些系统操作的回调。比如当一个TCP Socket尝试连接并收到ECONNREFUSED时,对应的处理这个TCP错误的回调函数会在这个阶段被执行
  • idle, prepare: 包括process.nextTick()调用的回调。idle对应的回调函数保存在一个数组中,每一次event loop到达idle阶段后,数组中的回调函数会被全部执行完,此后这次event loop的idle阶段才结束
  • poll: 检查线程池中是否有已经完成的I/O事件,如果有,则将该I/O事件对应的回调函数放入poll阶段对应的队列中。更准确地说:
    • poll对应的队列不为空时,队列中的回调函数会被依次执行,直到这个回调函数队列为空,或者达到了指定的最大执行次数
    • poll对应的队列为空时,
      • 若代码中包含setImmediate()任务,Event Loop将结束poll阶段,并进入check阶段(并在check阶段执行setImmediate()的回调函数);
      • 若代码中不包含setImmediate()任务,Event Loop会检查当前是否有setTimeout() and setInterval()定时器已经达到阈值执行时间,如果有,Event Loop会回滚到times阶段,并执行这些定时器对应的回调函数
    • 回调函数需要被添加到队列中,如果有,则添加他们到队列中,并立刻执行他们。
  • check: 包括setImmediate()调用的回调。
  • close callbacks: 包括一些意外关闭事件的回调。比如一个socket被突然关闭(调用了socket.destroy()

可以发现,当poll的队列为空时,代码中又包含setImmediate()任务,Event Loop会立即执行setImmediate()的回调函数,这样的机制是尽可能保证setImmediate()可以被尽早执行。

Node异步的内部实现机制

I/O的异步API

以一个简单的fs.open()例子,来探索从JavaScript代码层的调用(异步调用),到异步I/O如何被执行,最终回调函数如何被调用(执行回调)。

JavaScript代码层的异步调用

fs.open()的作用是根据指定路径和参数去打开一个文件,从而得到一个文件描述符,这是后续所有I/O操作的初始操作。JavaScript层面的代码通过调用C++核心模块以实现下层的操作。

具体来说,JavaScript调用Node的核心模块,核心模块调用C++内建模块,内建模块通过libuv库进行系统内核调用,这里libuv作为封装层,有两个平台的实现,实质上是调用了uv_fs_open()方法。

异步I/O的执行

在uv_fs_open()的调用过程中,Node底层会创建一个FSReqWrap请求对象。从JavaScript层传入的参数和回调函数都被封装在这个请求对象中。此后,FSReqWrap请求对象诶推入线程池中等待执行。

至此, JavaScript调用立即返回, 由JavaScript层面发起的异步调用的第一阶段就此结束。 JavaScript线程可以继续执行当前任务的后续操作。当前的I/O操作在线程池中等待执行,不管它是否阻塞I/O,都不会影响到JavaScript线程的后续执行,如此就达到了Node异步I/0的目的。

执行回调

JavaScript代码层的异步调用、组装好请求对象、送入I/O线程池等待执行,实际上只是完成了JavaScript代码层的调用和异步I/O的执行,这只是异步I/O的第一部分。获得回调通知并执行回调则是第二部分。

在event loop的每次Tick的poll阶段,Node引擎会检查线程池中是否有已经完成的I/O操作,如果有,则将其请求对象放入poll阶段的队列中。之前提到,请求对象中包含该I/O操作对应的JavaScript回调函数。因此,该回调函数就能被执行了。

总结

事件循环、观察者、请求对象、I/O线程池这四者共同构成了Node异步I/O模型的基本要素。

Windows下主要通过IOCP来向系统内核发送I/O调用和从内核获取已完成的I/O操作,配以事件循环,以此完成异步I/O的过程。在Linux下通过epoll实现这个过程,FreeBSD下通过kqueue实现,Solaris下通过Event ports实现。不同的是线程池在Windows下由内核(IOCP)直接提供,*nix 系列下由libuv自行实现。

非 I/O 的异步 API

尽管在介绍Node的时候,多数情况下都会提到异步I/O,但是Node中其实还存在一些与 I/O无关的异步API,它们分别是setTimeout()、setInterval()、 setImmediate()和process.nextTick()。

setTimeout()与setInterval()

setTimeout()和setInterval()与浏览器中的API是一致的,分别用于单次和多次定时执行任 务。它们的实现原理与异步I/O比较类似,只是不需要I/O线程池的参与。调用setTimeout()或者 setInterval()创建的定时器会被插入到定时器观察者内部的一个红黑树中。每次Tick执行时,会从该红黑树中迭代取出定时器对象,检查是否超过定时时间,如果超过,就形成一个事件,它的回调函数将立即执行。

process.nextTick()

在未了解 process.nextTick()之前,很多人也许为了立即异步执行一个任务,会这样调用 setTimeout()来达到所需的效果:

setTimeout(function () { 
// TODO 
}, 0);

由于事件循环自身的特点,定时器的精确度不够。而事实上,采用定时器需要动用红黑树, 创建定时器对象和迭代等操 作, 而 setTimeout(fn, 0) 的方式较为浪费性能。 实际上, process.nextTick()方法的操作相对较为轻量,

每次调用process.nextTick()方法,只会将回调函数放入队列中,在下一轮Tick时取出执行。 定时器中采用红黑树的操作时间复杂度为$O(log_2(n))$,nextTick()的时间复杂度为O(1)。相较之下, process.nextTick()更高效。

另外,过多的process.nextTick()声明会导致所有的异步I/O回调被阻塞。原因在于,由于使用process.nextTick()声明的回调函数会被添加到idle队列中,而idle又在poll阶段之前。因此,只有当 idle队列中的回调函数被依次执行后(idle队列被清空后),处理异步I/O的异步回调才能被执行。

setImmediate()

可以发现,当poll的队列为空时,代码中又包含setImmediate()任务,Event Loop会立即进入check阶段以执行setImmediate()的回调函数。

这样设计是为了尽可能地保证setImmediate()可以被尽早执行。

(当poll的队列不为空时,会先执行poll队列中的回调函数,直到这个回调函数队列为空,或者达到了指定的最大执行次数。此后进入check阶段,并依次执行check队列中的回调函数,然后进入close callbacks阶段)

setImmediate()方法与process.nextTick()方法十分类似,都是将回调函数延迟执行。两者之间细微的差别在于两者被触发的优先级不同(process.nextTick()会被更早触发)。

使用setImmediate()方法声明的回调函数会被添加到idle阶段中,而使用process.nextTick()方法声明的回调函数会被添加到check阶段中。

而由于在Event Loop的每个Tick中,idle阶段早于check阶段。因此,同时调用两者时,process.nextTick()对应的回调函数会先被执行。示例:

process.nextTick(function () { 
	console.log('nextTick延迟执行'); 
}); 

setImmediate(function () {
	console.log('setImmediate延迟执行'); 
}); 

console.log('正常执行');

// 其执行结果如下:
// 正常执行 
// nextTick延迟执行 
// setImmediate延迟执行

Node中的libuv

除了V8之外,Node在底层还依赖于libuv。

libuv 是 Node 的副产品。最开始 Node 用 libev 监听各种异步事件,后来因为它无法支持 Windows 才不得不自己写了个 libuv,加入了 Windows 支持。

libuv是一个高性能事件驱动库,使用异步和基于事件驱动的编程方式,核心是提供I/O的事件循环和异步回调。从本质上来说,libuv实现了一套自己的**线程池(Thread Pool)**来处理所有同步操作(从而模拟出异步的效果)。

libuv提供了一个跨平台的抽象,libuv是一个高性能事件驱动库。本质上,会根据当前运行的操作系统来决定最底层到底使用哪个内核事件通知机制。

在Windows 平台中,依赖的内核事件通知机制是IOCP;在Linux中,对应epoll;在FreeBSD中,对应kqueue();在中Solaris,对应event ports。


在libuv事件编程模型中, 应用程序只是去监视特定的事件, 并在事件发生后对其作出响应。而收集事件或监控其他事件源则是libuv的职责, 编程人员只需要对感兴趣的事件去注册回调函数, 在事件发生后 libuv 将会调用相应的回调函数. 只要程序不退出并且还有待处理的事件, 事件循环会一直运行。

其他异步事件库

Libevent、libev、libuv三个网络库,都是c语言实现的异步事件库Asynchronousevent library)

异步事件库本质上是提供异步事件通知(Asynchronous Event Notification,AEN)的。异步事件通知机制就是根据发生的事件,调用相应的回调函数进行处理。

  • libevent :名气最大,应用最广泛,历史悠久的跨平台事件库;Nginx就基于libevent
  • libev :较libevent而言,设计更简练,性能更好,但对Windows支持不够好;
  • libuv :开发node的过程中需要一个跨平台的事件库,他们首选了libev,但又要支持Windows,故重新封装了一套,linux下用libev实现,Windows下用IOCP实现;

可见,目前libuv的影响力最大,其次是libevent,libev关注的人较少。

Reference