提个问题
我们知道,在 js 中,诸如 setTimeout()
这样的代码,其中的回调是异步执行的:
javascript
// 例 1
setTimeout(() => { console.log('异步执行') }, 10000)
console.log(1)
执行例 1 的代码会直接打印 1,然后等 10s 再打印 '异步执行'。我们还知道,js 是单线程运行的(当然使用 H5 中 Web Workers 可以多线程运行)。
那么问题来了,为什么例 1 的代码不会卡在第 1 行那,比如执行计时的任务,计算出 10s 的时间到了然后执行打印,之后再继续执行第 3 行呢?想回答这个问题,我们先来了解下计算机操作系统中的 2 个概念------进程和线程。
进程和线程
从狭义的角度,可以认为进程(process)是正在运行的程序的实例。可以认为启动一个应用程序就会启动一个或多个进程,进程占有一片独有的内存空间。比如 Chrome 浏览器一般会开启多个进程,可以通过 windows 任务管理器查看:
线程(thread)是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。也就是说一个进程中至少包含一个线程(主线程),进程启动后自动创建,用来执行程序中的代码,其它的可以称为分线程。当然一个进程中也可以同时运行多个线程,比如下图中的进程 A1,程序 A 也被称为多线程运行的。一个进程内的数据可以供其中的各个线程直接共享,而多个进程之间的数据是不能直接共享的。
我们可以把操作系统看成是工厂,进程比作车间,而线程就是工人,进程是线程的容器,真正干活的是线程。
操作系统的工作方式
即使是在单核 CPU 的电脑上,我们也可以同时让多个进程同时工作,比如我们可以一边用 vscode 写着代码,一边开着网抑云听歌,同时还能打开掘金看点文章充充电(摸摸鱼)。这是因为 CPU 的运算速度非常快,可以在多个进程间快速地切换,当进程中的线程获取到时间片(分时操作系统分配给每个正在运行的进程微观上的一段 CPU 时间)时,可以快速地执行程序的代码。这些时间片通常很短,一般是 ms 级的,用户感觉不到,就会觉得多个进程似乎在同时进行。
JS 是单线程的
我们说回到 js 是单线程运行的这件事,js 的线程的容器进程,为浏览器或 Node。大多数现代浏览器是多进程的,打开一个 tab 页就开启一个进程,每个进程又包含多个线程,其中就有执行 js 的线程。因为 js 是单线程的,所以在同一时刻,js 只能做一件事情,如果遇到某些耗时的操作,比如一个循环次数非常大的 for 循环,就会阻塞当前的线程。为例避免阻塞的发生,那些比较耗时的操作,实际并非由 js 线程来执行,而是交给浏览器的其它线程来完成。
回答开头那个问题
现在就可以回到文章开头的问题了,例 1 第 2 行的这个定时器,setTimeout()
函数本身是同步立即执行的,而计时操作(肯定也有相应的代码)是让浏览器的其它线程来完成的,等待时间条件满足时再告诉 js 线程执行回调,打印 '异步执行'。如此,自然就不会阻塞 js 线程,而是继续执行第 3 行的代码,先打印 1。
浏览器的事件循环模型
浏览器在执行定时器函数时,具体是怎么处理其中的回调函数的呢?等待时间条件满足时是怎么让对应的回调被 js 线程执行的呢?这就与浏览器的事件循环模型有关系了,我们先来看看事件循环的示意图:
以定时器为例,说一下是怎么形成个循环的:假设在 js 线程中,fn1 是个定时器函数,那么执行到 fn1 时就交给浏览器的其它线程中的定时器管理模块处理,将定时器函数的回调保存起来,开始计时。等待设定的时间一到,再把回调函数加入到由浏览器维护的事件队列(队列是先进先出的)中。事件队列又分为宏任务队列和微任务队列,定时器的回调会放入宏任务队列。等到 js 线程中那些栈里的同步代码都执行完了,在执行任何一个宏任务之前,会先看看微任务队列中所有任务是否已被执行,如果发现微任务队列没有清空,则优先执行微任务队列中的任务。如此,回调函数从 js 线程来到其它线程再被加入事件队列最后又被 js 线程执行,就形成了一个事件循环。
牛刀小试
阅读至此,jym 能正确说出下面这段代码执行时的打印顺序吗?
javascript
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:
javascript
// 例 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。