彻底搞懂JavaScript单线程异步执行机制
背景
看过 Chrome浏览器进程工作原理和机制 一文我们知道,浏览器每个渲染进程都有一个主线程,而且主线程既要处理 DOM,又要计算样式,还要处理布局,同时还需要处理 JavaScript 任务以及各种输入事件(在渲染主线程渲染过程中频繁收到来自 IO 线程的一些任务,接收到这些任务之后,渲染进程就需要着手处理。比如接收到资源加载完成消息后渲染进程就要着手进行 DOM 解析;接收到鼠标点击消息后渲染主线程就要开始执行相应的 JavaScript 脚本来处理该点击事件)。要让这么多不同类型任务在主线程中有条不紊地执行,就需要一个系统来统筹调度这些任务,这个统筹调度系统就是今天要讲的消息队列和事件循环。
消息队列
消息队列是一种数据结构,可以存放要执行的任务,要添加任务的话,添加到队列尾部,要取出任务的话,从队列头部取出,它符合“先进先出”的特点。
主线程执行的任务都从消息队列中获取,其他线程要想发送任务让主线程去执行,只需要将任务添加到该消息队列中即可。如果其他进程想要发送任务给页面主线程,先通过 IPC 把任务发送给渲染进程专门一个用来接收其他进程消息的 IO 线程,它会将这些消息封装成任务发送给渲染主进程,后续就像其他线程给主线程发送任务一样将其添加到消息队列。
由于是多个线程操作同一个消息队列,因此在添加任务和取出任务时还会加上一个同步锁。
消息队列中的任务类型也非常多,包含很多内部消息类型,如输入事件(鼠标滚动、点击、移动)、微任务、文件读写、WebSocket、JavaScript 定时器等。以及跟页面相关的事件,如 JavaScript 执行、解析 DOM、样式计算、布局计算、CSS 动画等。
消息队列中的任务是通过事件循环机制来执行的。
关于JavaScript异步
JavaScript是一门单线程语言 一切JavaScript中说的多线程都是纸老虎 本质都是用单线程模拟出来的
先来一张来自大神(mr.z 大佬)的图
本文的目的就是彻底弄懂JavaScript的事件循环的执行机制。
事件循环(Event Loop)是一个程序结构,用于等待和发送消息和事件。
首先,不论你是面试求职,还是日常的开发工作,给定的几行代码,我们需要准确的知道代码执行输出的结果,也就是代码执行的顺序。因为JavaScript是单线程语言,所以它的语句都是顺序执行的,也就是同步任务。但是,如果加载一个包含了很多图片的页面,如果都是同步任务,要是图片资源巨大加载不出来,岂不是后面的内容都显示不了。所以就要了解JavaScript的事件执行机制。
其实,当打开页面时,页面的渲染过程涉及到很多同步任务(页面骨架,页面元素的渲染),也有一些异步任务(图片音乐之类占用资源大耗时比较久的任务)。
- 同步任务
- 异步任务
同步任务和异步任务会在不同的执行场所等待被执行,同步任务会在主线程中执行,碰到异步任务会先放到异步队列中等待被执行。
下图用来说明每当js遇到事件时会如何执行
ajax
AJAX(Asynchronous Javascript And XML)异步JavaScript和XML
,相信我们在日常工作中ajax
已经用过很多了,通过和后端进行少量数据交互,实现网页异步更新。
ajax
出现之前,如果网页需要更新部分数据,比如我们获取一个表单数据,就必须重载整个网页页面。
有了ajax
技术,我们就可以在不重载整个网页的情况下,对网页的某部分进行更新,比如只更新表单数据,而不用对表单整个页面进行重载。
看一个例子:
$.ajax({
url: 'xxx.com/list',
data: xxx,
success: () => {
console.log('接收数据了')
}
})
console.log('代码执行结束')
这一段简单的ajax
请求代码就是个简单的异步任务:
ajax
进入事件列表,注册回调函数success
- 执行主线程同步任务
console.log('代码执行结束')
ajax
事件完成,回调函数success
进入事件队列(Event Queue)- 主线程从事件队列读取回调函数
success
并执行
setTimeout
setTimeout
也是异步任务。那到底是如何异步执行的呢?
通常我们延时执行时会想到用setTimeout
setTimeout(() => {
console.log('延时1s')
}, 1000)
逐渐用的多了,问题也多了,有时候延时1秒但是实际却过了3,4秒才执行。比如下面的例子
setTimeout(() => {
task()
}, 1000)
console.log('主流程console')
sleep(100000) // 执行了很久的函数
我们知道setTimeout
是个异步任务,这里我们想要task
在1秒后执行,但是实际等待的时间却远远不止1秒,这是为什么?我们弄懂这里的setTimeout
异步任务是如何执行的就明白了:
- 首先遇到
setTimeout
,发现是个异步任务时,会注册其回调函数task
- 执行主流程
console
- 执行主流程任务
sleep
函数,执行了很久很久… - 3秒后,回调
task
进入到事件队列中(如果这时候主线程任务都执行完了是空闲的,下一步主线程就会从事件队列中取到这个函数去主线程中执行了)。此时应该开始执行事件队列中注册的task
了,但是主线程还在执行sleep
,因为js是单线程,所以只能继续等待 sleep
终于执行完了,task
从事件队列中取出进入到主线程中执行
上述流程了解完,我们就知道setTimeout
的延时时间,是指定经过多长时间把要执行的任务加入到事件队列中去,所以真实等待的时长就不仅仅是根据setTimeout
的延时时间参数来决定的了,还依赖主线程中任务执行的时长。
setInterval
setInterval
跟setTimeout
很像,它的含义是指定多长时间将注册的函数放入事件队列中,如果前面的任务耗时很长,同样也会延长这个时间。通过对setTimeout
的说明,知道对setInterval(fn, ms)
的理解需要注意的是:不是每隔ms
执行一次fn
,而是每隔ms
,会将fn
加入到事件队列中一次。
所以,细品这句话:如果fn
执行时间超过了ms
,用户是看不出这个时间间隔的。因为,如果你每隔1秒加入一个fn
,而这个fn
每次执行都需要5秒才能结束,也就是主线程第一个执行完的时候去查队列时,发现队列中还有4个没执行呢,此时主线程就又会去队列中拿一个新的fn
到主线程中去执行,也就没有什么休息时间了。
Promise
ES6
为我们提供了Promise
来处理异步操作。
先看一个例子:
setTimeout(() => {
console.log('setTimeout')
})
new Promise((resolve) => {
console.log('promise')
resolve()
}).then(() => {
console.log('then')
})
console.log('console')
根据之前的介绍,如果promise
和setTimeout
的事件循环机制相同的话,结果应该是
// promise
// console
// setTimeout
// then
但是正确的执行结果却是:
// promise
// console
// then
// setTimeout
宏任务vs微任务
宏任务
前面介绍,页面中的大部分任务都是在主线程上执行的,这些任务包括:
- 渲染事件(如解析 DOM、计算布局、绘制)
- 用户交互事件(如鼠标点击、滚动页面、放大缩小等)
- JavaScript 脚本执行事件
- 文件读写
渲染进程内部会维护多个消息队列,比如延迟执行队列和普通消息队列。主线程采用 for
循环的方式,不断从这些任务队列中取出任务并执行,我们把这些消息队列中的任务都称为宏任务。
宏任务可以满足我们大部分的日常需求,不过如果对时间精度要求较高的需求,宏任务就难以胜任了。例如页面的渲染事件、各种 IO 的完成事件、执行 JavaScript 脚本的事件、用户交互的事件等都随时可能被添加到消息队列中,而且添加事件是由系统操作的,JavaScript 代码不能准确掌控任务要添加到队列中的位置,所以很难控制任务执行的开始和结束时间。比如下面这段示例代码:
function timerCallback2() {
console.log(2)
}
function timerCallback() {
console.log(1)
setTimeout(() => {
timerCallback2
}, 0)
}
setTimeout(timerCallback, 0)
这段代码中目的是通过 setTimeout
来设置两个回调任务,并让它们按照前后顺序来执行,如果这两个任务之间插入了其他系统任务,就很有可能会影响到第二个定时器的执行时间,而这个是否穿插任务是代码不可控的。
script
,setTimeout
,setInterval
、UI渲染,I/O
,postMessage
等都是宏任务
微任务
微任务的执行时机是在主函数执行结束之后、当前宏任务结束之前。每个宏任务都关联了一个微任务队列,在当前宏任务执行过程中有可能会产生多个微任务,这时候需要一个微任务队列来保存这些微任务,这个队列是给 V8 引擎内部使用的,无法通过 JavaScript 直接访问。
在现代浏览器里面,产生微任务有两种方式
- 使用
MutationObserver
监控某个 DOM 节点 - 使用
Promise
微任务的工作流程有几下几个特点:
- 微任务和宏任务是绑定的,每个宏任务在执行时,会创建自己的微任务队列
- 微任务执行时长影响到宏任务的时长,在写代码时要注意控制微任务的执行时长
- 在一个宏任务重,分别创建一个用于回调的宏任务和微任务,无论什么情况下,微任务都早于宏任务执行
宏任务微任务案例
所以,虽然都是异步任务,但是因为 promise
和 setTimeout
是不同的异步任务,所以执行时机不同,循环任务执行时也会不同,很多时候输出结果可能会跟你想象的不大一样。
上述的例子具体的执行步骤:
- 因为整段代码在
script
中,所以首先整段代码作为一个宏任务,进入主线程 - 遇到
setTimeout
,此时将其回调函数注册后发布到事件队列中 - 接下来遇到
promise
,因为我们知道promise
是立即执行的(可参考Promise原理和使用),而then
回调会分发到微任务队列中 - 立即执行
console
- 到此为止第一轮宏任务完成。开始检查微任务队列中是否有回调,发现了
then
,执行 - 到此第一个事件循环结束了。
- 第二轮事件循环开始,从宏任务队列中查看,发现了
setTimeout
的回调,执行。 - 没任务了,结束。
所以,宏微任务的关系如下:
最后一个复杂的例子,检查你是否掌握了这个执行机制:
console.log(1)
setTimeout(() => {
// setTimeout_1
console.log(2)
new Promise((resolve) => {
console.log(3)
resolve()
}).then(() => {
// promise_3
console.log(4)
})
}, 1000)
new Promise((resolve) => {
console.log(5)
resolve()
}).then(() => {
// promise_1
setTimeout(() => {
// setTimeout_3
console.log(6)
})
console.log(7)
})
setTimeout(() => {
// setTimeout_2
console.log(8)
new Promise((resolve) => {
console.log(9)
resolve()
}).then(() => {
// promise_2
console.log(10)
})
})
第一轮事件循环:
- 整体代码作为
script
宏任务,遇到console.log(1)
- 遇到
setTimeout
回调1秒后放入宏事件队列,记为setTimeout_1
promise
立即执行,console.log(5)
同时then
放入微任务中,记为promise_1
- 又遇到
setTimeout
,立即放入宏任务队列中,记为setTimeout_2
- 此时宏任务完成,去微任务队列中看看这一轮宏任务产生的微任务有哪些,发现只有一个
promise_1
,执行时候发现又遇到一个宏任务,将其放入宏任务队列中,记为setTimeout_3
,继续执行console.log(7)
- 过了1秒了,
setTimeout_1
也被加入到宏队列中了 - 第一轮循环结束,结果:
1,5,7
,此时任务队列
宏任务 |
---|
setTimeout_2 |
setTimeout_3 |
setTimeout_1 |
第二轮事件循环:
- 拿到宏任务队列中
setTimeout_2
,执行console.log(8)
,立即执行promise
的console.log(9)
,同时将其then
加入到微任务中,记为promise_2
- 宏任务完成,去微任务队列中发现
promise_2
,执行console.log(10)
- 此时第二轮循环结束,结果为:
1,5,7,8,9,10
第三轮事件循环:
- 拿到宏任务
setTimeout_3
,console.log(6)
,无微任务,结束 - 此时结果为:
1,5,7,8,9,10,6
第四轮事件循环:
setTimeout_1
立即执行console.log(2)
,遇到promise
立即执行console.log(3)
,同时将then
加入到微任务中,记为promise_3
- 宏任务结束,查看微任务队列,执行
promise_3
,执行console.log(4)
- 本轮循环结束,结果为:
1,5,7,8,9,10,6,2,3,4
结束语
从头到尾都在强调JavaScript是一门单线程语言,不管是什么新框架还是新语法糖实现的所谓的异步,其实都是根据JavaScript的事件循环机制原理用同步的方式来模拟的,事件循环是JavaScript实现异步的一种方法,也是它的执行机制。
牢牢记住这两点:
- JavaScript是一门单线程语音
- 事件循环是它的执行机制
- 微任务还是宏任务针对的是回调函数,当前的
new Promise
或者定时器是同步立即执行的,异步的是他们的then
和回调函数方法