事件循环
js 语言是单线程的,为了协调事件、脚本、用户交互、UI 渲染和网络请求(events, scripts, user interaction, rendering, networking)等行为,防止主线程阻塞,js 引入了事件循环这个概念。
Event Loop
有多种类型,按线程来分的话,可以分为 window
event loop 和 worker
event loop。每个 JavaScript 线程都有一个独立的 Event Loop,所以不同的 Worker 有不同的 Event Loop,它们都是独立运行的。
按照运行环境来分的话,Event Loop
大致可以分为浏览器环境中的 event loop
和 node.js 环境中 event loop
。它们对event loop
的实现方式不同,并不一定遵循 WHATWG
标准,相对来说,浏览器环境中的 event loop
更加符合标准。
一些基本概念:
- 执行上下文:JS 引擎在执行全局 JS 代码,函数或者
eval
语句时,会生成一个执行上下文对象,它里面有变量对象(VO
)、作用域链(scope
)、this
等属性; - 函数调用栈:调用函数时,JS 引擎会将函数的执行上下文
push
到函数调用栈中,等它执行完毕就会pop
出栈;
浏览器环境
首先看标准——WHATWG HTML
标准:
An event loop has one or more task queues. A task queue is a set of tasks.
一个 event loop
中有一个或多个任务队列。task queue
是 task
的集合。
A source: One of the task sources, used to group and serialize related tasks
task source
任务源对任务进行分组和序列化。
Per its source field, each task is defined as coming from a specific task source. For each event loop, every task source must be associated with a specific task queue.
每个 task
都来自于一个特殊的 task source
。对于每个事件循环,每个任务源都与一个特殊的任务队列相关联。
主要的 task source
有:
- tasks: script(整体代码), setTimeout, setInterval, setImmediate(ie, node.js), I/O, MessageChannel;
- microtask: Promise, MutationObserve, process.nextTick(node.js)
这些 task source
可以分发 task
或者 microtask
,比如Promise
的三个原型方法 then
,catch
,finally
的回调函数就是真正的microtask
。
继续看标准:
Each event loop has a currently running task, which is either a task or null. Initially, this is null. It is used to handle reentrancy.
每个事件循环都有一个正在执行的 task
,不过也可以为 null
。
Each event loop has a microtask queue, which is a queue of microtasks, initially empty. A microtask is a colloquial way of referring to a task that was created via the queue a microtask algorithm.
每个事件循环都有 一个 微任务队列,注意是一个,不是多个。当然这只是 标准
,不是 实现
,node.js
环境中就不止一个microtask queue
。
更多内容请直接阅读标准 —— WHATWG HTML
标准。
事件循环的流程:
在浏览器环境中:
- 首次事件循环,执行脚本整体代码,将全局上下文压入
call stack
中执行。执行过程中遇到的同步任务直接压入call stack
执行,遇到的异步任务则由浏览器后台其他线程处理,待异步任务满足条件,就放入microtask queue
中。 - 等此次事件循环的
task
执行完毕(即call stack
只剩全局上下文),则开始执行microtask queue
中的所有微任务。一般来说,这些microtask
按照microtask queue
中的顺序执行。一旦轮到某个microtask
,就将其执行上下文压入call stack
中执行,执行过程中遇到同步和异步任务时,处理方式同第一步。待call stack
中的栈帧清空,就表示这个microtask
执行完毕,开始执行microtask queue
中的下一个microtask
。 - 执行完所有的微任务后,浏览器会判断是否需要进行
UI
渲染。如需要,则渲染;不需要,则进入下一次事件循环。 - 第二次事件循环,取出某个
task queue
中的队首task
,压入call stack
中执行,……
Promise.resolve(2).then(v => {console.log('Promise1')
Promise.resolve(2).then(v => console.log('Promise1 触发的 Promise2'))
})
setTimeout(_ => console.log('setTimeout'))
输出为:Promise1
=> Promise1 触发的 Promise2
=> setTimeout
。
这是因为在第一次事件循环中,执行 Promise1
时触发了 Promise2
,Promise2
被加入了 microtask queue
中,其后会在此次事件循环就执行掉。
node.js 环境
参考链接:
- 剖析 nodejs 的事件循环
- Node.js 事件循环,定时器和 process.nextTick
在 node.js 中每次事件循环都包含 6 个阶段:
┌───────────────────────────┐
┌─>│ `timers` │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ `pending callbacks` │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ `idle`, `prepare` │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ `poll` │<─────┤ `connections`,│
│ └─────────────┬─────────────┘ │ `data`, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ `check` │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ `close callbacks` │
└───────────────────────────┘
每个阶段都有一个 FIFO
队列来存储待执行的回调。
通常来说,当事件循环进入某一阶段时,它将执行该阶段的一些特定操作,然后执行该阶段队列中的回调,直到队列用尽,或者达到当前阶段回调可调用的最大次数。然后会执行所有的 process.nextTick
回调,再然后执行所有的 microtask
,最终事件循环进入下一阶段。node.js
中task
和 microtask
的任务源:
- task:
timer
(setTimeout
、setInterval
),setImmediate
,I/O
… - microtask:
process.nextTick
,promise
…
node.js
实现了 libuv
(用来实现 Node.js 事件循环和平台所有异步行为的 C
函数库),而 node.js
事件循环机制就是在它里面实现的,事件循环核心代码 (C
语言):
int uv_run(uv_loop_t* loop, uv_run_mode mode) {
int timeout;
int r;
int ran_pending;
r = uv__loop_alive(loop); // 事件循环是否存活
if (!r)
uv__update_time(loop); // 更新 loop->time 为当前时间
// 如果事件循环存活,并且事件循环没有停止
while (r != 0 && loop->stop_flag == 0) {uv__update_time(loop);
// timers 阶段
uv__run_timers(loop);
// pending callbacks 阶段
ran_pending = uv__run_pending(loop);
// idle 阶段
uv__run_idle(loop);
// prepare 阶段
uv__run_prepare(loop);
timeout = 0;
if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
// 计算当前时间和最近的 timer 的超时时间之间的时间差 timeout
timeout = uv_backend_timeout(loop);
// poll 阶段,该阶段轮询 I/O 事件,有则执行,无则阻塞,直到时间超过 timeout
uv__io_poll(loop, timeout);
// check 阶段
uv__run_check(loop);
// close callbacks 阶段
uv__run_closing_handles(loop);
if (mode == UV_RUN_ONCE) {uv__update_time(loop);
uv__run_timers(loop);
}
r = uv__loop_alive(loop);
// 如果事件循环目前没有等待任何异步 I/O 或计时器,退出循环
if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
break;
}
if (loop->stop_flag != 0)
loop->stop_flag = 0;
return r;
}
timers 阶段
此阶段执行已达到等待时间的 timer
(setTimeout
、setInterval
)的回调函数。uv__run_timers
的执行时间由 poll
阶段控制。
void uv__run_timers(uv_loop_t* loop) {
struct heap_node* heap_node;
uv_timer_t* handle;
for (;;) {
// 取出 timer_heap 中 ` 超时时间最近 ` 的定时器 heap_node
heap_node = heap_min((struct heap*) &loop->timer_heap);
if (heap_node == NULL)
break;
// 获取 heap_node 的句柄
handle = container_of(heap_node, uv_timer_t, heap_node);
// 判断最近的定时器的句柄 handle 的超时时间是否大于当前时间,如果大于当前时间,说明还未超时,跳出循环
if (handle->timeout > loop->time)
break;
// 停止定时器
uv_timer_stop(handle);
// 如果 handle->repeat 为 true,重启定时器
uv_timer_again(handle);
// 执行定时器的回调函数
handle->timer_cb(handle);
}
}
循环取出 &loop->timer_heap
中的定时器,执行它的回调函数,直到当前定时器为 NULL
或者没有超时为止。从这可以初步看出,node.js
环境在事件循环某一阶段,会一次性执行完对应的任务队列中所有满足条件的task
。
至于 process.nextTick
为何没有出现在流程图中,node.js 文档给出的解释是:
You may have noticed that process.nextTick() was not displayed in the diagram, even though it’s a part of the asynchronous API. This is because process.nextTick()
is not technically part of the event loop. Instead, the nextTickQueue will be processed after the current operation is completed, regardless of the current phase of
the event loop. Here, an operation is defined as a transition from the underlying C/C++ handler, and handling the JavaScript that needs to be executed.
大意是:process.nextTick
在技术上不是事件循环的一部分,但是在事件循环的每个阶段中,等当前操作完成后就会执行 nextTickQueue
中的任务。具体原因得看 node.js 是怎么实现 process.nextTick
的。
至于更后面的 microtask
(一般是promise
回调),它们会在每个阶段的最后执行。promise
回调的具体执行时间得看 import 的 Promise
是怎么实现的,在 node\deps\npm\node_modules\es6-promise
的asap.js
中,有多种调用回调的方式:
//1、**node**
function useNextTick() {
// node version 0.10.x displays a deprecation warning when nextTick is used recursively
// see https://github.com/cujojs/when/issues/410 for details
return () => process.nextTick(flush);
}
//2、vertx
function useVertxTimer() {if (typeof vertxNext !== 'undefined') {return function() {vertxNext(flush);
};
}
return useSetTimeout();}
//3、浏览器
function useMutationObserver() {
let iterations = 0;
const observer = new BrowserMutationObserver(flush);
const node = document.createTextNode('');
observer.observe(node, { characterData: true});
return () => {node.data = (iterations = ++iterations % 2);
};
}
大部分是用 microtask
来实现 Promise
的,后面还有一些用 setTimeout
等task
来实现的。
我们主要关注 node.js
环境,如果用户是用 es6-promise
提供的 Promise
对象的话,该对象绑定的回调函数最终会在 process.nextTick
的回调中被调用,所以,promise
回调也是在事件循环每个阶段的末尾执行的。
pending callbacks 阶段
此阶段执行 pending_queue
中的 I/O
回调函数(上个循环未执行完,并被延迟到这个循环的 I/O
回调)。
- 非 I /O:定时器 (
setTimeout
,setInterval
),microtask
,process.nextTick
,setImmediate
… - I/O:网络 I /O,文件 I /O,一些 DNS 操作 …
之后的 idle
, prepare
阶段仅在系统内部使用,不用了解。
poll 阶段
node.js 会将非阻塞 I/O
操作转移到系统内核中去,一旦操作完成,内核通过事件通知 Node.js 将适合的回调函数添加到 poll queue
中等待时机执行。
在 uv_run
函数中,调用 uv__io_poll(loop, timeout)
进入 poll
阶段时,传入了 timeout
参数,它是当前时间距离最近定时器阈值的时间,也是 poll
阶段的阻塞时间。
在内核监听到事件通知 node
时,如果时间达到 timeout
,则直接退出poll
阶段。poll
阶段执行的操作:
- 如果
poll queue
不为空,循环执行回调队列中的回调函数,直到队列用尽,或者达到了最大调用数。 -
如果
poll queue
是空的:- 如果
setImmediate task
已经加入队列,则事件循环将结束poll
阶段,进入check
阶段。 - 如果
setImmediate task
尚未加入队列,则事件循环将等待I/O
回调被添加到poll queue
中,然后立即执行。
- 如果
check 阶段
此阶段执行 setImmediate
的回调函数。setImmediate
实际上是在事件循环的特定阶段运行的特殊计时器,它的回调在 poll
阶段完成后执行。
close callbacks 阶段
此阶段执行 socket close
事件的句柄函数。如果 socket
或handle
突然关闭(例如 socket.destroy()),则 close
事件将在这个阶段发出,否则它将通过 process.nextTick()
发出。
Vue.js 中的 nextTick
不同版本的 Vue.js
中的 nextTick
的实现方式不尽相同,在 Vue 2.5+
后,单独有一个 JS
文件来实现nextTick
,我们直接看目前的最新稳定版本:v2.6.10
。
import {noop} from 'shared/util'
import {handleError} from './error'
import {isIE, isIOS, isNative} from './env'
export let isUsingMicroTask = false
const callbacks = []
let pending = false
function flushCallbacks () {
pending = false
const copies = callbacks.slice(0)
callbacks.length = 0
for (let i = 0; i < copies.length; i++) {copies[i]()}
}
// Here we have async deferring wrappers using microtasks.
// In 2.5 we used (macro) tasks (in combination with microtasks).
// However, it has subtle problems when state is changed right before repaint
// (e.g. #6813, out-in transitions).
// Also, using (macro) tasks in event handler would cause some weird behaviors
// that cannot be circumvented (e.g. #7109, #7153, #7546, #7834, #8109).
// So we now use microtasks everywhere, again.
// A major drawback of this tradeoff is that there are some scenarios
// where microtasks have too high a priority and fire in between supposedly
// sequential events (e.g. #4521, #6690, which have workarounds)
// or even between bubbling of the same event (#6566).
let timerFunc
// The nextTick behavior leverages the microtask queue, which can be accessed
// via either native Promise.then or MutationObserver.
// MutationObserver has wider support, however it is seriously bugged in
// UIWebView in iOS >= 9.3.3 when triggered in touch event handlers. It
// completely stops working after triggering a few times... so, if native
// Promise is available, we will use it:
/* istanbul ignore next, $flow-disable-line */
if (typeof Promise !== 'undefined' && isNative(Promise)) {const p = Promise.resolve()
timerFunc = () => {p.then(flushCallbacks)
// In problematic UIWebViews, Promise.then doesn't completely break, but
// it can get stuck in a weird state where callbacks are pushed into the
// microtask queue but the queue isn't being flushed, until the browser
// needs to do some other work, e.g. handle a timer. Therefore we can
// "force" the microtask queue to be flushed by adding an empty timer.
if (isIOS) setTimeout(noop)
}
isUsingMicroTask = true
} else if (!isIE && typeof MutationObserver !== 'undefined' && (isNative(MutationObserver) ||
// PhantomJS and iOS 7.x
MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
// Use MutationObserver where native Promise is not available,
// e.g. PhantomJS, iOS7, Android 4.4
// (#6466 MutationObserver is unreliable in IE11)
let counter = 1
const observer = new MutationObserver(flushCallbacks)
const textNode = document.createTextNode(String(counter))
observer.observe(textNode, {characterData: true})
timerFunc = () => {counter = (counter + 1) % 2
textNode.data = String(counter)
}
isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
// Fallback to setImmediate.
// Techinically it leverages the (macro) task queue,
// but it is still a better choice than setTimeout.
timerFunc = () => {setImmediate(flushCallbacks)
}
} else {
// Fallback to setTimeout.
timerFunc = () => {setTimeout(flushCallbacks, 0)
}
}
export function nextTick (cb?: Function, ctx?: Object) {
let _resolve
callbacks.push(() => {if (cb) {
try {cb.call(ctx)
} catch (e) {handleError(e, ctx, 'nextTick')
}
} else if (_resolve) {_resolve(ctx)
}
})
if (!pending) {
pending = true
timerFunc()}
// $flow-disable-line
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {_resolve = resolve})
}
}
- 简单来说,首先,Vue 用一个
callbacks
数组存放待执行的callback
函数,每当使用Vue.nextTick
或者vm.$nextTick
时,就会将callback
push
到callbacks
数组中。 - 接下来,
Vue
声明了一个flushCallbacks
函数,这个函数会取出(清空)callbacks
数组中所有的callback
函数并执行。 - 然后
Vue
会尝试把flushCallbacks
变成一个microtask
或者task
来执行。具体是microtask
还是task
得看Vue
当前运行在什么环境:
大致判断流程如下:
- 当前环境有提供原生的
Promise
? Promise.resolve().then(flushCallbacks) : - 是 ie 环境 ? setImmediate(flushCallbacks) :
- 有提供原生的 MutationObserver ? new MutationObserver(flushCallbacks) :
- setTimeout(flushCallbacks, 0);