一、事件循环的本质
本质:运行时对 JS 脚本的调度方式就叫做事件循环.
- 对于 浏览器 而言,需要考虑用户交互、UI渲染、脚本运行、网络请求等操作,这些操作必然都依赖于事件去执行,因此,为了协调事件必须要使用事件循环.
- 对于 Node 而言,尽管 JavaScript 是单线程的,但系统内核是多线程的,它们可以在后台处理多种操作,当其中一个完成操作的时候,内核将通知 Node 将适合的回调添加到队列中,并等待时机执行. 而事件循环是 Node 处理非阻塞 I/O 操作的机制.
二、浏览器的事件循环
JS 为什么是单线程?
JavaScript 的用途决定了它的单线程。
作为浏览器脚本语言,JavaScript 的主要用途是与用户互动,以及操作 DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定 JavaScript 同时有两个线程,一个线程在某个 DOM 节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?
任务队列
产生任务队列的原因?
单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。
如果排队是因为计算量大,CPU 处理不过来,这时候也算合理,但很多时候 CPU 是空闲的,是因为 IO 设备(输入输出设备)很慢(比如 Ajax 操作从网络读取数据),CPU 不得不等着结果返回,才能继续往下执行。
JavaScript 语言的设计者意识到,这时主线程完全可以不管 IO 设备,挂起处于等待中的任务,先运行排在后面的任务。等到 IO 设备返回了结果,再回过头,把挂起的任务继续执行下去。
任务队列类型
所有任务可以分成两种,一种是 同步任务(synchronous),另一种是 异步任务(asynchronous)。
同步任务 指的是 在主线程上排队执行的任务 ,只有前一个任务执行完毕,才能执行后一个任务;
异步任务 指的是 不进入主线程,而进入"任务队列"(task queue)的任务,只有 "任务队列" 通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。
异步执行的运行机制(同步执行也是如此,因为它可以被视为没有异步任务的异步执行)
(1)所有同步任务都在 主线程 上执行,形成一个 执行栈(execution context stack)。
(2)主线程之外,还存在一个 "任务队列"(task queue)。只要 异步任务 有了运行结果,就在 "任务队列" 之中放置一个事件。
(3)一旦 "执行栈" 中的 所有同步任务执行完毕,系统就会 读取 "任务队列" ,看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
(4)主线程不断重复上面的第三步。
主线程 "任务队列" 中读取事件,这个过程是循环不断的,所以整个的运行机制又被称为 事件循环(Event Loop)
宏任务和微任务
除了广义的定义 同步任务 和 异步任务,JavaScript 单线程中的任务还可以细分为 宏任务(macrotask) 和 微任务(microtask)
宏任务包含:
- script (整体代码)
- setTimeout
- setInterval
- I/O
- UI 交互事件
- postMessage
- MessageChannel
- setImmediate(Node.js)
注意: 很多人可能认为 requestAnimationFrame 是宏任务,但确切的说它的执行时机和宏任务还是有些不同的,具体可见
微任务包含:
- Promise.then、catch 、finally
- queueMicrotask()
- MutationObserver
- process.nextTick (Node.js)
通过题目进行分析
- 一轮事件循环,会执行一次宏任务,以及本轮循环产生的所有微任务.
- 任务队列保持先进先出的顺序去执行.
// 下面的输出顺序是什么? setTimeout(() => { console.log('setTimeout'); }, 0); Promise.resolve().then(()=>{ console.log('promise'); }); console.log('main'); 复制代码
相信上面的题目对大家来说都不困难,下面通过图片来展示相应的流程:
到现在你应该对整个浏览器事件循环有所理解,可以尝试分析下面这道题目:
- PS:如果你仍旧不是很理解这个流程,建议按照本文中画流程图的形式去进行分析
setTimeout(() => { console.log('setTimeout_outer'); Promise.resolve().then(() => { console.log('promise in setTimeout_outer'); }); }, 0); Promise.resolve().then(() => { console.log('promise_outer'); setTimeout(() => { console.log('setTimeout in promise_outer'); }, 0); }); console.log('main'); 复制代码
三、Node 中的事件循环
事件循环机制解析
当 Node.js 启动后,它会初始化事件循环,处理已提供的输入脚本,它可能会调用一些异步的 API、调度定时器,或者调用 process.nextTick()
,然后开始处理事件循环。
每一个 Event Loop 都会包含如下顺序的六个阶段,它和浏览器的事件循环是完全不同的。
六个阶段
- timers (定时器):本阶段执行已经被
setTimeout()
和setInterval()
的调度回调函数。 - pending callbacks (待定回调):执行延迟到下一个循环迭代的 I/O 回调。
- idle, prepare:仅系统内部使用。
- poll (轮询):检索新的 I/O 事件;执行与 I/O 相关的回调(几乎所有情况下,除了关闭的回调函数,那些由计时器和
setImmediate()
调度的之外),其余情况 node 将在适当的时候在此阻塞。 - check (检测):
setImmediate()
回调函数在这里执行。 - close callback (关闭的回调函数):一些关闭的回调函数,如:
socket.on('close', ...)
。
其中 poll (轮询) 阶段有两个重要的功能:
- 计算应该阻塞和轮询 I/O 的时间。
- 然后,处理 轮询 队列里的事件。
当事件循环进入 轮询 阶段且 没有被调度的计时器时
,将发生以下 两种情况之一:
- 如果 轮询 队列 不是空的 ,事件循环将循环访问回调队列并同步执行它们,直到队列已用尽,或者达到了与系统相关的硬性限制。
- 如果 轮询 队列 是空的 ,还有两件事发生:
- 如果脚本被
setImmediate()
调度,则事件循环将结束 轮询 阶段,并继续 检查 阶段以执行那些被调度的脚本。 - 如果脚本 未被
setImmediate()
调度,则事件循环将等待回调被添加到队列中,然后立即执行。
一旦 poll(轮询) 队列为空,事件循环将检查 已达到时间阈值的计时器
。如果一个或多个计时器已准备就绪,则事件循环将绕回timers(计时器) 阶段
以执行这些计时器的回调。
setImmediate()
对比 setTimeout()
setImmediate()
和 setTimeout()
很类似,但它们被调用的时机不同。
setImmediate()
设计为一旦在当前 轮询 阶段完成, 就执行脚本.setTimeout()
在最小阈值(ms 单位)过后运行脚本.setImmediate()
相对于setTimeout()
优势在于,如果setImmediate()
是在I/O 周期内
被调度的,那它将会在其中任何的定时器之前执行
,跟存在多少个定时器无关.
执行计时器的顺序将根据调用它们的上下文而异
如果二者都从主模块内调用,则计时器将受进程性能的约束(这可能会受到计算机上其他正在运行应用程序的影响)。
例如,如果运行以下 不在 I/O 周期(即主模块)内的脚本 ,则执行两个计时器的顺序是 非确定性 的,因为它受进程性能的约束,或者说受 event loop 启动时间的影响:
// timeout_vs_immediate.js setTimeout(() => { console.log('timeout'); }, 0); setImmediate(() => { console.log('immediate'); }); 输出的结果可能为:timeout immediate 或者 immediate timeout 复制代码
下面给出了存在上述输出结果的两种情况 图解 :
如果把这两个函数放入一个 I/O 循环 内调用,setImmediate 总是被 优先调用:
// timeout_vs_immediate.js const fs = require('fs'); fs.readFile(__filename, () => { setTimeout(() => { console.log('timeout'); }, 0); setImmediate(() => { console.log('immediate'); }); }); 输出结果恒为:immediate timeout 复制代码
同一个 I/O 循环中更有确定性,因此,你也可以自己尝试画流程图的方式去理解它.
process.nextTick()
process.nextTick()
是异步 API 的一部分,它并不是事件循环的一部分.process.nextTick()
始终在当前操作完成后处理nextTickQueue
,而不管事件循环的当前阶段如何.- 任何时候在给定的阶段中调用的
process.nextTick()
,不在六个阶段中的任一阶段内执行,而是在一个阶段切换到下一个阶段之前执行
.
process.nextTick()
对比 setImmediate()
process.nextTick()
在同一个阶段立即执行.setImmediate()
在事件循环的接下来的迭代或 'tick' 上触发.process.nextTick()
比setImmediate()
触发得更快. 下面通过具体的例子,来验证三者的关系:
const fs = require('fs'); const path = require('path'); fs.readFile(path.resolve(__dirname, '/index.html'), () => { setTimeout(() => { console.log('setTimeout'); process.nextTick(() => { console.log('nextTick in setTimeout'); }); }, 1); setImmediate(() => { console.log('setImmediate'); process.nextTick(() => { console.log('nextTick in setImmediate'); }); }); process.nextTick(() => { console.log('nextTick outer'); }); }); 复制代码
下面给出流程分析图解:
结束语
事件循环的相关内容相对来说还是迂回环绕的,并不是任何一篇文章就能够了解透彻,里面的很多执行流程,最好自己画图去辅助理解,不要凭空想象.