setTimeout(() => console.log('setTimeout1'), 0);
setTimeout(() => {
console.log('setTimeout2');
Promise.resolve().then(() => {
console.log('promise2');
Promise.resolve().then(() => {
console.log('promise3');
console.log(5)
setTimeout(() => console.log('setTimeout4'), 0);
}, 0);
setTimeout(() => console.log('setTimeout3'), 0);
Promise.resolve().then(() => {
console.log('promise1');
js是单线程的,EL机制实现异步
轮询发生的前提:所有代码皆在主线程调用栈完成执行
轮询发生的时机:当主线程任务清空后,轮询任务队列中的任务
我们要讨论的是,轮询机制在浏览器和Node中的区别
browsing contexts
EL在HTML规范中的定义
To coordinate events, user interaction, scripts, rendering, networking, and so forth, user agents must use event loops as described in this section. There are two kinds of event loops: those for browsing contexts, and those for workers.
为了协调事件、用户交互、脚本、UI渲染、网络请求等行为,用户引擎必须使用Event Loop。EL包含两类:基于browsing contexts,基于worker。二者独立。
本文讨论的浏览器中的EL基于browsing contexts
浏览器上下文 是一个将document对象呈现给用户的环境
图解Event Loop
同步任务直接进入主执行栈(call stack)中执行
等待主执行栈中任务执行完毕,由EL将异步任务推入主执行栈中执行
一个EL中有 一个或多个 task队列。
来自不同任务源的task会放入不同的task队列中:比如,用户代理会为鼠标键盘事件分配一个task队列,为其他的事件分配另外的队列。
task执行顺序是由进入队列的时间决定的,先进队列的先被执行。
典型的任务源有以下几种(Generic task sources):
DOM操作任务源:响应DOM操作
用户交互任务源:对用户交互作出反应,例如键盘或鼠标输入。响应用户操作的事件(例如click)必须使用task队列
网络任务源:响应网络活动
history traversal任务源:当调用history.back()等类似的api时,将任务插进task队列
task在网上也被成为macrotask
可能是为了和 microtask
做对照。但是规范中并不是这么描述任务的。
除了上述task来源,常见的来源还有 数据库操作、setTimeout/setInterval
等,可以概括为以下几种
script代码
setTimeout/setInterval
setImmediate(nodejs环境中)
Microtask
一个EL中只有一个microtask队列,通常下面几种任务被认为是microtask
promise(promise
的then
和catch
才是microtask,本身其内部的代码并不是)
MutationObserver
process.nextTick(nodejs环境中)
EL循环过程
一个EL只要存在,就会不断执行下边的步骤:
在所有task队列中选择一个最早进队列的task,用户代理可以选择任何task队列,如果没有可选的任务,则跳到6Microtasks步骤
将前一步选择的task设置为 currently running task
Run: 运行被选择的task
运行结束之后,将event loop的 currently running task
置为 null
从task队列里移除前边Run里运行的task
Microtasks: 执行microtasks任务检查点。(也就是执行microtasks队列里的任务)
更新渲染(可能会发生,改部分细致解释可在【拓展】部分看详细解释)
如果这是一个worker event loop,但是task队列中没有任务,并且WorkerGlobalScope对象的closing标识为true,则销毁EL,中止这些步骤,然后 run a worker
返回到第1步
简化一下上面的步骤,可以用下面的伪代码描述EL循环过程:
一个宏任务,所有微任务(,更新渲染),一个宏任务,所有微任务(,更新渲染)......
执行完microtask队列里的任务,有可能会渲染更新。在一帧以内的多次dom变动浏览器不会立即响应,而是会积攒变动以最高60HZ的频率更新视图
while (true) {
宏任务队列.shift()
微任务队列全部任务()
掌握了吗?在浏览器中运行文章开头的代码
运行结果:
promise1
setTimeout1
setTimeout2
promise2
promise3
setTimeout3
setTimeout4
过程分析:
node (version<=10)
Node中的EL由 libuv库 实现,它为 Node.js 提供了跨平台,线程池,事件池,异步 I/O 等能力。
event loop是libuv的核心所在,js是单线程的,会把回调和任务交给libuv
何时来调用回调就是 libuv实现的 event loop 来控制的。
event loop 首先会在内部维持多个事件队列,比如 时间队列、网络队列等等,而libuv会执行一个相当于 while true的无限循环,不断的检查各个事件队列上面是否有需要处理的pending状态事件,如果有则按顺序去触发队列里面保存的事件,同时由于libuv的事件循环每次只会执行一个回调,从而避免了 竞争的发生
个人理解,它与浏览器中的轮询机制(一个task,所有microtasks;一个task,所有microtasks…)最大的不同是,node轮询有phase(阶段)的概念,不同的任务在不同阶段执行,进入下一阶段之前执行process.nextTick() 和 microtasks。(以下概念性描述和例子均是对于<=10的node版本而言,node11在EL的处理上与浏览器趋同,可参考这篇文档 New Changes to the Timers and Microtasks in Node v11.0.0 ( and above)
Node事件轮询中的几个阶段
┌───────────────────────┐
┌─>│ timers │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ I/O callbacks │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │
│ └──────────┬────────────┘ ┌───────────────┐
│ ┌──────────┴────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └──────────┬────────────┘ │ data, etc. │
│ ┌──────────┴────────────┐ └───────────────┘
│ │ check │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└──┤ close callbacks │
└───────────────────────┘
事件循环必须跑完这六个阶段才算一个轮回
每个阶段都有一个回调函数FIFO(先进先出)队列。
EL进入一个阶段会执行里面所有的操作,然后执行回调函数,直到队列消耗尽,或是回调函数执行数量达到最大限制
清理nextTickQueue/microtasks 之后进入下一个阶段
阶段里的执行队列:
Timers Queue setTimeout()
setInterval()
设定的回调函数
I/O Queue 几乎所有的回调,除了timers、close callbacks、check阶段的回调
Check Queue setImmediate()
设定的回调函数
Close Queue 比如 socket.on('close', ...)
timers
在这个阶段检查是否有到达阈值的timer(setTimeout/setInterval),有的话就执行他们的回调
但timer设定的阈值不是执行回调的确切时间(只是最短的间隔时间),node内核调度机制和其他的回调函数会推迟它的执行
由poll阶段来控制什么时候执行timers callbacks
I/O callbacks
处理异步事件的回调,比如网络I/O,比如文件读取I/O。当这些I/O动作都结束的时候,在这个阶段会触发它们的回调。
idle, prepare内部使用,忽略
获取新的I/O事件,node会在适当的情况下阻塞在这里
为防止poll phase 耗尽 event loop,libuv 也有一个最大值(基于系统),会在超过最大值之后停止轮询更多的事件
由于其它各个阶段的操作都有可能导致新的事件发生,并使得内核向poll queue中添加事件,所以在poll阶段处理事件的时候可能还会有新的事件产生,最终,长时间的调用回调函数将会导致定时器过期,所以在poll阶段与定时器会有"合作"
poll阶段主要的两个功能:
处理poll queue的callbacks
回到timers phase执行timers callbacks(当到达timers指定的时间时)
进入poll阶段,timer的设定有下面两种情况:
event loop进入了poll阶段, 未设定timer
poll queue不为空:event loop将同步的执行queue里的callback,直到清空或执行的callback到达系统上限
poll queue为空
如果有设定setImmediate() callback
, event loop将结束poll阶段进入check阶段,并执行check queue (check queue是 setImmediate设定的)
如果代码没有设定setImmediate() callback,event loop将阻塞在该阶段等待callbacks加入poll queue
function someAsyncOperation (callback) {
// 假设用了95ms
fs.readFile('/path/to/file', callback);
var timeoutScheduled = Date.now();
setTimeout(function () {
var delay = Date.now() - timeoutScheduled;
console.log(delay + "ms have passed since I was scheduled");
}, 100);
someAsyncOperation(function () {
var startCallback = Date.now();
while (Date.now() - startCallback < 10) {
});
// log: 105ms have passed since I was scheduled
timers:定时器加入到timers queue中,定时的时间设置为100ms,进入下阶段
I/O callbacks:没有回调队列
poll:执行I/O操作,由于读取文件要耗费95ms的时间,这时它的任务队列为空,poll将会阻塞在这里循环相应的回调函数。大约在95ms时相应的文件读取I/O操作执行完毕,对应的回调函数又耗费了10ms。这时poll queue为空,此时poll会检查有没有到达阈值到期的timer。发现存在一个已经超时近5ms的定时器
timers:回到timers阶段执行回调函数,打log
对于循环开始之前的process.nextTick() microtasks会不会被处理,和小组的同学们有过讨论。在node官方文档中我们看到这样的定义
When Node.js starts, it initializes the event loop, processes the provided input script (or drops into the REPL, which is not covered in this document) which may make async API calls, schedule timers, or call process.nextTick(), then begins processing the event loop.
所以开始循环之前,process.nextTick()/microtasks 是会被先清掉的
循环开始之前
所有同步任务
同步任务中的异步操作发出异步请求
规划好同步任务中的定时器生效时间
执行process.nextTick()
清空当前循环内的 Timers Queue,清空NextTick Queue,清空Microtask Queue
清空当前循环内的 I/O Queue,清空NextTick Queue,清空Microtask Queue
poll情况比较复杂(前面已经分析过了)
清空当前循环内的 Check Queue,清空NextTick Queue,清空Microtask Queue
清空当前循环内的 Close Queue,清空NextTick Queue,清空Microtask Queue
进入下一轮循环
while (true) {
loop.forEach((阶段) => {
阶段全部任务()
nextTick全部任务()
microTask全部任务()
loop = loop.next
优先级:nextTick
> microtask
| setTimeout/setInterval
> setImmediate
setTimeout 和 setImmediate 的区别
setImmediate
一旦当前poll阶段结束(poll queue为空或执行任务到达上限)就执行一次脚本
setTimeout
设定一个最短的调度该脚本的时间阈值
不在同一个I/O cycle中的时候,回调的调度顺序是不被保证的
// timeout_vs_immediate.js
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
// terminal
$ node timeout_vs_immediate.js
timeout
immediate
$ node timeout_vs_immediate.js
immediate
timeout
在同一个I/O cycle中,immediate
总比 timeout
更早被调度
// timeout_vs_immediate.js
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
// terminal
$ node timeout_vs_immediate.js
immediate
timeout
$ node timeout_vs_immediate.js
immediate
timeout
process.nextTick()
process.nextTick()
不是Node的EL中的一部分(虽然它也是异步API),但是,任意阶段的操作结束之后 nextTickQueue
就会被处理。
nextTickQueue & microtasks
日常应用中经常会将 promise、`process.nextTick`、nextTickQueue、microtask 混为一谈,其实真正注册为 microtask 的任务的目前只有 promise。但是问题来了,v8 目前是没有暴露 `runMicrotasks` ,也就是说我们目前还没有办法通过内核的 API 执行 microtask queue 的任务。
Node.js 最终选择的实现方法是将 microtask queue 的任务通过一个 runMicrotasks
对象暴露给上游,然后通过 nextTick 方法把它们推进了 nextTickQueue,也就是说最终 microtask queue 的任务变成了 nextTickQueue 的任务,所以我们用 promise.then
和 process.nextTick
可以实现相同的效果。
process.nextTick() 和 setImmediate()
官方推荐使用 setImmediate()
,因为更容易推理,也兼容更多的环境,例如浏览器环境
process.nextTick()
在当前循环阶段结束之前触发
setImmediate()
在下一个事件循环中的check阶段触发
通过process.nextTick()
触发的回调也会在进入下一阶段前被执行结束,这会允许用户递归调用 process.nextTick()
造成I/O被榨干,使EL不能进入poll阶段
因此node作者推荐我们尽量使用setImmediate,因为它只在check阶段执行,不至于导致其他异步回调无法被执行到
掌握了吗:在浏览器中运行文章开头的代码
运行结果(执行demo的node版本为v8.*)
promise1
setTimeout1
setTimeout2
setTimeout3
promise2
promise3
setTimeout4
过程分析:
web worker
[【转向Javascript系列】深入理解Web Worker](http://www.alloyteam.com/2015/11/deep-in-web-worker/)
[Web Worker浅识](http://note.youdao.com/noteshare?id=eb287f54753e456315c28cc9f1b17741)
简单介绍:web worker是HTML5标准的一部分,将浏览器js线程分为主线程和worker线程,在主线程中,通过 new Worker()创建一个worker实例,参数是一个js文件。主线程和worker之间的通信是通过postMessage/onMessage的形式来做的,彼此发送数据,接受数据
MutationObserver
MutationObserver - Web API 接口 | MDN
javaScript是单线程,也就是说只有一个主线程。
主线程有一个栈,每一个函数执行的时候,都会生成新的execution context(执行上下文),执行上下文会包含一些当前函数的参数、局部变量之类的信息,它会被推入栈中, running execution context(正在执行的上下文)始终处于栈的顶部。当函数执行完后,它的执行上下文会从栈弹出。
简单的例子
function bar() {
console.log('bar');
function foo() {
console.log('foo');
bar();
foo();
执行栈的变化
在上面说到的浏览器的Event Loop 循环过程中,执行完microtask队列里的任务,有可能会渲染更新。这取决于在当下渲染是否“获益”。
更新渲染在HTML的规范中包括了十二个步骤,大致是做5件事情:
1-4. 判断 document 在此时间点渲染是否会『获益』,是否有效。浏览器只需保证 60Hz 的刷新率即可(在机器负荷重时还会降低刷新率),若 eventloop 频率过高,一帧以内的多次dom变动浏览器不会立即响应,即使渲染了浏览器也无法及时展示。所以并不是每轮 eventloop 都会执行 UI Render。
5-9. 执行各种渲染所需工作,如 触发 resize、scroll 事件、建立媒体查询、运行 CSS 动画等等
10. 执行 request animation frame callbacks
11. 执行 IntersectionObserver callback
12. 渲染 UI
名词解释:
requestAnimationFrame是js绘制动画的API,注册callback,浏览器更新渲染时触发animate,animate触发cb
IntersectionObserver API 会注册一个回调方法,当期望监听的元素进入/退出另一个元素/浏览器视窗,或者是两个元素交集的部分大小发生变化,该回调就会被执行
HTML 5.2: 7. Web application APIs
你不得不知的Event Loop
浏览器和Node不同的事件循环(Event Loop)
JavaScript 运行机制详解:再谈Event Loop
Web Worker 是什么鬼?
node的事件机制
The Node.js Event Loop, Timers, and process.nextTick()
NodeJS官方文档中文版之《事件循环, 定时器和process.nextTick()》
从event loop规范探究javaScript异步及浏览器更新渲染时机
你好,『因此node作者推荐我们尽量使用setImmediate,因为它只在check阶段执行,不至于导致其他异步回调无法被执行到』 请问这句话应该如何理解呢? 也就是说,process.nexttick()
会让其他异步的回调无法执行,可以举一些具体的例子嘛?
@dujuncheng
按照实现来说,process.nectTick()
是将回调函数存入数组中,setImmediate
是将回调存在链表上的。
在以前的版本(看朴灵的书的版本),会在一个tick里将process.nectTick()
的回调数组都执行完才会到下一个tick,而setImmediate
会取一个节点来执行,避免占用CPU过多阻塞后续操作。
去查了一下源码,nodejs/node@460ee75#diff-e7ef4821107f4cae3bd0fea4dec350bf
这次修改优化了nextTick,避免阻塞的情况出现。
用新版的话可以不用考虑这么多了吧
你的demo肯定没有自己执行过,或者自己对着代码写一遍,因为你里面没有promise2这个东西,你把promise3和promise4,变成了promise2和promise3.。。。。。
不好意思,很久没关注这个issue,现在回复。
demo书写有误,已更正:promise3/promse4=>promise2/promise3,感谢你的仔细阅读👍