事件循环(Event Loop)

概念

进程和线程

  • 进程是系统分配的独立资源,是 CPU 资源分配的基本单位,进程是由一个或者多个线程组成的。
  • 线程是进程的执行流,是 CPU 调度和分派的基本单位,同个进程之中的多个线程之间是共享该进程的资源的。

浏览器是多进程的

  • browser 进程
    • 主进程,负责协调、控制其他进程
    • 负责浏览器界面显示,用户交互(前进、后退等),网络资源下载
    • 负责将 渲染进程 得到的在内存中的 bitmap 绘制到用户界面上
  • 第三方插件进程
    • 每个插件对应一个进程,仅当使用该插件时才创建
  • GPU 进程
    • 最多一个,用于 3D 绘制等
  • 浏览器内核渲染/renderer 进程
    • 浏览器每一个 tab 标签都代表一个独立的进程(也不一定,因为多个空白 tab 标签会合并成一个进程)
    • 内部为多线程

多进程的好处可以避免单个 page、单个插件 crash 影响整个浏览器,也充分利用多核优势,提高浏览器稳定性。

浏览器内核(renderer 进程)有多种线程在工作

  • GUI 渲染线程
    • 负责渲染页面,解析 HTMLCSS 构成 DOM 树等,当页面重绘或者由于某种操作引起回流都会调起该线程。
    • JS 引擎线程是互斥的,当 JS 引擎线程在工作的时候,GUI 渲染线程会被挂起,GUI 更新被放入在 JS 任务队列中,等待 JS 引擎线程空闲的时候继续执行。
  • JS 引擎线程(解释器)
    • 单线程工作,负责解析运行 JavaScript 脚本。
    • GUI 渲染线程互斥,JS 运行耗时过长就会导致页面阻塞。
    • 一个浏览器 Tabrenderer 进程)只有一个 js 线程运行。
    • JS 同步任务都在 JS 引擎线程上执行,形成一个执行栈
  • 事件触发线程
    • 该线程归属浏览器,不属于 JS 引擎,用来控制事件循环
    • 事件循环是一个程序结构,用于等待和发送消息和事件。
    • 当事件符合触发条件被触发时,该线程会把对应的事件回调函数添加到任务队列的队尾,等待 JS 引擎处理。
    • 事件触发线程管理一个任务队列/事件队列,异步任务触发条件达成,将回调事件放到任务队列中
  • 定时器触发线程
    • 浏览器定时计数器并不是由 JS 引擎计数的,阻塞会导致计时不准确。
    • 开启定时器触发线程来计时并触发计时,计时完成后会被添加到任务队列中,等待 JS 引擎处理。
  • http 请求线程
    • 每次 http 请求的时候都会新开启一条请求线程
    • 请求完成有结果了之后,将请求的回调函数添加到任务队列中,等待 JS 引擎处理。

browser 进程与 renderer 进程的通信过程

  • Browser 进程收到用户请求,首先需要获取页面内容(譬如通过网络下载资源),随后将该任务通过 RendererHost 接口传递给 Render 进程
  • Renderer 进程的 Renderer 接口收到消息,简单解释后,交给渲染线程,然后开始渲染
  • 渲染线程接收请求,加载网页并渲染网页,这其中可能需要 Browser 进程获取资源和需要 GPU 进程来帮助渲染,当然可能会有 JS 线程操作 DOM(这样可能会造成回流并重绘)
  • 最后 Render 进程将结果传递给 Browser 进程
  • Browser 进程接收到结果并将结果绘制出来

web workers

  • 原理:JS 引擎向浏览器新申请开一个子线程,与子线程通过 postMessage API 通信。子线程完全受主线程控制
  • 作用:后台运行计算,将结果发到主线程,解决单线程的 JS 引擎进行密集型计算会堵塞页面的问题
  • 子线程不能影响用户界面,即不能操作 dom 等,在一个新的全局上下文
  • 除了 webworker(属于 renderer 进程) 还有 SharedWorker(多个标签页、iframe 共享,不属于某个 renderer 进程,自己就是一个进程),Service WorkersChromeWorker
  • 更多查阅 1,查阅 2

任务队列

事件循环是通过 任务队列 的机制来进行协调的。一个 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 内完成。
  • 也不是每轮事件循环都会执行 视图更新,浏览器有自己的优化策略,可能把几次的视图更新累积到一起重绘.重绘之前会通知 requestAnimationFrame() 执行回调函数.
  • requestAnimationFrame 回调的执行时机是在一次或多次事件循环的 UI render 阶段。查阅 1,查阅 2

举例

例 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 执行完毕,才会去执行 microtask 队列的任务。
---- 本文结束,感谢您的阅读 ----