事件循环(Event Loop)

概念

任务队列

事件循环是通过 任务队列 的机制来进行协调的。一个 Event Loop 中,可以有一个或者多个任务队列(task queue),一个任务队列便是一系列有序任务(task)的集合;每个任务都有一个任务源(task source),源自同一个任务源的 task 必须放到同一个任务队列,从不同源来的则被添加到不同队列。

JavaScript 单线程中的任务分为 同步任务异步任务。同步任务会在 调用栈\执行栈 中按照顺序排队等待主线程执行,异步任务则会在异步有了结果后将注册的回调函数添加到 任务队列(消息队列) 中等待主线程空闲的时候,也就是栈内被清空的时候,被读取到栈中等待主线程执行。任务队列先进先出 的数据结构。

异步任务队列可分为 task(macrotask) 宏任务 和 microtask(job) 微任务 两类,不同的 API 注册的异步任务会依次进入自身对应的队列中,然后等待 Event Loop 将它们依次压入执行栈中执行,宏任务队列可以有多个,微任务队列只有一个。

  • microtask 主要包含:Promise.thenMutaionObserverprocess.nextTick(Node.js 环境)Object.observe(已废弃)查阅
  • (macro)task 主要包含:script(整体代码)setTimeoutsetIntervalI/OUI交互事件postMessageMessageChannelsetImmediate(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
2
3
4
5
6
7
8
9
10
async function async1() {
await async2();
console.log('async1 end');
}
//等价于
async function async1() {
Promise.resolve(async2()).then(() => {
console.log('async1 end');
});
}
  • Promise.resolve 方法允许调用时不带参数,直接返回一个 resolved 状态的 Promise 对象。立即 resolvedPromise 对象,是在本轮“事件循环”(event loop)的结束时,而不是在下一轮 “事件循环” 的开始时。
  • Promises/A+规范:实践中要确保 onFulfilledonRejected 方法异步执行,且应该在 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)都需要完成哪些工作?

  1. Input event:处理用户的交互,如点击、触碰、滚动等事件
  2. JSJS 解析执行(可能有多个事件循环)
  3. Begin frame:帧开始。窗口尺寸变更,页面滚动等的处理
  4. rAfrequestAnimationFrame
  5. Layout:布局
  6. Paint: 绘制

上面六个步骤完成后没超过 16 ms,说明时间有富余,此时就会执行 requestIdleCallback 里注册的任务。

  • requestAnimationFrame: 告诉浏览器在下次重绘之前执行传入的回调函数(通常是操纵 dom,更新动画的函数);由于是每帧执行一次,那结果就是每秒的执行次数与浏览器屏幕刷新次数一样,通常是每秒 60 次。

    用于操作 DOM,动画。

  • requestIdleCallback: 会在浏览器空闲时间执行回调,也就是允许开发人员在主事件循环中执行低优先级任务,而不影响一些延迟关键事件。如果有多个回调,会按照先进先出原则执行,但是当传入了 timeout,为了避免超时,有可能会打乱这个顺序。

    用于低优先级任务;因为它发生在一帧的最后,此时页面布局已经完成,所以不建议在 requestIdleCallback 里再操作 DOM,这样会导致页面再次重绘。

    Promise 不建议在这里面进行,因为 Promise 的回调属性 Event loop 中优先级较高的一种微任务,会在 requestIdleCallback 结束时立即执行,不管此时是否还有富余的时间,这样有很大可能会让一帧超过 16 ms。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 一个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
})

举例

例 1

解析查阅

1
2
3
4
5
6
7
8
9
10
11
12
13
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);

例 2

解析查阅

1
2
3
4
5
6
new Promise((resolve) => {
resolve(1);
Promise.resolve().then(() => console.log(2));
console.log(4);
}).then((t) => console.log(t));
console.log(3);

例 3

解析查阅

1
2
3
4
5
6
7
8
9
10
11
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);

例 4

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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');

例 5

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
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');

例 6

解析查阅

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<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 的解析引擎,而 I/O 处理方面使用了自己设计的 libuvlibuv 是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的 API事件循环机制 也是它里面的实现。

事件循环模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
   ┌───────────────────────┐
┌─>│ timers │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ I/O callbacks │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │
│ └──────────┬────────────┘ ┌───────────────┐
│ ┌──────────┴────────────┐ │ incoming: │
│ │ poll │<──connections─── │
│ └──────────┬────────────┘ │ data, etc. │
│ ┌──────────┴────────────┐ └───────────────┘
│ │ check │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└──┤ close callbacks │
└───────────────────────┘
  • timers: 执行 定时器队列中的回调setTimeout()setInterval()
  • I/O callbacks: 执行一些 系统调用错误,比如网络通信的错误回调。
  • idle, prepare: 仅 node 内部使用。
  • poll: 等待新的 I/O 事件,node 在一些特殊情况下会阻塞在这里。
  • check: 执行 setImmediate()的回调
  • close callbacks: 执行 socket 的 close 事件回调,例如 socket.on('close', ...) 这种。

timer

timers 阶段会执行 setTimeoutsetInterval 回调,并且是由 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
2
3
4
5
6
7
8
9
10
11
12
const fs = require('fs');

fs.readFile('test.txt', () => {
console.log('readFile');
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
//readFile,immediate,timeout

node 例 2

解析查阅

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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');
//start=>end=>promise3=>timer1=>timer2=>promise1=>promise2

node 例 3

解析查阅

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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');
});
});
});
});
// nextTick=>nextTick=>nextTick=>nextTick=>timer1=>promise1

node 例 4

解析查阅

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
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);
});

// setTimeout - 1
// 1s over
// setTimeout - 2 //1、2为单阶段task
// 1s over
// setTimeout - 1 - then
// setTimeout - 2 - then
// setTimeout - 1 - then - then
// setTimeout - 2 - then - then
// setTimeout - 1 - 1
// 1s over
// setTimeout - 2 - 1
// 1s over

node 示意图

示意图

node 11 版本后

和浏览器趋同,都是每执行一个宏任务就执行完微任务队列查阅 1,查阅 2

两者循环区别(NODE 11 之前)

  • 浏览器环境下, microtask 的任务队列是每个 macrotask 执行完之后执行。
  • Node.js 中,microtask 会在事件循环的各个阶段之间执行,也就是一个阶段里所有的 macrotask 执行完毕,然后执行 nextTick 队列,才会去执行 microtask 队列的任务。