Node 异步编程
- 事件发射器
- 事件循环
事件发射器(Event Emitter)
用于处理事件和异步编程。它是一个基于观察者模式(Observer pattern)的设计模式,用于将事件和事件处理器(或回调函数)解耦,从而使得应用程序更加模块化和可维护。
使用场景
- 处理 HTTP 请求和响应
- 处理文件系统操作
- 处理数据库操作
- 处理用户界面事件等等
使用方式
- on(eventName, listener):为指定事件注册一个监听器,每次触发事件时都会调用该监听器。
- emit(eventName, [arg1], [arg2], [...]):触发指定事件,并将可选参数传递给监听器。
- removeListener(eventName, listener):从指定事件的监听器数组中移除一个监听器。
- once(eventName, listener):为指定事件注册一个单次监听器,当事件触发时,监听器会被移除,然后再调用。
示例:
// Node.js 提供了内置模块`events`
const EventEmitter = require('events');
const eventEmitter = new EventEmitter();
// 注册事件
eventEmitter.on('start', () => {
console.log('started');
});
// 发送事件
eventEmitter.emit('start');
2
3
4
5
6
7
8
9
10
11
12
事件循环(Node.js Event Loop)
流程总结:
在主调用栈结束后,会优先处理 prcoess.nextTick 以及之前所有产生的微任务
之后正式进入 EventLoop 事件队列,首先是 timers 阶段,会检查 timers 中是否存在满足条件的定时器任务。当存在时,会依次取出对应的 timer (定时器产生的回调)推入 stack (JS 执行栈)中进行执行。
每当执行完毕后仍然会进行 Prcess.nextTick -> 微任务执行的步骤。
此后,在清空队列中所有的 timer 后,Loop 进入 poll 阶段进行轮询。
如果存在 I/O 相关 callback,那么推入对应 JS 调用栈进行执行,同样每次任务执行完毕会伴随清空随之产生的 Process.nextTick 以及微任务。(注意,如果此阶段产生了 timer 并不会在本次 Loop 中执行,因为此时 EventLoop 已经到达 poll 阶段)
- 5.1 如果轮询队列不是空的,事件循环将循环访问回调队列并同步执行它们。
- 5.2 如果轮询队列是空的
- 5.2.1 如果脚本被 setImmediate() 调度,则事件循环将结束 poll(轮询) 阶段,并继续 check(检查) 阶段
- 5.2.2 如果脚本 未被 setImmediate()调度,则事件循环将等待回调被添加到队列中,也就是会出现阻塞,会回头检查 loop 中是否存在到达时间的 timer,然后立即执行。
Node.js 事件循环分为 6 个阶段(宏任务):
timers:执行 setTimeout() 和 setInterval() 的回调函数
I/O callbacks:处理网络请求、访问数据库、读取文件。上一次循环队列中,还未执行完毕的会在这个阶段进行执行
idle, prepare:仅在内部使用。
poll:被称为轮询阶段,它主要会检测新的 I/O 回调,需要注意的是在适当的条件下会存在阻塞
check:检测 setImmediate() 回调函数在这个阶段进行执行
close callbacks:这个阶段会执行一系列关闭的回调函数
每一个宏任务阶段都包含一个微任务队列:
process.nextTick():会在当前任务执行完成后立即执行,因此它的优先级最高。
Promise/async/await:会在 process.nextTick() 之后执行。
queueMicrotask():方法也会在 Promise 之后执行,
和浏览器中的 event-loop 对比
Node.js 同样使用 ES 语法,也是单线程,也需要异步
都是执行完一个宏(macro)任务后清空本次队列中的微(micro)任务
异步任务也分:宏任务和微任务
但是,Node.js 中的宏任务和微任务分不同类型,有不同优先级
Node.js 针对于 EventLoop 实现一些自定义的额外队列,基于 Libuv
阶段优先级
event loop 的每个阶段都有一个该阶段对应的队列和一个微任务队列
当 event loop 到达某个阶段时,将执行该阶段的任务队列(先执行阶段队列,再执行微任务队列),直到队列清空或执行的回调达到系统上限后,才会转入下一个阶段
当所有阶段被顺序执行一次后,称 event loop 完成了一个 tick
注意
微任务中的 Promise 和 queueMicrotask 优先级问题,我用代码测试的结果是相同的,在调用栈中谁先执行,谁优先级高。可能和 Node 版本有关。
需要注意的是,微任务的执行顺序可能会因不同的实现而有所不同。
重点是 timers、poll、check 这三个阶段,开发中使用最多,影响着我们代码书写的执行顺序。
示例
console.info('start'); // 1
setImmediate(() => {
console.info('setImmediate'); // 6
});
setTimeout(() => {
console.info('setTimeout'); // 5
});
Promise.resolve().then(() => {
console.info('promise then'); // 4
});
// queueMicrotask(() => {
// console.info('queueMicrotask'); // 和Promise有相同的优先级
// });
process.nextTick(() => {
console.info('process.nextTick'); // 3
});
console.info('end'); // 2
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
timers 阶段
timers 是事件循环的第一个阶段,当我们使用 setTimeout 或者 setInterval 时,node 会添加一个 timer 到 timers 堆,当事件循环进入到 timers 阶段时,node 会检查 timers 堆中有无过期的 timer,如果有,则依次执行过期 timer 的回调函数。
setTimeout 和 setImmediate
二者非常相似,但是二者区别取决于他们什么时候被调用:
- setImmediate 设计在 poll 阶段完成时执行,即 check 阶段;
- setTimeout 设计在 poll 阶段为空闲时,且设定时间到达后执行(在 poll 阶段阻塞时会检查有无过期 timer,有则回到 timers 阶段执行 timer 的回调);
当二者在异步 I/O callback 内部调用时,总是先执行 setImmediate,再执行 setTimeout:
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
// 多次执行结果相同
// immediate
// timeout
2
3
4
5
6
7
8
9
10
11
12
13
14
因为 fs.readFile callback 执行完后,程序设定了 timer 和 setImmediate,因此 poll 阶段不会被阻塞进而进入 check 阶段先执行 setImmediate,后进入 timer 阶段执行 setTimeout。
poll 阶段
poll 阶段主要有 2 个功能:
- 处理 poll 队列的事件
- 计算应该阻塞和轮询 I/O 的时间(当有新的 I/O 完成,I/O callback 加入 poll queue,然后执行 I/O callback;当有已超时的 timer,进入 timers 阶段执行它的回调函数)
- 若有预设的 setImmediate(), event loop 将结束 poll 阶段进入 check 阶段,并执行 check 阶段的任务队列
- 若没有预设的 setImmediate(),event loop 将阻塞在该阶段等待
check 阶段
这个阶段允许在 poll 阶段结束后立即执行回调,如果 poll 阶段空闲并且有被 setImmediate 设置回调,那么事件循环直接跳到 check 阶段执行而不是阻塞在 poll 阶段等待回调被加入。
setImmediate 的回调会被加入 check 队列中。它使用 libuv 的 API 来设定在 poll 阶段结束后立即执行回调。
setImmediate
用于将一个函数添加到事件循环的下一个队列中,等待当前队列的所有任务执行完成后立即执行。
通常情况下,使用 setImmediate 比使用 setTimeout 更加高效,因为 setTimeout 的最小时间粒度是 1 毫秒(设置为 0 也是 1),而 setImmediate 可以在下一个事件循环迭代中立即执行任务。
process.nextTick
process.nextTick 的回调函数会被添加到 nextTickQueue,nextTickQueue 比其他 microtaskQueue 具有更高的优先级。
nextTick 不在 event loop 的任何阶段执行,而是在各个阶段切换的中间执行,即从一个阶段切换到下个阶段前执行。
官方推荐使用 setImmediate 代替 process.nextTick(),来提高性能。因为递归调用 process.nextTick()会阻塞程序,而 setImmediate 不会。