事件循环(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()
andsetInterval()
定时器已经达到阈值执行时间,如果有,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 时取出执行。 定时器中采用红黑树的操作时间复杂度为
另外,过多的 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
-
https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/
-
《深入浅出 Node.js》
-
libuv 初步学习 - https://blog.csdn.net/okiwilldoit/article/details/79014979