浏览器-事件循环
尽管 JS 运行异步 JS 代码, 但实际上, JS 本身并没有内建异步的概念.
JS 引擎没有运行在隔离的区域, 而是运行在一个宿主环境中, 它可能是浏览器, 或者 Node 这样的 Runtime. 所有的这些环境都有同样的机制: 事件轮询 (Event Loop).
JS的事件分为两种, 宏任务(macro-task)和微任务(micro-task).
- 宏任务: script全部代码, setTimeout, setInterval, setImmediate(浏览器暂不支持), I/O, UI render
- 微任务: Promise.then, process.nectTick(Node), Object.observe(废弃)、MutationObserver .
浏览器中的 Event-Loop
JavaScript有一个main thread(主线程)和call-stack(调用栈, 或执行栈), 所有的任务都会被放到调用栈中等待主线程去执行.
JS 调用栈
JS调用栈采用的是后进先出的规则, 当函数指定的时候, 会被添加到栈的顶部, 当执行栈执行完成后, 就会从栈顶移出, 直到栈内被清空.
同步任务和异步任务
JS单线程任务被分为同步任务和异步任务, 同步任务会在调用栈中按照顺序等待主线程一次执行, 异步任务会在异步任务有了结果以后, 将注册的回调函数放入任务队列中等待主线程空闲的时候(调用栈被清空), 被读取到栈内等待主线程的执行.

任务队列task Queue, 是一个典型的队列结构, 也就是先进先出.
简单的说, 一个任务的执行流程会如下所示:

事件循环的进程模型
- 选择当前的任务队列, 选择任务队列中最先进入的任务, 如果任务队列为空, 则跳转到执行微任务队列
- 将事件循环中的任务设置为已选择任务
- 执行任务
- 将事件循环中当前运行任务设置为null
- 将已经运行完成的任务从任务队列中删除
- microtasks步骤: 进入mincrotask检查点
- 设置microtask检查点标志为true
- 当事件循环mincrotask执行不为空:
- 选择一个最先进入微任务队列的微任务
- 将事件循环的微任务设置为已选择的微任务
- 运行微任务
- 将已经执行的微任务设置为null
- 移除该微任务
- 清理IndexDB食物
- 设置mincrotask检查点的标志位false
- 更新界面渲染
- 返回第一步
简单的来说, 就是执行栈在执行完同步任务后, 查看执行栈是否为空, 如果执行栈为孔明, 就会去执行Task(宏任务), 每次宏任务执行完毕后, 检查microTask(微任务)是否为空, 如果不为空, 按照先入先出的规则执行完全部的微任务, 然后重置微任务队列, 然后再执行宏任务. 如此循环.
一个简单的例子
console.log('script start')
async function async1() {
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2 end')
}
async1()
setTimeout(function() {
console.log('setTimeout')
}, 0)
new Promise(resolve => {
console.log('Promise')
resolve()
})
.then(function() {
console.log('promise1')
})
.then(function() {
console.log('promise2')
})
console.log('script end')
这里, 首先输出第一次同步任务的结果:
script start
async2 end
Promise
scriptend
这里被推入到微任务队列中的有:
async1 end
promise1
promise2
所以执行这些微任务. 注意这里的promise2是在执行微任务的过程中被推入到当前的微任务队列中的.
最后执行宏任务:
setTimeout
Node 中的 Event Loop
Node 中的 Event Loop 和浏览器中的是完全不相同的东西. Node.js 采用 v8 作为 js 的解析引擎, 而 I/O 处理方面使用了libuv, libuv是一个基于事件驱动的跨平台抽象层, libuv使用异步, 事件驱动的编程方式. 和兴是提供i/o的时间循环和异步回调. libuv的api包含: 时间(timer), 非阻塞的网络, 异步文件操作, 子进程等等. Event Loop就是在libuv中实现的.

