从进程协作到事件循环
如之前『浏览器架构』一章所述,一个 tab 页签需要如下几个进程相互协作:
一、进程分工
- 浏览器进程。
主要负责界⾯显⽰、⽤⼾交互、⼦进程管理,同时提供存储等功能。
- **浏览器渲染进程(浏览器内核)。 **核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,排版引擎 Blink 和 JavaScript 引擎 V8 都是运行在该进程中,默认情况下,Chrome 会为每个 Tab 标签创建一个渲染进程。
- GUI 线程
- 负责渲染浏览器界面,解析 HTML,CSS,构建 DOM 树和 Render 树,布局跟绘制。
- 当我们修改元素的尺寸,页面就会回流(Reflow)
- 当我们修改了一些元素的颜色或者背景色,页面就会重绘(Repaint)
- 事件触发线程
- 主要用于控制事件(例如鼠标,键盘等事件),当该事件被触发时候,事件触发线程就会把该事件的处理函数推进事件队列,等待 JS 引擎线程执行。
- JS 引擎线程(渲染主线程)
- JS 引擎线程就是 JS 内核,负责处理 Javascript 脚本程序(例如 V8 引擎)
- JS 引擎线程负责解析 Javascript 脚本,运行代码
- JS 引擎一直等待着任务队列中任务的到来,然后加以处理
- 浏览器同时只能有一个 JS 引擎线程在运行 JS 程序,所以 js 是单线程运行的
- GUI 渲染线程与 JS 引擎线程是互斥的,js 引擎线程会阻塞 GUI 渲染线程
- 我们常遇到的 JS 执行时间过长,造成页面的渲染不连贯,导致页面渲染加载阻塞(就是加载慢)
- 定时器线程
- 网络请求线程
- GUI 线程
- **GPU 进程。 **为了实现 3D CSS 的效果。
- **⽹络进程。 **主要负责⻚⾯的⽹络资源加载。
- 插件进程。 主要是负责插件的运⾏,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和⻚⾯造成影响。
二、主线程与消息队列
单线程运行,意味着所有事件一件接着一件处理,从上往下顺序执行。
console.log('开始')
console.log('中间')
console.log('结束')
// 开始
// 中间
// 结束
事件逐一执行,顺序输出。有问题吗? 没有问题。
但此时问题来了。按上面的逻辑,如果一个任务的处理耗时(或者是等待)很久的话,如:网络请求、定时器、等待鼠标点击等,后面的任务应该会被阻塞,也就是说会阻塞所有的用户交互(按钮、滚动条等),会带来极不友好的体验。
但事实上,
console.log('开始')
console.log('中间')
setTimeout(() => {
console.log('timer over')
}, 1000)
console.log('结束')
// 开始
// 中间
// 结束
// timer over
会发现 timer over 会在 打印结束之后打印,也就是说计时器并没有阻塞后面的代码。
那么,为什么呢?
事实上,当遇到计时器、DOM 事件监听或者是网络请求的任务时,JS 引擎会将它们直接交给『 webapi』,也就是浏览器提供的相应线程(如定时器线程为 setTimeout 计时、网络线程处理网络请求)去处理,而 JS 引擎线程继续后面的其他任务,这样便实现了 异步非阻塞。
值得一提的是,定时器触发线程也只是为 setTimeout(..., 1000) 定时而已,时间一到,还会把它对应的回调函数(callback)交给 消息队列 去维护,JS 引擎线程会在适当的时候去 **消息队列 **取出任务并执行。
那么 JS 引擎线程什么时候去处理呢?消息队列又是什么?
- JS 引擎线程维护了一个『调用栈』(就是执行上下文栈),所有任务都将在此内处理。
- 『消息队列』是事件触发线程维护的一种数据结构。 负责将当前线程中的控制事件与其他线程(如定时器线程为 setTimeout 计时、网络线程处理网络请求)的回调维护进各自的『消息队列』。
- 事件触发线程内部会维护多个消息队列,比如延迟执行队列和普通的消息队列。然后主线程采用一个 for 循环,不断地从这些任务队列中取出任务并执行任务。我们把这些消息队列中的任务称为宏任务。
私以为原先的消息队列存储所有异步任务,而后随着优先级更加细致,微任务的出现导致消息队列存放宏任务成为宏任务队列,而微任务存储于执行上下文栈中的微任务队列中
异步任务分类
**异步任务分为宏任务(macro-task)与微任务(micro-task)。 **
- macro-task:宿主任务。 主代码块、setTimeout、setInterval 等(可以看到,消息队列中的每一个事件都是一个 macro-task,现在称之为宏任务队列)
- script(整体代码)
- setTimeout
- setInterval
- I/O
- UI 交互
- postMessage
- MessageChannel
- setImmediate
- micro-task:JS 引擎任务。
- Promise.then
- Object.Observe
- MutaionObserver
- process.nextTick
宏任务和微任务的区别
- 宏队列可以有多个,微任务队列只有一个,所以每创建一个新的 settimeout 都是一个新的宏任务队列,执行完一个宏任务队列后,都会去 checkpoint 微任务。 宏任务队列维护在事件触发线程上,而微任务队列维护在『执行上下文栈』中。
- 一个事件循环后,微任务队列执行完了,再执行宏任务队列
- 一个事件循环中,在执行完一个宏队列之后,就会去 check 微任务队列
- 微任务必然是在某个宏任务执行的时候创建的。
执行流程
- 主体代码(第一次事件循环开始,所有的 script 代码)作为宏任务进入任务执行栈(就是执行上下文栈),但在主线程执行之前要做一系列操作判断。
- 判断当前任务是同步还是异步,同步的由主线程在任务栈中按先进后出顺序(先局部上下文,再全局上下文)执行。
- 异步任务会交予对应的线程处理,处理完成之后将其回调放入对应队列中。 宏任务的回调放入事件触发线程维护的『消息队列』(宏任务队列)。 微任务的回调放入主线程维护的执行上下文栈中的微任务队列。
- 当主线程的任务执行完后,会检查微任务队列是否有任务,如果有就执行,如此循环,知道微任务队列没有任务。
- 当前事件的微任务执行完后,页面会重新渲染。 然后开始执行下一次事件,即会执行宏任务队列中的宏任务,如此循环下去,直到没有任务。
宏任务与微任务示例
1、主线程上添加宏任务和微任务
console.log('-------开始--------')
setTimeout(() => {
console.log('setTimeout')
}, 0)
new Promise((resolve, reject) => {
for (let i = 0; i < 5; i++) {
console.log(i)
}
resolve()
}).then(() => {
console.log('Promise')
})
console.log('-------结束--------')
//-------开始--------
//0
//1
//2
//3
//4
//-------结束--------
//Promise
//setTimeout
第一轮事件循环:
- 整体 script 作为第一个宏任务进入主线程,遇到 console.log,输出-------开始--------。
- 遇到 setTimeout,其回调函数被分发到宏任务 Event Queue 中。我们暂且记为 setTimeout1。
- 遇到 Promise,new Promise 直接执行,循环依次输出 0、1、2、3、4、。then 被分发到微任务 Event Queue 中。我们记为 then1。
- 继续往下,遇到 clg,直接输出-------结束--------,到此第一轮事件循环即将结束,会先看当前循环有没有产生出微任务,有依次按产生顺序执行。
- 发现有 then1,输出 Promise,当前微任务执行完毕,到此,第一轮事件循环结束。
发现 setTimeout1 宏任务,开始第二轮事件循环:
- 遇到 clg,直接输出 setTimeout,没有微任务,第二轮事件循环结束。
所有宏任务执行完毕,整个程序执行完毕
2 、在微任务中创建微任务
setTimeout(() => console.log('setTimeout4'))
new Promise((resolve) => {
resolve()
console.log('Promise1')
}).then(() => {
console.log('Promise3')
Promise.resolve()
.then(() => {
console.log('before timeout')
})
.then(() => {
Promise.resolve().then(() => {
console.log('also before timeout')
})
})
})
console.log('结束')
//Promise1
//结束
//Promise3
//before timeout
//also before timeout
//setTimeout4
第一轮事件循环:
- 遇到 setTimeout,其回调函数被分发到宏任务 Event Queue 中。我们暂且记为 setTimeout1
- 遇到 Promise,new Promise 直接执行,输出 Promise1。then 被分发到微任务 Event Queue 中。我们记为 then1(这里注意不要看 then 里面的内容)。
- 遇到 console.log,输出结束
- 执行微任务 then1,遇到 clg,输出 Promise3,遇到 Promise.resolve().then,then 被分发到微任务 Event Queue 中。我们记为 then2
- 执行 then2,clg 输出 before timeout,生成微任务 then3
- 执行 then3,clg 输出 also before timeout,至此微任务,执行完毕,第一轮事件循环结束
发现 setTimeout1 宏任务,开始第二轮事件循环:
- console.log,输出 setTimeout4,无微任务,第二轮事件循环结束
3、微任务队列中创建宏任务
new Promise((resolve) => {
console.log('new Promise(macro task 1)')
resolve()
}).then(() => {
console.log('micro task 1')
setTimeout(() => {
console.log('setTimeout1')
}, 0)
})
setTimeout(() => {
console.log('setTimeout2')
}, 500)
console.log('========== 结束==========')
//new Promise(macro task 1)
//========== 结束==========
//micro task 1
//setTimeout1
//setTimeout2
第一轮事件循环:
- 遇到 Promise,new Promise 直接执行,输出 new Promise(macro task 1)。then 被分发到微任务 Event Queue 中。我们记为 then1。
- 遇到 setTimeout,其回调函数被分发到宏任务 Event Queue 中。记为宏 set1
- clg 输出========== 结束==========
- 执行微任务 then1,clg 输出 micro task 1,遇到 setTimeout,生成宏任务,记为宏 set2,至此微任务,执行完毕,第一轮事件循环结束
发现有两个定时器宏任务,这里优先执行宏 set2(为什么优先执行,下面详细解释),开始第二轮事件循环:
- clg 输出 setTimeout1,无微任务,第二轮事件循环结束
执行宏 set1,开始第三轮事件循环:
- clg 输出 setTimeout2,无微任务,第三轮事件循环结束
为什么会优先宏 set2 尽管 宏 set1 先被定时器触发线程处理,但是宏 set2 的 callback 会先加入消息队列。 上面,宏 set2 的延时为 0ms,HTML5 标准规定 setTimeout 第二个参数不得小于 4(不同浏览器最小值会不一样),不足会自动增加,所以 "setTimeout2" 还是会在 "setTimeout1" 之后。 就算延时为 0ms,只是宏 set2 的回调函数会立即加入消息队列而已,回调的执行还是得等执行栈为空(JS 引擎线程空闲)时执行。
4、宏任务中创建微任务
setTimeout(() => {
console.log('timer_1')
setTimeout(() => {
console.log('timer_3')
}, 0)
new Promise((resolve) => {
resolve()
console.log('new promise')
}).then(() => {
console.log('promise then')
})
}, 0)
setTimeout(() => {
console.log('timer_2')
}, 0)
console.log('结束')
//结束
// timer_1
//new promise
//promise then
// timer_2
//timer_3
第一轮事件循环:
- 创建宏 set1
- 创建宏 set2
- console.log 输出 “结束”
- 无微任务,当前事件循环结束
执行宏 set1,开始第二事件循环:
- console.log 输出 “timer_1”
- 创建宏 set3
- new Promise,直接输出 'new promise',创建微任务 then1
- 执行微任务 then1,输出 'promise then'
- 无微任务,当前事件循环结束
执行宏 set2,开始第三事件循环:
- clg 输出 'timer_2'
- 无微任务,当前事件循环结束
执行宏 set3,开始第四事件循环:
- clg 输出 'timer_3'
- 无微任务,当前事件循环结束
5、async+await
- async 返回的是一个 promise generator + co
- await => yield 如果产出的是一个 promise 会调用这个 promise.then 方法
async function f1() {
await f2()
console.log('f1结束')
}
async function f2() {
await f3()
console.log('f2结束')
}
async function f3() {
console.log('f3结束')
}
f1()
new Promise((res) => {
console.log('new Promise')
res()
})
.then((res) => {
console.log('promise第一个then')
})
.then((res) => {
console.log('promise第二个then')
})
//f3结束
//new Promise
//f2结束
//promise第一个then
//f1结束
//promise第二个then
- f1 跟 f2 里面的 await 返回都是 promise,所以我们可以转换成 promise
async function f1() {
//await f2()
//console.log('f1结束')
new Promise((resolv, reject) => resolve(f2())).then(() => {
console.log('f1结束')
})
}
async function f2() {
//await f3()
//console.log('f2结束')
new Promise((resolv, reject) => resolve(f3())).then(() => {
console.log('f2结束')
})
}
async function f3() {
console.log('f3结束')
}
f1()
new Promise((res) => {
console.log('new Promise')
res()
})
.then((res) => {
console.log('promise第一个then')
})
.then((res) => {
console.log('promise第二个then')
})
- 执行 f1() 的时候,直接执行 resolve 里面的 f2 方法;
- 执行 f2() ,同上,进入到 f3 执行;
- f3 只有同步代码 clg,直接打印 f3 结束;
- 此时,f2 里面的 resoleve()执行完成,遇到 .then 产生第一个 then1 微任务;执行 f1()到此先放停,往下执行
- new Promise 直接打印 new Promise ,创建第二个 then2 微任务,
- 当前宏任务里面代码执行完毕,清空微任务队列,
- 执行 then1 ,打印 f2 结束,此时 f2 执行完毕,也就是 f1 的 resolve()执行完毕,创建第三个 then3 微任务
- 执行 then2,打印 promise 第一个 then,创建微任务 then4
- 执行 then3,打印 f1 结束;
- 执行 then4,打印 promise 第二个 then,
- 微任务队列清空,当前事件循环结束
流程总结:
- 从上往下,同步直接执行,异步分发 MacroTask 或者 microtask
- 碰到 MacroTask 直接执行,并且把回调函数放入 MacroTask 执行队列中(下次事件循环执行);碰到 microtask 直接执行。把回调函数放入 microtask 执行队列中(本次事件循环执行)
- 当同步任务执行完毕后,去执行微任务 microtask。(microtask 队列清空)
- 由此进入下一轮事件循环:执行宏任务 MacroTask (setTimeout,setInterval,callback)
参考:
浏览器原理与实践 一篇搞定(Js 异步、事件循环与消息队列、微任务与宏任务)