我们都知道 JavaScript 是单线程的,但是却很适合做 IO 密集型操作,那么 Javascript 为什么是单线程的又是如何处理异步操作的?下面我们来探究下 Jascript 的异步处理机制。

进程与线程 - process & thread

Javascript 代码是有运行环境的,例如在 浏览器 或者 Nodejs 等环境中,我们以浏览器为例,解释下浏览器中 Javascript 的异步运行机制。
我们知道浏览器是多进程的,它主要包括以下进程:

  • Browser 进程(Browser Process):浏览器的主进程,唯一,负责创建和销毁其它进程、网络资源的下载与管理、浏览器界面的展示、前进后退等。
  • GPU 进程(GPU Process):用于 3D 绘制等,最多一个。
  • 第三方插件进程(Plugin Process):每种类型的插件对应一个进程,仅当使用该插件时才创建。
  • 浏览器渲染进程(Renderer Process):内部是多线程的,每打开一个新网页就会创建一个进程,主要用于页面渲染,脚本执行,事件处理等。

页面的渲染,JavaScript 的执行,事件的循环均是在浏览器渲染进程(浏览器内核)中进行的,而是浏览器渲染进程(浏览器内核)是多线程的,主要包括以下线程:

  • GUI 渲染线程:负责渲染浏览器界面HTML,当界面需要重绘(Repaint)或由于某种操作引发回流(Reflow)时,该线程就会执行;GUI 渲染线程与 JavaScript 引擎线程是互斥的,当 JavaScript 引擎执行时 GUI 线程会被挂起。
  • JavaScript 引擎线程:也称为 JavaScript 内核,负责处理 Javascript 脚本程序、解析 Javascript 脚本、运行代码等。如果 JavaScript 执行的时间过长,会阻塞GUI渲染县城,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞。
  • 事件触发线程:用来控制浏览器事件循环,注意这不归 JavaScript 引擎线程管,当事件被触发时,该线程会把事件添加到待处理队列的队尾,等待 JavaScript 引擎的处理。
  • 定时触发器线程:setInterval 与 setTimeout 所在线程,注意,W3C 在 HTML 标准中规定,规定要求 setTimeout 中低于 4ms 的时间间隔算为 4ms 。
  • 异步 http 请求线程:在 XMLHttpRequest 连接后通过浏览器新开一个线程请求,将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中,再由 JavaScript 引擎执行。

Tip: 浏览器 - 浏览器内核 - javascript内核 关系参考 Chromium - Blink - v8

所谓单线程,是指在 JavaScript 引擎中负责解释和执行 JavaScript 代码的线程唯一,同一时间上只能执行一件任务。

为什么采用单线程

我们知道浏览器是需要渲染 DOM 的,而 JavaScript 可以修改 DOM 结构,如果 JavaScript 引擎线程不是单线程的,那么可以同时执行多段 JavaScript,如果这多段 JavaScript 都修改 DOM,那么就会出现 DOM 冲突。JavaScript从诞生开始就选择了简单的单线程执行来避免产生这个问题。单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。这样的优点是实现比较简单,但也会带来一些问题,只要有一个任务耗时很长,后面的任务都必须排队等着,会拖延整个程序的执行。常见的浏览器无响应(假死),往往就是因为某一段 Javascript 代码长时间运行(比如死循环),导致整个页面卡在这个地方,其他任务无法执行。

同步与异步 - synchronization & asynchronous

为了解决这个问题,JavaScript 将任务的执行模式分为两种:同步和异步。
如果在函数 func 返回的时候,调用者就能够得到预期结果(即拿到了预期的返回值或者看到了预期的效果),那么这个函数就是同步的。

1
2
3
let a = 1
Math.floor(a)
console.log(a) // 1

如果在函数 func 返回的时候,调用者还不能够得到预期结果,需要经过一定时间,而且需要在将来通过一定的手段(比如回调)得到,那么这个函数就是异步的。

