javascript
从诞生之日起就是一门单线程的非阻塞的脚本语言。而非阻塞则是当代码需要进行一项异步任务(无法立刻返回结果,需要花一定时间才能返回的任务,如 I/O
事件)的时候,主线程会挂起(pending
)这个任务,然后在异步任务返回结果的时候再根据一定规则去执行相应的回调。到底是如何实现非阻塞这一点呢?答案就是 事件循环(Event Loop)
。
当函数被调用时,会被添加到 调用栈
栈中的顶部,执行完成之后就从栈顶部移出该函数,直到栈内被清空。每次栈内被清空都会去读取 任务队列
有没有任务,有就按照顺序读取执行。如果这个时候栈中又出现了事件,该事件又去调用了 WebAPIs
里的异步方法,那这些异步方法会在再被调用的时候放在 任务队列
里,一直循环读取-执行的操作,就形成了 事件循环
。
调用栈\执行栈(call stack)
是一种 后进先出(LIFO)
的数据结构,任务队列
是 先进先出(FIFO)
的数据结构。
任务队列
事件循环是通过 任务队列(task queue)
的机制来进行协调的。一个事件循环中,可以有一个或者多个任务队列,一个任务队列便是一系列有序任务 task
的集合,每个任务都有一个任务源 task source
,源自同一个任务源的 task
必须放到同一个任务队列,从不同源来的则被添加到不同队列。
JavaScript
单线程中的任务分为 同步任务
和 异步任务
。同步任务会在 调用栈
中按照顺序排队等待主线程
执行,异步任务则会在异步有了结果后将注册的回调函数添加到 任务队列(消息队列)
中等待主线程空闲(即栈内被清空)的时候读取到栈中等待主线程执行。
异步任务队列可分为 task(macrotask)
宏任务队列和 microtask(job)
微任务队列两类,不同的 API
注册的异步任务会依次进入自身对应的队列中,然后等待 Event Loop
将它们依次压入执行栈中执行,宏任务队列可以有多个,微任务队列只有一个。
- 宏任务主要包含:
script(整体代码)
、setTimeout
、setInterval
、I/O
、UI交互事件
、postMessage
、MessageChannel
、setImmediate(Node.js 环境)
、requestAnimationFrame
- 微任务主要包含:
Promise的方法及其派生
、MutationObserver(浏览器)
、Object.observe(已废弃)
查阅
浏览器中的事件循环
流程
在事件循环中,每进行一次循环操作称为 tick
,每一次 tick
的任务处理模型是比较复杂的,但关键步骤如下:
- 在此次
tick
中选择最先进入队列的任务(oldest task
),如果有则执行(一个),如果执行中有异步任务就放至各自的队列中 - 检查是否存在
Microtasks
,如果存在则不停地执行,直至清空Microtasks Queue
- 更新
render(update rendering)
(GUI 线程) - 取出下一个宏任务
task
,主线程重复执行上述步骤(回到 JS 线程)
相关点
await
await
将直接使用 Promise.resolve()
相同语义查阅,即:
async function async1() {
await async2();
console.log('async1 end');
}
//等价于
function async1() {
Promise.resolve(async2()).then(() => {
console.log('async1 end');
});
}
//等价于
function async1() {
new Promise((resolve) => {
resolve(async2());
}).then(() => {
console.log('async1 end');
});
}
update rendering(视图渲染)
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 & requestIdleCallback
-
requestAnimationFrame
: 告诉浏览器在下次重绘之前执行传入的回调函数(通常是用于操纵dom
,更新动画的函数);由于是每帧执行一次,那结果就是每秒的执行次数与浏览器屏幕刷新次数一样,通常是每秒 60 次。 -
requestIdleCallback
: 会在浏览器空闲时间执行回调,也就是允许开发人员在主事件循环中执行低优先级任务,而不影响一些延迟关键事件。如果有多个回调,会按照先进先出原则执行;但是当传入了timeout
,为了避免超时,有可能会打乱这个顺序;由于它发生在一帧的最后,此时页面布局已经完成,所以不建议在requestIdleCallback
里再操作DOM
,这样会导致页面再次重绘。
例子:
// 一个sleep函数,模拟阻塞
function sleep(d) {
for (var t = Date.now(); Date.now() - t <= d;);
}
let count = 0;
function callself(){
console.log(++count, 'frame')
sleep(16)
if(count<20){
window.requestAnimationFrame(callself);
}
}
// 当count<20时候,就一直使用raf占满16ms,这样模拟一帧中无空闲时间
window.requestAnimationFrame(callself);
function cb1({didTimeout}){
console.log('idle cb1', didTimeout)
}
function cb2({didTimeout}){
console.log('idle cb2', didTimeout)
}
function cb3({didTimeout}){
console.log('idle cb3', didTimeout)
}
// 注册三个rIC回调,正常是按照先进先出原则执行这三个回调,当设置的有timeout,该回调会被提前
window.requestIdleCallback(cb1)
window.requestIdleCallback(cb2)
window.requestIdleCallback(cb3, {
timeout: 30
})
浏览器举例
通过例子加深对浏览器事件循环执行顺序的理解:
eg1:
Promise.resolve().then(function promise1() {
console.log('promise1');
});
setTimeout(function setTimeout1() {
console.log('setTimeout1');
Promise.resolve().then(function promise2() {
console.log('promise2');
});
}, 0);
setTimeout(function setTimeout2() {
console.log('setTimeout2');
}, 0);
eg2:
解析查阅
new Promise((resolve) => {
resolve(1);
Promise.resolve().then(() => console.log(2));
console.log(4);
}).then((t) => console.log(t));
console.log(3);
eg3:
解析查阅 参照
new Promise((resolve) => {
resolve(1);
Promise.resolve({
then: function (resolve, reject) {
console.log(2);
resolve(3);
},
}).then((t) => console.log(t));
console.log(4);
}).then((t) => console.log(t));
console.log(5);
eg4:
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2');
}
console.log('script start');
setTimeout(function () {
console.log('setTimeout');
}, 0);
async1();
new Promise(function (resolve) {
console.log('promise1');
resolve();
})
.then(function () {
console.log('promise2');
})
.then(function () {
console.log('promise3');
});
console.log('script end');
eg5:
console.log('start');
var intervalA = setInterval(() => {
console.log('intervalA');
}, 0);
setTimeout(() => {
console.log('timeout');
clearInterval(intervalA);
}, 0);
var intervalB = setInterval(() => {
console.log('intervalB');
}, 0);
var intervalC = setInterval(() => {
console.log('intervalC');
}, 0);
new Promise((resolve, reject) => {
console.log('promise');
for (var i = 0; i < 10000; ++i) {
i === 9999 && resolve();
}
console.log('promise after for-loop');
})
.then(() => {
console.log('promise1');
})
.then(() => {
console.log('promise2');
clearInterval(intervalB);
});
new Promise((resolve, reject) => {
setTimeout(() => {
console.log('promise in timeout');
resolve();
});
console.log('promise after timeout');
})
.then(() => {
console.log('promise4');
})
.then(() => {
console.log('promise5');
clearInterval(intervalC);
});
Promise.resolve().then(() => {
console.log('promise3');
});
console.log('end');
eg6:
解析查阅
<script>
console.log('start');
setTimeout(() => {
console.log('timeout1');
}, 0);
Promise.resolve().then(() => {
console.log('promise1');
});
</script>
<script>
setTimeout(() => {
console.log('timeout2');
}, 0);
requestAnimationFrame(() => {
console.log('requestAnimationFrame');
});
Promise.resolve().then(() => {
console.log('promise2');
});
console.log('end');
</script>
<!-- 输出:start promise1 end promise2 requestAnimationFrame timeout1 timeout2 -->
NODE 中的事件循环(适用于 NODE 11 以下)
Node.js
采用 V8
作为 js
的解析引擎,而事件循环方面使用了自己设计的 libuv
。Node.js
的事件循环核心对应 libuv
中的 uv_run
函数,整个事件循环迭代就是一个 while
无限循环。
事件循环模型
┌───────────────────────┐
┌─>│ timers │ 执行到期的 `setTimeout` 和 `setInterval` 回调
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ I/O callbacks │ 执行到期的一些被延迟调用的 `I/O` 回调
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │ 仅 `node` 内部使用
│ └──────────┬────────────┘ ┌───────────────┐
│ ┌──────────┴────────────┐ │ incoming: │
│ │ poll │<──connections─── │ 立即执行大部分 `I/O` 回调
│ └──────────┬────────────┘ │ data, etc. │
│ ┌──────────┴────────────┐ └───────────────┘
│ │ check │ 执行到期的 `setImmediate` 的回调
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└──┤ close callbacks │ 执行注册 `close` 事件的回调,如 `socket` 等
└───────────────────────┘
其中外部输入数据则从 poll
阶段开始。
event loop
总是要经历以上阶段,由timer
阶段开始,由close
回调函数阶段结束。event loop
的每个阶段都有一个任务队列。event loop
到达某个阶段时,将执行该阶段的任务队列,该任务队列完成后执行nextTick队列
,然后执行微任务队列
。直到队列清空或执行的回调达到系统上限后,才会转入下一个阶段。- 当所有阶段被顺序执行一次后,称
event loop
完成了一个tick
。
poll
poll
是一个至关重要的阶段,会做两件事情:
- 计算当前轮询需要阻塞后续阶段(即维持)的时间:由后续 tick 各个阶段是否存在不为空的回调函数队列 和 最近的计时器时间节点 决定。若所有队列为空且不存在任何计时器,那么事件循环将 无限制地维持在 poll 阶段。其中
- 对于事件循环部分属性而言:
uv_stop()
函数标记为停止时,不阻塞;- 不处于活动状态时且不存在活动的
request
时,不阻塞; idle
句柄队列不为空时,不阻塞;I/O callbacks
回调队列不为空时,不阻塞;closing
句柄不为空时,不阻塞;
- 对于计时器而言:
- 若不存在任何计时器(
setTimeout/setInterval
),那么当前事件循环中的poll
阶段将一直阻塞 - 若最近计时器时间节点<=开始时间,则表明在
计时器二叉最小
堆中至少存在一个过期的计时器,那么当前poll
阶段的超时时间将被设置为 0 即不阻塞。这是为了尽可能快的进入下一阶段,即尽可能快地结束当前事件循环。 - 若最近计时器时间节点>开始时间,
poll
将根据此差值来阻塞当前阶段,阻塞是为了保持在该阶段从而尽可能快的处理异步 I/O 事件。
- 若不存在任何计时器(
- 对于事件循环部分属性而言:
- 处理
poll
队列的事件回调(事件循环 tick 总有一种维持 poll 状态的倾向,为了尽可能快的处理随时可能到来异步I/O
事件)
如果 poll
阶段进入 idle
状态并且存在 setImmediate
,那么 poll
阶段将打破无限制的等待状态,并进入 check
阶段执行 setImmediate
。
node 内置定时器
setTimeout(() => {
//一些代码;
}, timeout);
nodejs
中所有计时器是通过一个双向链表
实现关联。有且仅有两种计时器:setTimeout/setInterval
和 setImmediate
。同浏览器一致,所有的计时器实现都不能保证在到达时间阈值后回调函数一定会被立即执行,它们只能保证在到达时间阈值后,尽快执行由计时器注册的回调函数。
所有计时器在 libuv
中是以计时器回调函数的执行时间节点(即 time + timeout
,而不是计时器时间阈值(上述代码里的timeout)
)构成的二叉最小堆
结构来存储。通过二叉最小堆
的根节点来获取时间线上最近的 timer
对应的回调函数的句柄,再通过该句柄对应的 timeout
值获取最近的计时器的执行时间节点。
时间阈值 timeout
的取值范围是 1 ~ 231-1 ms
,且为整数。所有超出时间阈值范围的时间阈值都会被重置为 1ms
,且所有非整数值会被转换为 整数值
。即 setTimeout(callback, 0)
会自动转为 setTimeout(callback, 1)
。
node 注意点
process.nextTick()
: 这个函数其实是独立于Event Loop
之外的,它有一个自己的队列,当每个阶段
完成后,如果存在nextTick 队列
,就会清空队列中的所有回调函数,并且优先于其他 microtask 执行
。setTimeout(callback, 0)
和setImmediate(callback)
的执行顺序是随机的,跟代码执行时间与1ms
大小比较有关。而上述代码在I/0 callbacks
阶段调用则执行顺序是setImmediate
在前,setTimeout
在后(第二轮)。
node 举例
根据以上知识点,以下这些例子就很容易理解了:
eg1:
const fs = require('fs');
fs.readFile('test.txt', () => {
console.log('readFile');
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
eg2:
console.log('start');
setTimeout(() => {
console.log('timer1');
Promise.resolve().then(function () {
console.log('promise1');
});
}, 0);
setTimeout(() => {
console.log('timer2');
Promise.resolve().then(function () {
console.log('promise2');
});
}, 0);
Promise.resolve().then(function () {
console.log('promise3');
});
console.log('end');
eg3:
setTimeout(() => {
console.log('timer1');
Promise.resolve().then(function () {
console.log('promise1');
});
}, 0);
process.nextTick(() => {
console.log('nextTick');
process.nextTick(() => {
console.log('nextTick');
process.nextTick(() => {
console.log('nextTick');
process.nextTick(() => {
console.log('nextTick');
});
});
});
});
eg4:
function sleep(time) {
let startTime = new Date();
while (new Date() - startTime < time) {}
console.log('1s over');
}
setTimeout(() => {
console.log('setTimeout - 1');
setTimeout(() => {
console.log('setTimeout - 1 - 1');
sleep(1000);
});
new Promise((resolve) => resolve()).then(() => {
console.log('setTimeout - 1 - then');
new Promise((resolve) => resolve()).then(() => {
console.log('setTimeout - 1 - then - then');
});
});
sleep(1000);
});
setTimeout(() => {
console.log('setTimeout - 2');
setTimeout(() => {
console.log('setTimeout - 2 - 1');
sleep(1000);
});
new Promise((resolve) => resolve()).then(() => {
console.log('setTimeout - 2 - then');
new Promise((resolve) => resolve()).then(() => {
console.log('setTimeout - 2 - then - then');
});
});
sleep(1000);
});
node 11 版本后
和浏览器趋同,都是每执行一个宏任务就执行完微任务队列,故上面例子在不同版本表现不一致。查阅 1,查阅 2
两者循环区别(NODE 11 之前)
- 在浏览器中,事件循环是由
macrotask、microtask
组成,执行顺序是macrotask->microtask
。 - 在
Node.js
中,事件循环由多个阶段 phase
的多个回调函数队列 callbacks queues
组成。在每一个阶段执行顺序是macrotask->nextTick->microtask
。
其他
- 从 Chrome 源码看事件循环
- 从 libuv 看 nodejs 事件循环
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!