事件循环(Event Loop)
事件循环是JavaScript的执行机制 。
前置知识
js是单线程语言
什么是单线程?
主程序只有一个线程,即同一时间片段内其只能执行单个任务。
单线程意味着什么?
单线程就意味着,所有任务都需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就需要一直等着。这就会导致IO操作(耗时但cpu闲置)时造成性能浪费的问题。
如何解决单线程带来的性能问题?
答案是异步!主线程完全可以不管IO操作,暂时挂起处于等待中的任务,先运行排在后面的任务。等到IO操作返回了结果,再回过头,把挂起的任务继续执行下去。于是,所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)。
同步任务和异步任务
- 同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务。
- 异步任务指的是,不进入主线程、而进入”任务队列“(task queue)的任务,只有”任务队列”通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。
任务队列(Event Queue)
- 宏任务(macrotask):
setTimeout
setInterval
setImmediate
(Node独有)requestAnimationFrame
(浏览器独有)- I/O
- UI rendering (浏览器独有)
- 微任务(microtask)
process.nextTick
(Node独有)- Promise
Object.observe
MutationObserver
setTimeout(fn,0)
指定某个任务在主线程最早可得的空闲时间执行,意思就是不用再等多少秒了,只要主线程执行栈内的同步任务全部执行完成,栈为空就马上执行。 (关于setTimeout
要补充的是,即便主线程为空,0毫秒实际上也是达不到的。根据HTML的标准,最低是4毫秒。)
setInterval(fn,0)
会每隔指定的时间将注册的函数置入Event Queue,如果前面的任务耗时太久,那么同样需要等待。每过ms秒,会有fn进入Event Queue。一旦setInterval
的 回调函数fn执行时间超过了延迟时间ms,那么就完全看不出来有时间间隔了 。
requestAnimationFrame
请求动画帧,是一个宏任务,html5 提供的一个专门用于请求动画的API,相比起setTimeout
由系统决定回调函数的执行时机。60Hz的刷新频率,那么每次刷新的间隔中会执行一次回调函数,不会引起丢帧,不会卡顿。
Promise与process.nextTick(callback)
process.nextTick(callback)
:类似node.js版的”setTimeout
”,在事件循环的下一次循环中调用 callback 回调函数。
不同类型的任务会进入对应的Event Queue,比如setTimeout
和setInterval
会进入相同的Event Queue。
Event Loop是谁规定的?
- Engine(执行引擎): 如V8 Engine,V8 实现并提供了 ECMAScript 标准中的所有数据类型、操作符、对象和方法(注意并没有 DOM)。 Event Loop 是属于 JavaScript Runtime 的,是由宿主环境提供的(比如浏览器,node)。
- Runtime(执行环境): Chrome 提供了 window、DOM,而 Node.js 则是 require、process 等等。
我们知道了Event Loop在不同的宿主环境中的表现可能不同:
- 浏览器的Event Loop是在html5的规范中明确定义。
- NodeJS的Event Loop是基于libuv实现的。可以参考Node的官方文档以及libuv的官方文档。
- libuv已经对Event Loop做出了实现,而HTML5规范中只是定义了浏览器中Event Loop的模型,具体的实现留给了浏览器厂商。
本文只讨论浏览器和Node中事件循环。
JavaScript执行机制
- 同步和异步任务分别进入不同的执行”场所”,同步的进入主线程,异步的进入Event Table并注册函数。
- 当指定的事情完成时,Event Table会将这个函数移入Event Queue。
- 主线程内的任务执行完毕为空,会去Event Queue读取对应的函数,进入主线程执行。
- 上述过程会不断重复,也就是常说的Event Loop(事件循环)。
怎么知道主线程执行栈为空?
js引擎存在monitoring process进程,会持续不断的检查主线程执行栈是否为空,一旦为空,就会去Event Queue那里检查是否有等待被调用的函数。
浏览器事件循环
看一张图,理解了这张图就理解了浏览器的事件循环。如果看不到图,看这里
执行规则
- 首先在执行栈(call stack)中的内容执行完毕清空后,会在事件队列(Event queue)检查一下哪些是宏任务哪些是微任务,然后执行所有的微任务,然后执行一个宏任务,之后再次执行所有的微任务。也就是说在主线程(main thread)任务执行完毕后会把任务队列中的微任务全部执行,然后再执行一个宏任务,这个宏任务执行完再次检查队列内部的微任务,有就全部执行没有就再执行一个宏任务。
- JS是单线程但是浏览器是多线程。你的异步任务是浏览器开启对应的线程来执行的,最后放入JS引擎中进行执行。
- 所以在执行定时器、事件、ajax这些异步事件的时候是另外三个线程在执行代码,并不是JS引擎在做事情,在这些线程达到某一特定事件把任务放入JS引擎的线程中,同时GUI线程(渲染界面HTML的线程)与JS线程是互斥的,在JS引擎执行时GUI线程会被冻结、挂起。
- 在执行微任务队列microtask queue中任务的时候,如果又产生了microtask,那么会继续添加到队列的末尾,也会在这个周期执行,直到microtask queue为空停止。
浏览器主线程常驻线程
- GUI 渲染线程
- 绘制页面,解析 HTML、CSS,构建 DOM 树,布局和绘制等
- 页面重绘和回流
- 与 JS 引擎线程互斥,也就是所谓的 JS 执行阻塞页面更新
- JS 引擎线程
- 负责 JS 脚本代码的执行
- 负责执行准备好待执行的事件,即定时器计数结束,或异步请求成功并正确返回的事件
- 与 GUI 渲染线程互斥,执行时间过长将阻塞页面的渲染
- 事件触发线程
- 负责将准备好的事件交给 JS 引擎线程执行
- 多个事件加入任务队列的时候需要排队等待(JS 的单线程)
- 定时器触发线程
- 负责执行异步的定时器类的事件,如
setTimeout
、setInterval
- 定时器到时间之后把注册的回调加到任务队列的队尾
- 负责执行异步的定时器类的事件,如
- HTTP 请求线程
- 负责执行异步请求
- 主线程执行代码遇到异步请求的时候会把函数交给该线程处理,当监听到状态变更事件,如果有回调函数,该线程会把回调函数加入到任务队列的队尾等待执行
一些补充
async
隐式返回 Promise 作为结果,执行完 await 之后直接跳出async
函数,让出执行的所有权,当前任务的其他代码执行完之后再次获得执行权进行执行。- 立即 resolve 的 Promise 对象,是在本轮”事件循环”的结束时执行,而不是在下一轮”事件循环”的开始时。
- 在一轮Event Loop中多次修改同一DOM,只有最后一次会进行绘制。
- 渲染更新(Update the rendering)会在Event Loop中的tasks和microtasks完成后进行,但并不是每轮Event Loop都会更新渲染,这取决于是否修改了DOM和浏览器觉得是否有必要在此时立即将新状态呈现给用户。如果在一帧的时间内(时间并不确定,因为浏览器每秒的帧数总在波动,16.7ms只是估算并不准确)修改了多处DOM,浏览器可能将变动积攒起来,只进行一次绘制,这是合理的。
- 如果希望在每轮Event Loop都即时呈现变动,可以使用
requestAnimationFrame
。
Node事件循环
Node的事件循环是基于libuv实现,而libuv是 Node 的新跨平台抽象层,libuv使用异步IO事件驱动的编程方式,核心是提供I/O的事件循环和异步回调。libuv的API包含有时间,非阻塞的网络,异步文件操作,子进程等等。
看一张图,如果看不到图,看这里
(1)V8引擎解析JavaScript脚本。
(2)解析后的代码,调用Node API。
(3)libuv库负责Node API的执行。它将不同的任务分配给不同的线程,形成一个Event Loop(事件循环),以异步的方式将任务的执行结果返回给V8引擎。
(4)V8引擎再将结果返回给用户。
当 Node.js 启动后,它会初始化事件循环,处理已提供的输入脚本(或丢入 REPL,本文不涉及到),它可能会调用一些异步的 API、调度定时器,或者调用 process.nextTick()
,然后开始处理事件循环。
下面的图表展示了事件循环操作顺序的简化概览。
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
Node事件循环的6个阶段
- 定时器(timers):本阶段执行已经被
setTimeout()
和setInterval()
的调度回调函数。 - 待定回调(pending callbacks):执行延迟到下一个循环迭代的 I/O 回调。
- idle, prepare:仅系统内部使用。
- 轮询(poll):检索新的 I/O 事件;执行与 I/O 相关的回调(几乎所有情况下,除了关闭的回调函数,那些由计时器和
setImmediate()
调度的之外),其余情况 node 将在适当的时候在此阻塞。 - 检测(check):
setImmediate()
回调函数在这里执行。 - 关闭的回调函数(close callbacks):一些关闭的回调函数,如:
socket.on('close', ...)
。
具体内容可以看Node官方文档
Node事件循环过程
- 执行全局Script的同步代码
- 执行microtask微任务
- 开始执行macrotask宏任务,共6个阶段,从第1个阶段开始执行相应每一个阶段macrotask中的所有任务,注意,这里是所有每个阶段宏任务队列的所有任务,在浏览器的Event Loop中是只取宏队列的第一个任务出来执行,每一个阶段的macrotask任务执行完毕后,开始执行微任务,也就是步骤2
- Timers Queue -> 步骤2 -> I/O Queue -> 步骤2 -> Check Queue -> 步骤2 -> Close Callback Queue -> 步骤2 -> Timers Queue ……
- 这就是Node的Event Loop
浏览器和Node事件循环的差异
- 浏览器环境下,microtask的任务队列是每个macrotask执行完之后执行。
- 而在Node.js中,microtask会在事件循环的各个阶段之间执行,也就是一个阶段执行完毕,就会去执行microtask队列的任务。
Node > 11 ?
观察下面代码
setTimeout(() => {
console.log('timer1')
setImmediate(function () {
console.log('immd 1')
})
Promise.resolve().then(function () {
console.log('promise1')
})
}, 0)
setTimeout(() => {
console.log('timer2')
setImmediate(function () {
console.log('immd 2')
})
Promise.resolve().then(function () {
console.log('promise2')
})
}, 0)
在node11及以上版本打印:
timer1
promise1
timer2
promise2
immd 1
immd 2
在 node 版本为 8.11.2 打印:
timer1
timer2
promise1
promise2
immd 1
immd 2
这是因为在 node11 的版本之后,也是每个 Macrotask 执行完后,就去执行 Microtask 了,和浏览器的模型一致。