1
2
3
fs.readFile('foo.txt', 'utf8', function(err, data) {
console.log(data);
});

在执行上面的这段异步代码时,fs.readFile 函数执行时,并不会立刻打印 data ,只有 foo.txt 读取完成时才打印。即异步函数 fs.readFile 不会在主线程中完成读取文件的操作,会由工作线程执行异步任务、通知主线程、主线程回调等操作,这个过程就叫做异步。
如果我们需要处理一些耗时的操作,例如:IO操作、定时/延时执行时,就可以使用异步的方法,不阻塞主线程,充分的利用CPU。
那么 Javascript 又是如何实现异步机制的呢?答案就是通过消息队列(task queue)与事件循环(event loop)。

任务队列与事件循环 - task queue & event loop

任务队列(消息队列):任务队列是一个先进先出的队列,它里面存放着各种异步任务。
事件循环:事件循环是指主线程重复从任务队列中取任务、执行任务的过程。

任务队列 - task queue

任务就是注册异步任务时设定的事件与添加的回调函数。以 Ajax 异步请求为例:

1
2
3
4
$.ajax('XXX', function(res) {
console.log(res)
})
...

主线程在发起 Ajax 请求后,会继续执行其它代码,Ajax 线程负责 HTTP 请求,拿到请求响应后,会封装成 JavaScript 对象,然后构造一条任务:

1
2
3
4
// 消息队列里的消息
var task = function () {
callback(response)
}

其中 callback 是 Ajax 网络请求成功响应时的回调函数。

主线程在执行完当前循环中的所有代码后,就会到任务队列取出这条任务(也就是 task 函数),并执行它。到此为止,就完成了工作线程对主线程的通知 ,异步回调函数也就得到了执行。如果一开始主线程就没有提供回调函数,Ajax 线程在收到 HTTP 响应后,也就没必要通知主线程,从而也没必要往任务队列放消息。

async_inner

事件循环 - event loop

主线程不断的从任务队列中取任务,执行任务,其运行机制称为事件循环(event loop)。每执行一次任务被称作一个循环。事件循环是 JavaScript 实现异步的具体解决方案。在代码执行过程中,同步方法会直接执行,异步的方法先放在任务队列中,待同步方法执行完毕后,轮询执行任务队列中的回调函数。

微任务与(宏)任务 - microtask & macrotask(task)

每执行完一次任务队列中的任务之后,GUI 渲染线程便不会再被 Javascript 线程阻塞,从而进行一次渲染,渲染之后会执行任务队列中的异步任务。那么如果我们想要执行异步任务就必须触发一次渲染吗?不是的,Javascript 提供了另外一种机制微任务机制来在渲染进程之前执行异步的操作。

JavaScript 中有 microtask 和 macrotask(task),它们是均为异步任务的类型,microtask 的优先级(这里指执行顺序)要高于 macrotask。macrotask 用于处理 I/O 和计时器等事件,每次执行一个。microtask 在每个同步 task 结束时执行,并且在每一个事件循环之前,microtask 队列总是被清空(全部执行)。

Microtask 包括如下行为

  • process.nextTick (Nodejs)
  • Promise
  • Object.observe (废弃)
  • MutationObserver

Macrotask(task)包括如下行为

  • setTimeout
  • setImmediate
  • setInterval
  • I/O
  • DOM事件监听

需要注意的是:

  1. 每一个 event loop 都有一个 microtask queue
  2. 每个 event loop 之后会有一个或多个 macrotask ( task queue )
  3. 一个异步任务 task 可以放入 macrotask queue 也可以放入 microtask queue 中
  4. 每一次event loop,会首先执行 microtask queue, 执行完毕后,会提取 macrotask queue 的第一个任务执行,如果这时又有异步任务加入 microtask queue, 会在同步任务执行结束后接着继续执行 microtask queue 里的任务,循环往复