众所周知 JavaScript 是一门单线程非阻塞的脚本语言。JavaScript 的并发模型基于 event loop,而这个模型与 C 或者 Java 这种其它语言中的模型截然不同。

概述

我们先明确两个概念:进程和线程。运行的程序就是一个进程,比如你正在运行的浏览器,它会有一个进程,进程是 CPU 资源分配的最小单位(是能拥有资源和独立运行的最小单位)。而线程是指程序中独立运行的代码段。线程是 CPU 调度的最小单位(线程是建立在进程的基础上的一次程序运行单位,一个进程中可以有多个线程)。所以单线程意味着,JavaScript 代码在执行的任何时候,都只有一个主线程来处理所有的任务。单线程是必要的,也是 JavaScript 这门语言的基石。

JavaScript 最初也是最主要的执行环境是浏览器,我们需要进行各种各样的 DOM 操作。试想一下,如果 JavaScript 是多线程的。那么当两个线程同时对 DOM 进行一项操作,例如一个向其添加事件,而另一个删除了这个 DOM,此时该如何处理呢?因此,为了保证不会发生类似于这个例子中的情景,JavaScript 选择只用一个主线程来执行代码,这样就保证了程序执行的一致性。

Javascript 执行引擎的主线程运行的时候,产生堆(heap)和栈(stack),程序中代码依次进入栈中等待执行,若执行时遇到异步方法,该异步方法会被添加到用于回调的队列(queue)中。

而非阻塞则是当代码需要进行一项异步任务(无法立刻返回结果,需要花一定时间才能返回的任务,如I/O事件)的时候,主线程会挂起(pending)这个任务,然后在异步任务返回结果的时候再根据一定规则去执行相应的回调,这个规则就是所谓的 event loop。

执行栈与事件队列

当 Javascript 代码执行的时候会将不同的变量存于内存中的不同位置:堆(heap)和栈(stack)中来加以区分。其中,堆里存放着一些对象。而栈中则存放着一些基础类型变量以及对象的指针。 但是我们这里说的执行栈和上面这个栈的意义却有些不同。

我们知道,当我们调用一个方法的时候,JS 会生成一个与这个方法对应的执行环境(context),又叫执行上下文。这个执行环境中存在着这个方法的私有作用域,上层作用域的指向,方法的参数,这个作用域中定义的变量以及这个作用域的 this 对象。 而当一系列方法被依次调用的时候,因为 JS 是单线程的,同一时间只能执行一个方法,于是这些方法被排队在一个单独的地方。这个地方被称为执行栈。

当一个脚本第一次执行的时候,JS 引擎会解析这段代码,并将其中的同步代码按照执行顺序加入执行栈中,然后从头开始执行。如果当前执行的是一个方法,那么 Javascript 会向执行栈中添加这个方法的执行环境,然后进入这个执行环境继续执行其中的代码。当这个执行环境中的代码 执行完毕并返回结果后,Javascript 会退出这个执行环境并把这个执行环境销毁,回到上一个方法的执行环境。这个过程反复进行,直到执行栈中的代码全部执行完毕。

一个方法执行会向执行栈中加入这个方法的执行环境,在这个执行环境中还可以调用其他方法,甚至是自己,其结果不过是在执行栈中再添加一个执行环境。这个过程可以是无限进行下去的,除非发生了栈溢出,即超过了所能使用内存的最大值。

以上的过程说的都是同步代码的执行。那么当一个异步代码(如发送 ajax 请求数据)执行后会如何呢?前文提过,的另一大特点是非阻塞,实现这一点的关键在于下面要说的这项机制——事件队列(Task Queue)。

JS 引擎遇到一个异步事件后并不会一直等待其返回结果,而是会将这个事件挂起,继续执行执行栈中的其他任务。当一个异步事件返回结果后,JS 会将这个事件加入与当前执行栈不同的另一个队列,我们称之为事件队列。被放入事件队列不会立刻执行其回调,而是等待当前执行栈中的所有任务都执行完毕, 主线程处于闲置状态时,主线程会去查找事件队列是否有任务。如果有,那么主线程会从中取出排在第一位的事件,并把这个事件对应的回调放入执行栈中,然后执行其中的同步代码,如此反复,这样就形成了一个无限的循环。这就是这个过程被称为 Event Loop 的原因。

1
2
3
4
5
6
7
8
9
console.log(1);

setTimeout(() => {
console.log(2)
}, 5000)

console.log(3)

// 输出结果 1,3,2
  1. JavaScript 执行引擎主线程运行,产生 heap 和 stack。

  2. 从上往下执行同步代码 console.log(1) 被压入执行栈,因为 log() 是 webkit 内核支持的普通方法而非 WebAPIs 的方法,因此立即出栈被引擎执行,输出1。

  3. JavaScript 执行引擎继续往下,遇到 setTimeout() 异步方法(setTimeout 属于 WebAPIs),将 setTimeout(callback, 5000) 添加到执行栈。

  4. 因为 setTimeout() 属于WebAPIs中的方法,JavaScript 执行引擎在将 setTimeout() 出栈执行时,注册 setTimeout() 延时方法交由浏览器内核其他模块(以 WebKit 为例,是 WebCore 模块)处理。

  5. 继续运行 setTimeout() 下面的 log(3) 代码,原理同步骤2。

  6. 当延时方法到达触发条件,即到达设置的延时时间时(5秒后),该延时方法就会被添加至任务队列里。这一过程由浏览器内核其他模块处理,与执行引擎主线程独立。

  7. JavaScript 执行引擎在主线程方法执行完毕,到达空闲状态时,会从任务队列中顺序获取任务来执行。

  8. 将队列的第一个回调函数重新压入执行栈,执行回调函数中的代码 log(2),原理同步骤2,回调函数的代码执行完毕,清空执行栈。

  9. JavaScript 执行引擎继续轮询队列,直到队列为空。

  10. 执行完毕。

Macro Task & Micro Task

以上的事件循环过程是一个宏观的表述,实际上因为异步任务之间并不相同,因此他们的执行优先级也有区别。不同的异步任务被分为两类:微任务(micro task)和宏任务(macro task)。

macro task,可以理解是每次执行栈执行的代码就是一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行)每一个 task 会从头到尾将这个任务执行完毕,不会执行其它 task 浏览器为了能够使得 JS 内部 task 与 DOM 任务能够有序的执行,会在一个 task 执行结束后,在下一个 task 执行开始前,对页面进行重新渲染(task -> 渲染 -> task -> …)。

micro task,可以理解是在当前 task 执行结束后立即执行的任务。也就是说,在当前 task 任务后,下一个 task 之前,在渲染之前。所以它的响应速度相比 setTimeoutsetTimeout 是 macro task)会更快,因为无需等渲染也就是说,在某一个 macro task 执行完后,就会将在它执行期间产生的所有 micro task 都执行完毕(在渲染前)。

在一个事件循环中,异步事件返回结果后会被放到一个任务队列中。然而,根据这个异步事件的类型,这个事件实际上会被对应的宏任务队列或者微任务队列中去。并且在当前执行栈为空的时候,主线程会查看微任务队列是否有事件存在。如果不存在,那么再去宏任务队列中取出一个事件并把对应的回到加入当前执行栈,如果存在,则会依次执行队列中事件对应的回调,直到微任务队列为空,然后去宏任务队列中取出最前面的一个事件,把对应的回调加入当前执行栈…如此反复,进入循环。

我们只需记住当前执行栈执行完毕时会立刻先处理所有微任务队列中的事件,然后再去宏任务队列中取出一个事件。同一次事件循环中,微任务永远在宏任务之前执行。

参考资料