Node.js 的运行机制如下:
- V8 引擎解析 JS 脚本
- 解析后的代码调用 Node API
- libuv 库负责 Node API 的执行. 它将不同的任务分配给不同的线程, 形成一个事件循环(Event Loop), 以异步的方式将任务的执行结果返回给 v8 引擎.
六个阶段
其中 libuv 引擎中的事件分为 6 个阶段, 会按照顺序反复运行. 每当进入某一个阶段的时候, 都会从对应的回调队列中取出函数去执行, 当队列为空或者执行的回调函数数量达到系统设定的阈值, 就会进入下一阶段:
大致可以看出, 事件循环的事件是:
外部输入数据-->
轮询阶段(poll)-->
检查阶段(check)-->
关闭事件回调阶段(close callback)-->
定时器检测阶段(timer)-->
I/O事件回调阶段(I/O callbacks)-->
闲置阶段(idle, prepare)-->
轮询阶段(按照该顺序反复运行)...
timers: 执行 timer(setTimeout、setInterval)中到期的回调pending callbacks: 处理一些上一轮循环中的少数未执行的 I/O 回调idle, prepare: 仅 node 内部使用poll: 最重要的阶段, 执行pending callback, 在适当的情况下会阻塞在这个阶段check: 执行 setImmediate() 的回调, setImmediate是将事件插入到事件队列尾部, 主线程和事件队列的函数执行完成之后立即执行setImmediate()指定的回到函数.close callbacks: 执行 close 事件回调, 例如socket.on('close'[, fn])或者heep.server.on('close', fn).
┌───────────────────────┐
┌─>│ timers │<————— 执行 setTimeout()、setInterval() 的回调
│ └──────────┬────────────┘
| |<-- 执行所有 Next Tick Queue 以及 MicroTask Queue 的回调
│ ┌──────────┴────────────┐
│ │ pending callbacks │<————— 执行由上一个 Tick 延迟下来的 I/O 回调(待完善,可忽略)
│ └──────────┬────────────┘
| |<-- 执行所有 Next Tick Queue 以及 MicroTask Queue 的回调
│ ┌──────────┴────────────┐
│ │ idle, prepare │<————— 内部调用(可忽略)
│ └──────────┬────────────┘
| |<-- 执行所有 Next Tick Queue 以及 MicroTask Queue 的回调
| | ┌───────────────┐
│ ┌──────────┴────────────┐ │ incoming: │ - (执行几乎所有的回调,
│ │ poll │<─────┤ connections, │ 除了 close callbacks
│ └──────────┬────────────┘ │ data, etc. │ 以及 timers 调度的回调
│ | | | 和 setImmediate() 调度的回调,
| | └───────────────┘ 在恰当的时机将会阻塞在此阶段)
| |<-- 执行所有 Next Tick Queue 以及 MicroTask Queue 的回调
| ┌──────────┴────────────┐
│ │ check │<————— setImmediate() 的回调将会在这个阶段执行
│ └──────────┬────────────┘
| |<-- 执行所有 Next Tick Queue 以及 MicroTask Queue 的回调
│ ┌──────────┴────────────┐
└──┤ close callbacks │<————— socket.on('close', ...)
└───────────────────────┘
其实node和浏览器的区别, 就是node的MacroTask分为好几种, 而这些不同的宏任务队列之间又有顺序区别, 微任务队列穿插在每一种(不是每一个)宏任务队列之间.
如图里面写的:
setTimeout/setInterval 属于timers
setImmediate 属于check类型
socket的close事件属于
close callbacks类型其他宏任务就都是poll类型
process.nextTick 本质上属于微任务, 但它先于所有其他的微任务执行
所有微任务的执行时机, 是不同类型的idle/prepare 仅供内部调用,我们可以忽略。
idle/prepare 仅供内部调用,我们可以忽略。
pending callbacks 不太常见,我们也可以忽略。
所以按照我们在浏览器中的经验, 可以得出一个结论:
- 先执行所有任务类型为timers的宏任务, 然后执行所有微任务, 这里如果有nextTick, 要先执行nextTick
- 进入poll阶段, 执行几乎所有的宏任务, 再执行所有的微任务
- 执行所有类型为check的宏任务, 然后执行所有的微任务
- 执行所有的close, 执行所有的微任务
到这里, 完成一个事件循环, 回到timer阶段.
一个例子
setTimeout(()=>{
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
setTimeout(()=>{
console.log('timer2')
Promise.resolve().then(function() {
console.log('promise2')
})
}, 0)
这个在浏览器环境下会输出:
timer1
promise1
timer2
promise2
这里先执行timer1这个宏任务, 然后清空所有的微任务(promise1), 然后执行timer2这个宏任务, 在执行所有的微任务(promise2)
但是在node(< 10)中的输出结果是不一样的:
timer1
timer2
promise1
promise2
先执行所有的同类型的宏任务, 然后再执行所有的微任务
当然这里还有几个细节:
setTimeout 和 setImmediate
前文说道timer是在check之前的, 但是实际上, Node不能保证timer在预设的时间到了之后就会立即执行, 因为Node对timers的过期检测不一定靠谱, 而是会受到系统调度的影响. 比如下面的代码:
setTimeout(() => {
console.log('timeout')
}, 0)
setImmediate(() => {
console.log('immediate')
})
虽然setTimeout延时是0, 但是Node会把0设置为1ms, 所以当node准备eventloop的时间大于1ms时, 进入timer阶段, setTimeout已经到期了, 就会执行setTimeout, 反之, 就会错过timers阶段, 先执行setImmediate
immediate
timeout
不过有一种情况, 顺序是固定:
const fs = require('fs')
fs.readFile('test.txt', () => {
console.log('readFile')
setTimeout(() => {
console.log('timeout')
}, 0)
setImmediate(() => {
console.log('immediate')
})
})
在这种情况下, setTimeout和setImmediate是卸载I/O callcaks中的, 这就意味着我们处在poll阶段, 然后是check阶段, 所以这时无论settimerout怎么快, 都会先执行setImmediate.
poll 阶段
poll阶段主要有两个功能:
- 获取新的I/O事件, 并执行这些I/O回调, 之后适当的条件下把node阻塞在这里
- 当有immediate或者已经超时的timers, 在这里执行他们的回调
poll阶段用于获取并执行几乎所有的I/O事件回调, 是是的node event loop得以无限循环下去的重要阶段. 所以它的首要任务就是同步执行所有poll queue中的所有callbacks直到queue被清空或者已经执行的callbacks达到一定的上限, 然后结束poll阶段, 接下来会有几种情况:
- setImmediate的queue不为空, 则进入check阶段, 然后是close callbacks阶段...
- setImmediate的queue为空, 但是timers的queue不为空, 则直接进入timers阶段, 然后又来到了poll阶段
- setImmediate的queue为空, timers的queue也为空, 则会阻塞在这里, 因为已经无事可做了.
pending callback 阶段
在libuv的event loop中, I/O callbacks阶段会执行pending callbacks. 绝大所述情况下, 在poll阶段, 所有的I/O回调都已经被执行, 但是在某些情况下, 会有一些回调被延迟到下一次循环执行. 也就是说, 在I/O callbacks阶段中执行的回调函数, 是上一次事件循环中被延迟执行的回调函数.
严格来说,i/o callbacks并不是处理文件i/o的callback而是处理一些系统调用错误,比如网络 stream, pipe, tcp, udp通信的错误callback。