timer 用于安排函数在未来某个时间点被调用,Node.js 中的定时器函数实现了与 Web 浏览器提供的定时器 API 类似的 API,但是使用了事件循环实现,Node.js 中有四个相关的方法
setTimeout(callback, delay[, ...args])
setInterval(callback[, ...args])
setImmediate(callback[, ...args])
process.nextTick(callback[, ...args])
前两个含义和 web 上的是一致的,后两个是 Node.js 独有的,效果看起来就是 setTimeout(callback, 0)
,在 Node.js 编程中使用的最多。
Node.js 不保证回调被触发的确切时间,也不保证它们的顺序,回调会在尽可能接近指定的时间被调用。setTimeout 当 delay 大于 2147483647 或小于 1 时,则 delay 将会被设置为 1, 非整数的 delay 会被截断为整数
奇怪的执行顺序
看一个示例,用几种方法分别异步打印一个数字
setImmediate(console.log, 1);
setTimeout(console.log, 1, 2);
Promise.resolve(3).then(console.log);
process.nextTick(console.log, 4);
console.log(5);
会打印 5 4 3 2 1 或者 5 4 3 1 2
同步 & 异步
第五行是同步执行,其它都是异步的
所以先打印 5,这个很好理解,剩下的都是异步操作,Node.js 按照什么顺序执行呢?
setImmediate(console.log, 1);
setTimeout(console.log, 1, 2);
Promise.resolve(3).then(console.log);
process.nextTick(console.log, 4);
/****************** 同步任务和异步任务的分割线 ********************/
console.log(5);
Event Loop
Node.js 启动后会初始化事件轮询,过程中可能处理异步调用、定时器调度和 process.nextTick(),然后开始处理event loop。官网中有这样一张图用来介绍 event loop 操作顺序。
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
各个阶段主要任务
- timers:执行 setTimeout、setInterval 回调
- pending callbacks:执行延迟到下一个循环的 I/O 回调(文件、网络等)
- idle, prepare:仅供系统内部调用
- poll:获取新的 I/O 事件,执行相关回调,其余情况 node 将在适当的时候在此阻塞
- check:setImmediate 回调在此阶段执行
- close callbacks:执行 socket 等的 close 事件回调
日常开发中绝大部分异步任务都是在 timers、poll、check 阶段处理的
event loop 的每个阶段都有一个任务队列,当 event loop 进入给定的阶段时,将执行该阶段的任务队列,直到队列清空或执行的回调达到系统上限后,才会转入下一个阶段,当所有阶段被顺序执行一次后,称 event loop 完成了一个 tick。
异步操作都被放到了下一个 event loop tick 中,process.nextTick
在进入下一次 event loop tick 之前执行,所以肯定在其它异步操作之前。
setImmediate(console.log, 1);
setTimeout(console.log, 1, 2);
Promise.resolve(3).then(console.log);
/****************** 下次 event loop tick 分割线 ********************/
process.nextTick(console.log, 4);
/****************** 同步任务和异步任务的分割线 ********************/
console.log(5);
1. timer
timers 是事件循环的第一个阶段,Node 会去检查有无已过期的 timer。如果有,则把它的回调压入timer 的任务队列中等待执行。 :::warning 事实上,Node 并不能保证 timer 在预设时间到了就会立即执行,因为 Node 对 timer 的过期检查不一定靠谱,它会受机器上其它运行程序影响,或者那个时间点主线程不空闲。 ::: 下面的代码,多次执行可能会发现打印的顺序不一样。
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
- 如果机器性能一般,那么进入 timers 阶段,1 ms 已经过去了,那么
setTimeout
的回调会首先执行。- 如果机器性能牛逼,那么进入 timers 阶段,1 ms 还没到,
setTimeout
回调不执行。事件循环来到了 poll 阶段,这个时候队列为空,此时有setImmediate
的回调,于是执行了 check 阶段,之后在下一个事件循环再执行setTimemout
的回调函数。
但是把它们放到一个 I/O 回调里面,就一定是 setImmediate()
先执行,因为 poll 阶段后面就是 check 阶段。
const fs = require('fs')
const callback = (err, data) => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
}
fs.readFile('./xxx.txt', callback)
2. poll
poll 阶段主要有两个任务
- 计算应该阻塞和轮询 I/O 的时间
- 然后,处理 poll 队列里的事件
当 event loop 进入 poll 阶段且没有被调度的计时器时,将发生以下两种情况之一:
- 如果 poll 队列不是空的 ,event loop 将循环访问回调队列并同步执行,直到队列已用尽或者达到了系统或达到最大回调数
- 如果 poll 队列是空的
- 如果有 setImmediate() 任务,event loop 会在结束 poll 阶段后进入 check 阶段
- 如果没有 setImmediate() 任务,event loop 阻塞在 poll 阶段等待回调被添加到队列中,然后立即执行
一旦 poll 队列为空,event loop 将检查 timer 队列是否为空,如果非空则进入下一轮 event loop
3. check
在该阶段执行 setImmediate 回调
Promise.then 与 setTimeout 的顺序
前端同学肯定都听说过 micoTask 和 macroTask,Promise.then 属于 microTask。
下面我们用代码来举例子:
setTimeout(() => {
console.log('timer1')
Promise.resolve().then(() => {
console.log('promise1');
})
})
setTimeout(() => {
console.log('timer2')
Promise.resolve().then(() => {
console.log('promise2');
})
})
- 在浏览器环境下,按照其事件循环的规则,打印顺序为 timer1 —> promise1 —> timer2 —> promise2
在浏览器环境下 microTask 任务会在每个 macroTask 执行最末端调用。
- 在 Node v11 以前,按照事件循环规则,打印顺序是 timer1 —> timer2 —> promise1 —> promise2
在 Node 环境 microTask 会在每个阶段完成之间调用,也就是每个阶段执行最后都会执行一下 microTask 队列
- 在 Node v11 以后,修改了事件循环规则,打印顺序是 timer1 —> promise1 —> timer2 —> promise2
在同一个阶段中只要执行了 macrotask 就会立即执行 microtask 队列,与浏览器表现一致。
与 Node v11 之前不同的是,当 Timeout cb1 执行完时,会判断 microTask queue 是否为空。
如果不为空,会进入下一轮循环,先执行 microTask queue 的任务。所以打印 timeout1 后,打印 promise1
如何验证是进入了下一个循环tick
呢?我们利用 process.nextTick
会在每次循环之前触发的特点
下列代码打印顺序:timer1 —> nextTick1 —> promise1 —> timer2 —> nextTick2 —> promise2
setTimeout(() => {
console.log('timer1')
Promise.resolve().then(() => {
console.log('promise1');
})
process.nextTick(() => console.log('nextTick1'))
})
setTimeout(() => {
console.log('timer2')
Promise.resolve().then(() => {
console.log('promise2');
})
process.nextTick(() => console.log('nextTick2'))
})