提个问题
我们知道,在 js 中,诸如 setTimeout()
这样的代码,其中的回调是异步执行的:
// 例 1
setTimeout(() => { console.log('异步执行') }, 10000)
console.log(1)
执行例 1 的代码会直接打印 1,然后等 10s 再打印 '异步执行'。我们还知道,js 是单线程运行的(当然使用 H5 中 Web Workers 可以多线程运行)。
那么问题来了,为什么例 1 的代码不会卡在第 1 行那,比如执行计时的任务,计算出 10s 的时间到了然后执行打印,之后再继续执行第 3 行呢?想回答这个问题,我们先来了解下计算机操作系统中的 2 个概念——进程和线程。
进程和线程
进程
从狭义的角度,可以认为进程(process)是正在运行的程序的实例。可以认为启动一个应用程序就会启动一个或多个进程,进程占有一片独有的内存空间,也可以简单地把这块内存空间理解为进程。比如现代浏览器一般会开启多个进程,可以通过 windows 任务管理器查看:
也可以直接通过浏览器设置中的“更多工具”-> “浏览器任务管理器”查看:
可以看到当我们打开浏览器,打开百度页面,浏览器会开启的所有进程:
介绍其中 3 个:
- 浏览器进程:主要负责整个浏览器本身的界面显示,用户交互,子进程管理等;
- Network Service(网络进程):负责加载网络资源;
- 标签页(渲染进程):默认情况下,每个新的标签页都会对应开启一个新的渲染进程,渲染进程会启动一个渲染主线程负责执行 html,css 和 js 代码。
进程之间是相互独立的,只有双方同意的情况下才能互相通信。
线程
线程(thread)是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位,可以把它看成运行代码的“人”。也就是说一个进程中至少包含一个线程(主线程),进程启动后自动创建,用来执行程序中的代码,其它的可以称为分线程。当然一个进程中也可以同时运行多个线程,比如下图中的进程 A1,程序 A 也被称为多线程运行的。一个进程内的数据可以供其中的各个线程直接共享,而多个进程之间的数据是不能直接共享的。
我们可以把操作系统看成是工厂,进程比作车间,而线程就是工人,进程是线程的容器,真正干活的是线程。
操作系统的工作方式
即使是在单核 CPU 的电脑上,我们也可以同时让多个进程同时工作,比如我们可以一边用 vscode 写着代码,一边开着网抑云听歌,同时还能打开掘金看点文章充充电(摸摸鱼)。这是因为 CPU 的运算速度非常快,可以在多个进程间快速地切换,当进程中的线程获取到时间片(分时操作系统分配给每个正在运行的进程微观上的一段 CPU 时间)时,可以快速地执行程序的代码。这些时间片通常很短,一般是 ms 级的,用户感觉不到,就会觉得多个进程似乎在同时进行。
JS 是单线程的
我们说回到 js 是单线程运行的这件事,js 的线程的容器进程,为浏览器或 Node。前文说到,大多数现代浏览器是多进程的,打开一个标签页就开启一个进程,每个进程又包含多个线程, js 运行在渲染主线程,所以在同一时刻,js 只能做一件事情,如果遇到某些耗时的操作,比如一个循环次数非常大的 for 循环,就会阻塞当前的线程,导致阻碍页面的渲染。为了避免阻塞的发生,那些比较耗时的操作,会交给其它线程来完成。
回答开头那个问题
现在就可以回到文章开头的问题了,例 1 第 2 行的这个定时器,setTimeout()
函数本身是同步立即执行的,而计时操作(肯定也有相应的代码)是让浏览器的其它线程来完成的,等待时间条件满足时再告诉 js 线程执行回调,打印 '异步执行'。如此,自然就不会阻塞 js 线程,而是继续执行第 3 行的代码,先打印 1。
浏览器的事件循环模型
浏览器在执行定时器函数时,具体是怎么处理其中的回调函数的呢?等待时间条件满足时是怎么让对应的回调被 js 线程执行的呢?这就与浏览器的事件循环模型有关系了,我们先来看看事件循环的示意图:
以定时器为例,说一下是怎么形成个循环的:
假设在 js 线程中,fn1 是个定时器函数,那么执行到 fn1 时就交给浏览器的其它线程中的定时器管理模块处理,将定时器函数的回调保存起来,开始计时。等待设定的时间一到,再把回调函数加入到由浏览器维护的事件队列(队列是先进先出的)中。
事件队列又分为宏任务队列:比如 ajax 回调,setTimeout 等;和微任务队列:比如 MutationObserver,promise 的 then 回调等。定时器的回调会放入宏任务队列。等到 js 线程中那些栈里的同步代码都执行完了,在执行任何一个宏任务之前,会先看看微任务队列中所有任务是否已被执行,如果发现微任务队列没有清空,则优先执行微任务队列中的任务。如果执行任务期间又有新的任务则会添加到任务队列末尾。如此,回调函数从 js 线程来到其它线程再被加入事件队列最后又被 js 线程执行,就形成了一个事件循环。
每一次循环都会检查事件队列中是否还有任务存在,有就取出第 1 个任务执行,执行完进入下一次循环。如果没有任务了则主线程会进入休眠状态,直至其它线程(包括其它进程的线程)向事件队列添加了新的任务,再将主线程唤醒继续循环。
顺便一提,这个过程也解释了为何 js 中计时器无法做到精确计时,因为计时器的回调总要等待主线程空闲了,并且微任务执行完了才执行。
W3C 最新解释
其实在最新的 W3C 解释中,已经不再使用宏任务队列的说法了,因为随着浏览器复杂度的提升,现在任务类型非常多,只是要求同一个任务类型的任务必须要在一个队列 —— 比如延时队列,存放计时器到达后的回调任务;交互队列,存放用户操作后产生的事件处理任务;不同的队列优先级不同。不同类型的任务可以分属不同的队列,也可以在同一个队列。微队列则依然是必须要有的,其中的任务优先其它队列任务执行。
同步与异步
请注意,上例中 js 线程并没有在等待计时的这段时间内阻塞,等待其它线程的计时结束再“同步”地执行任务,这样做就成了“同步”的方式;而是会结束当前任务去执行下一个任务,是为“异步”方式。可以说,单线程是异步产生的原因,事件循环是异步的实现方式。
牛刀小试
阅读至此,jym 能正确说出下面这段代码执行时的打印顺序吗?
console.log('a')
Promise.resolve()
.then(() => {
console.log(0)
return Promise.resolve(4)
})
.then(res => {
console.log(res)
})
console.log('b')
Promise.resolve()
.then(() => {
console.log(1)
})
.then(() => {
console.log(2)
})
.then(() => {
console.log(3)
})
.then(() => {
console.log(5)
})
console.log('c')
一开始,执行最顶层也就是全局作用域的函数(可以称为 main script):
即先打印 a ;
然后是第 2 行的Promise.resolve()
,将第 5/6 行的代码放入微队列;
然后是打印 b;
然后是第 14 行的Promise.resolve()
,将 console.log(1)
放入微队列;
然后是打印 c。
至此 main script 执行完毕。因为没有宏任务,所以开始准备执行微任务队列,此时微任务队列里任务如下图:
先打印 0,然后执行 return Promise.resolve(4)
,这里即为本例的重点,我查了些资料,按自己的理解是执行 return Promise.resolve(4)
的结果为往微任务队列增加了一个任务,该任务是要执行一个 thenable:
// 例 2
{
then(reolve) {
reolve(4)
}
}
我们姑且借用 ECMAScript 规范里的说法称该任务为 NewPromiseResolveThenableJob;
然后执行 console.log(1)
打印 1,第 15 行的这个 then 方法返回的 promise 状态即为 fulfilled, 将执行它的 then 方法的回调,也就是将第 19 行的 console.log(2)
加入到微任务队列。
此时微任务队列如下图:
继续执行微任务,先是 NewPromiseResolveThenableJob,执行结果为将 reolve(res)
添加到微任务队列(注意这里不是直接将第 9 行的 console.log(res)
加入队列,可以把第 6 行的 return Promise.resolve(4)
改为 return 例 2 这个 thenable,看看打印顺序)。
然后执行打印 2,结果为将第 22 行的 console.log(3)
添加进队列。此时微任务队列如下图:
执行 reolve(res)
的结果就是将第 9 行的 console.log(4)
加进队列;然后打印 3,并将 console.log(5)
添加进队列。此时微任务队列如下图:
最后依次打印 4 和 5。