进程、线程与浏览器的事件循环模型

提个问题

我们知道,在 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)添加进队列。此时微任务队列如下图:

最后依次打印 45

相关推荐
Black蜡笔小新44 分钟前
网页直播/点播播放器EasyPlayer.js播放器OffscreenCanvas这个特性是否需要特殊的环境和硬件支持
前端·javascript·html
秦jh_2 小时前
【Linux】多线程(概念,控制)
linux·运维·前端
蜗牛快跑2132 小时前
面向对象编程 vs 函数式编程
前端·函数式编程·面向对象编程
Dread_lxy2 小时前
vue 依赖注入(Provide、Inject )和混入(mixins)
前端·javascript·vue.js
涔溪3 小时前
Ecmascript(ES)标准
前端·elasticsearch·ecmascript
榴莲千丞3 小时前
第8章利用CSS制作导航菜单
前端·css
奔跑草-3 小时前
【前端】深入浅出 - TypeScript 的详细讲解
前端·javascript·react.js·typescript
羡与3 小时前
echarts-gl 3D柱状图配置
前端·javascript·echarts
guokanglun3 小时前
CSS样式实现3D效果
前端·css·3d
咔咔库奇3 小时前
ES6进阶知识一
前端·ecmascript·es6