事件循环(Event Loop)
概念
任务队列
事件循环是通过 任务队列
的机制来进行协调的。一个 Event Loop
中,可以有一个或者多个任务队列(task queue)
,一个任务队列便是一系列有序任务(task
)的集合;每个任务都有一个任务源(task source
),源自同一个任务源的 task
必须放到同一个任务队列,从不同源来的则被添加到不同队列。
JavaScript
单线程中的任务分为 同步任务
和 异步任务
。同步任务会在 调用栈\执行栈
中按照顺序排队等待主线程执行,异步任务则会在异步有了结果后将注册的回调函数添加到 任务队列(消息队列)
中等待主线程空闲的时候,也就是栈内被清空的时候,被读取到栈中等待主线程执行。任务队列
是 先进先出
的数据结构。
异步任务队列可分为 task(macrotask)
宏任务 和 microtask(job)
微任务 两类,不同的 API
注册的异步任务会依次进入自身对应的队列中,然后等待 Event Loop
将它们依次压入执行栈中执行,宏任务队列可以有多个,微任务队列只有一个。
microtask
主要包含:Promise.then
、MutaionObserver
、process.nextTick(Node.js 环境)
、Object.observe(已废弃)
查阅(macro)task
主要包含:script(整体代码)
、setTimeout
、setInterval
、I/O
、UI交互事件
、postMessage
、MessageChannel
、setImmediate(Node.js 环境)
事件循环(Event Loop)
javascript
从诞生之日起就是一门单线程的非阻塞的脚本语言。而非阻塞则是当代码需要进行一项异步任务(无法立刻返回结果,需要花一定时间才能返回的任务,如 I/O
事件)的时候,主线程会挂起(pending
)这个任务,然后在异步任务返回结果的时候再根据一定规则去执行相应的回调。到底是如何实现非阻塞这一点呢?答案就是 event loop(事件循环)
。
call stack 调用栈(执行栈)
是一种 后进先出
的数据结构。所有的同步任务都会被放到 调用栈
等待 主线程
执行。当函数被调用时,会被添加到栈中的顶部,执行完成之后就从栈顶部移出该函数,直到栈内被清空。每次栈内被清空,都会去读取 任务队列
有没有任务,有就按照顺序读取执行,这个时候栈中又出现了事件,这个事件又去调用了 WebAPIs
里的异步方法,那这些异步方法会在再被调用的时候放在队列里,一直循环读取-执行的操作,就形成了 事件循环
。
浏览器中的事件循环
总体图
流程
在事件循环中,每进行一次循环操作称为 tick
,每一次 tick
的任务处理模型是比较复杂的,但关键步骤如下:
- 在此次
tick
中选择最先进入队列的任务(oldest task
),如果有则执行(一个),如果执行中有异步任务就放至各自的队列中 - 检查是否存在
Microtasks
,如果存在则不停地执行,直至清空Microtasks Queue
- 更新
render(update rendering)
- 取出下一个宏任务
task
,主线程重复执行上述步骤
注意点
await
将直接使用Promise.resolve()
相同语义查阅,即:
1 | async function async1() { |
Promise.resolve
方法允许调用时不带参数,直接返回一个resolved
状态的Promise
对象。立即resolved
的Promise
对象,是在本轮“事件循环”(event loop
)的结束时,而不是在下一轮 “事件循环” 的开始时。Promises/A+规范
:实践中要确保onFulfilled
和onRejected
方法异步执行,且应该在then
方法被调用的那一轮事件循环之后的新执行栈中执行。update rendering(视图渲染)
发生在本轮事件循环的microtask
队列被执行完之后,也就是说执行任务的耗时会影响视图渲染的时机。通常浏览器以每秒 60 帧(60fps
)的速率刷新页面,这个帧率最适合人眼交互,大概16.7ms
渲染一帧,所以如果要让用户觉得顺畅,单个macrotask
及它相关的所有microtask
最好能在16.7ms
内完成。- 也不是每轮事件循环都会执行
update rendering
,浏览器有自己的优化策略,可能把几次的视图更新累积到一起重绘.重绘之前会通知requestAnimationFrame()
执行回调函数. requestAnimationFrame
回调的执行时机是在一次或多次事件循环的UI render
阶段。查阅 1,查阅 2
life of a frame
浏览器页面是一帧一帧绘制出来的,每一帧(Frame
)都需要完成哪些工作?
- Input event:处理用户的交互,如点击、触碰、滚动等事件
- JS:
JS
解析执行(可能有多个事件循环) - Begin frame:帧开始。窗口尺寸变更,页面滚动等的处理
- rAf:
requestAnimationFrame
- Layout:布局
- Paint: 绘制
上面六个步骤完成后没超过 16 ms,说明时间有富余,此时就会执行 requestIdleCallback
里注册的任务。
requestAnimationFrame: 告诉浏览器在下次重绘之前执行传入的回调函数(通常是操纵
dom
,更新动画的函数);由于是每帧执行一次,那结果就是每秒的执行次数与浏览器屏幕刷新次数一样,通常是每秒 60 次。用于操作
DOM
,动画。requestIdleCallback: 会在浏览器空闲时间执行回调,也就是允许开发人员在主事件循环中执行低优先级任务,而不影响一些延迟关键事件。如果有多个回调,会按照先进先出原则执行,但是当传入了
timeout
,为了避免超时,有可能会打乱这个顺序。用于低优先级任务;因为它发生在一帧的最后,此时页面布局已经完成,所以不建议在
requestIdleCallback
里再操作DOM
,这样会导致页面再次重绘。Promise
不建议在这里面进行,因为Promise
的回调属性Event loop
中优先级较高的一种微任务,会在requestIdleCallback
结束时立即执行,不管此时是否还有富余的时间,这样有很大可能会让一帧超过 16 ms。
1 | // 一个sleep函数,模拟阻塞 |
举例
例 1
解析查阅
1 | Promise.resolve().then(function promise1() { |
例 2
解析查阅
1 | new Promise((resolve) => { |
例 3
解析查阅
1 | new Promise((resolve) => { |
例 4
1 | async function async1() { |
例 5
1 | console.log('start'); |
例 6
解析查阅
1 | <script> |
示意图
NODE 中的事件循环(适用于 NODE 11 以下)
Node.js
采用 V8
作为 js
的解析引擎,而 I/O
处理方面使用了自己设计的 libuv
,libuv
是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的 API
,事件循环机制
也是它里面的实现。
事件循环模型
1 | ┌───────────────────────┐ |
timers
: 执行定时器队列中的回调
如setTimeout()
和setInterval()
。I/O callbacks
: 执行一些系统调用错误
,比如网络通信的错误回调。idle, prepare
: 仅node
内部使用。poll
: 等待新的I/O
事件,node
在一些特殊情况下会阻塞在这里。check
: 执行setImmediate()的回调
。close callbacks
: 执行socket 的 close 事件回调
,例如socket.on('close', ...)
这种。
timer
timers
阶段会执行 setTimeout
和 setInterval
回调,并且是由 poll
阶段控制的。 同样,在 Node
中定时器指定的时间也不是准确时间,只能是尽快执行。
poll
poll
是一个至关重要的阶段,这一阶段中,系统会做两件事情
- 回到
timer
阶段执行回调 - 执行
I/O
回调- 如果
poll
队列不为空,会遍历回调队列并同步执行,直到队列为空或者达到系统限制 - 如果
poll
队列为空时- 如果有
setImmediate
回调需要执行,poll
阶段会停止并且进入到check
阶段执行回调 - 如果没有
setImmediate
回调需要执行,会等待回调被加入到队列中并立即执行回调,这里同样会有个超时时间设置防止一直等待下去
- 如果有
- 如果
当然设定了 timer
的话且 poll
队列为空,则会判断是否有 timer
超时,如果有的话会回到 timer
阶段执行回调。
check
setImmediate()
的回调会被加入 check
队列中
node 事件循环流程
外部输入数据
–>轮询阶段(poll)
–>检查阶段(check)
–>关闭事件回调阶段(close callback)
–>定时器检测阶段(timer)
–>I/O 事件回调阶段(I/O callbacks)
–>闲置阶段(idle, prepare)
–>轮询阶段
…
event loop
的每个阶段都有一个任务队列。- 当
event loop
到达某个阶段时,将执行该阶段的任务队列,直到队列清空或执行的回调达到系统上限后,才会转入下一个阶段。 - 执行完
nextTick队列
里面的内容。 - 执行完
微任务队列
的内容。 - 当所有阶段被顺序执行一次后,称
event loop
完成了一个tick
。
node 注意点
process.nextTick()
: 这个函数其实是独立于Event Loop
之外的,它有一个自己的队列,当每个阶段
完成后,如果存在nextTick 队列
,就会清空队列中的所有回调函数,并且优先于其他 microtask 执行
。- 如果在
timers
阶段执行时创建了setImmediate
则会在此轮循环的check
阶段执行,如果在timers
阶段创建了setTimeout
,由于timers
已取出完毕,则会进入下轮循环,check
阶段创建timers
任务同理。 setTimeout
优先级比setImmediate
高,但是由于setTimeout(fn,0)
的真正延迟不可能完全为 0 秒,可能出现先创建的setTimeout(fn,0)
而比setImmediate
的回调后执行的情况。
node 举例
node 例 1
解析查阅
1 | const fs = require('fs'); |
node 例 2
解析查阅
1 | console.log('start'); |
node 例 3
解析查阅
1 | setTimeout(() => { |
node 例 4
解析查阅
1 | function sleep(time) { |
node 示意图
node 11 版本后
和浏览器趋同,都是每执行一个宏任务就执行完微任务队列。查阅 1,查阅 2
两者循环区别(NODE 11 之前)
- 浏览器环境下,
microtask
的任务队列是每个macrotask
执行完之后执行。 - 在
Node.js
中,microtask
会在事件循环的各个阶段
之间执行,也就是一个阶段里所有的macrotask
执行完毕,然后执行nextTick
队列,才会去执行microtask
队列的任务。