Tian Jiale's Blog

JavaScript 中的异步编程

什么是异步编程

在谈论异步编程概念之提前,先了解什么是同步编程。同步编程是指程序的执行是按着代码顺序执行的,若一段代码因任何原因而卡住(http 请求或死循环)时,程序将不再往下执行,只有等前面的事件执行完之后,后面的事件才会执行。

异步编程是指在某段耗时较长的代码执行完之前就将代码的执行权交由下面的代码执行,当耗时长的代码执行完之后再重新拿回代码的执行权继续执行。

JavaScript 中的异步编程问题

浏览器的多线程

在浏览器中,每一个 tab 页面都是一个独立的进程,在该进程中同时存在多个线程,每个进程一般有以下几个常驻线程:

  • GUI 渲染线程
  • JavaScript 引擎线程
  • 事件触发线程
  • 定时触发器线程
  • 异步 http 请求线程

GUI 渲染线程

GUI 渲染线程负责渲染浏览器界面 HTML 元素,解析 HTML,CSS,构建 DOM 树和 RenderObject 树,布局和绘制等。

当界面需要重绘(Repaint)或由于某种操作引发回流(重排)(reflow)时,该线程就会执行。

在 Javascript 引擎运行脚本期间,GUI 渲染线程都是处于挂起状态的,也就是说被”冻结”了,GUI 更新会被保存在一个队列中等到 JS 引擎空闲时立即被执行。

JavaScript 引擎线程

JavaScript 引擎,也可以称为 JS 内核,主要负责处理 Javascript 脚本程序,例如 V8 引擎。

JS 引擎一直等待着任务队列中任务的到来,然后加以处理,一个 Tab 页(renderer 进程)中无论什么时候都只有一个 JS 线程在运行 JS 程序(单线程)。

注意:GUI 渲染线程和 JavaScript 引擎线程互斥!

由于 JavaScript 是可操纵 DOM 的,如果在修改这些元素属性同时渲染界面(即 JavaScript 线程和 UI 线程同时运行),那么渲染线程前后获得的元素数据就可能不一致了。

因此为了防止渲染出现不可预期的结果,浏览器设置 GUI 渲染线程与 JavaScript 引擎为互斥的关系,当 JavaScript 引擎执行时 GUI 线程会被挂起,GUI 更新会被保存在一个队列中等到引擎线程空闲时立即被执行。

如果 JS 执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞的感觉。

事件触发线程

当一个事件被触发时该线程会把事件添加到待处理队列的队尾,等待 JS 引擎的处理。

这些事件可以是当前执行的代码块如定时任务、也可来自浏览器内核的其他线程如鼠标点击、AJAX 异步请求等,但由于 JS 的单线程关系所有这些事件都得排队等待 JS 引擎处理。

定时触发器线程

setInterval 与 setTimeout 所在线程

浏览器定时计数器并不是由 JavaScript 引擎计数的, 因为 JavaScript 引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确。

通过单独线程来计时并触发定时(计时完毕后,添加到事件队列中,等待 JS 引擎空闲后执行)

注意,W3C 在 HTML 标准中规定,规定要求 setTimeout 中低于 4ms 的时间间隔算为 4ms。

异步 http 请求线程

在 XMLHttpRequest 在连接后是通过浏览器新开一个线程请求。

将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中,再由 JavaScript 引擎执行。

摘自:浏览器的多线程

事件循环

如图:

698814-20180906145003189-254912994

事件循环是指 JavaScript 引擎线程在工作时不断从任务队列(Event Queue)中获取任务并执行的过程。

宏任务主要包含:script( 整体代码)、setTimeout、setInterval、I/O、UI 交互事件、setImmediate(Node.js 环境)。

微任务主要包含:Promise、MutaionObserver、process.nextTick(Node.js 环境)。

从浏览器的多线程可知,事件触发线程、定时触发器线程、异步 http 请求线程都会向任务队列(Event Queue)中添加宏任务。

参考:

深入理解 JavaScript 事件循环机制

【JS】深入理解事件循环,这一篇就够了!(必看)

实现异步编程

回调函数(callback)

JavaScript 语言对异步编程的实现,就是回调函数。所谓回调函数,就是把任务的第二段单独写在一个函数里面,等到重新执行这个任务的时候,就直接调用这个函数。回调函数的英语名字callback,直译过来就是"重新调用"。以下代码就是一个回调函数的例子:

ajax(url, () => {
  // 处理逻辑
});

但是回调函数有一个致命的弱点,如果出现多重嵌套,代码不是纵向发展,而是横向发展,很快就会乱成一团,无法管理。因为多个异步操作形成了强耦合,只要有一个操作需要修改,它的上层回调函数和下层回调函数,可能都要跟着修改。即产生"回调函数地狱"(callback hell)。例如多个请求存在依赖性,就会有如下代码:

ajax(url, () => {
  // 处理逻辑
  ajax(url1, () => {
    // 处理逻辑
    ajax(url2, () => {
      // 处理逻辑
    });
  });
});

回调函数的优点是简单、容易理解和实现,缺点是不利于代码的阅读和维护,各个部分之间高度耦合,使得程序结构混乱、流程难以追踪(尤其是多个回调函数嵌套的情况),而且每个任务只能指定一个回调函数。此外它不能使用 try catch 捕获错误,不能直接 return。

参考:es6 入门教程JS 异步编程六种方案

事件监听

采用事件驱动模式。任务的执行不取决于代码的顺序,而取决于某个事件是否发生。

还是以 f1 和 f2 为例。首先,为 f1 绑定一个事件(这里采用的 jQuery 的写法)。

f1.on('done', f2);

上面这行代码的意思是,当 f1 发生 done 事件,就执行 f2。然后,对 f1 进行改写:

function f1() {
  setTimeout(function () {
    // f1的任务代码
    f1.trigger('done');
  }, 1000);
}

f1.trigger(‘done’)表示,执行完成后,立即触发 done 事件,从而开始执行 f2。

这种方法的优点是比较容易理解,可以绑定多个事件,每个事件可以指定多个回调函数,而且可以“去耦合”(Decoupling),有利于实现模块化。缺点是整个程序都要变成事件驱动型,运行流程会变得很不清晰。

摘自:Javascript 异步编程的 4 种方法

发布/订阅

上一节的"事件",完全可以理解成"信号"。

我们假定,存在一个"信号中心",某个任务执行完成,就向信号中心"发布"(publish)一个信号,其他任务可以向信号中心"订阅"(subscribe)这个信号,从而知道什么时候自己可以开始执行。这就叫做“发布/订阅模式”(publish-subscribe pattern),又称“观察者模式”(observer pattern)。

这个模式有多种实现,下面采用的是 Ben Alman 的Tiny Pub/Sub,这是 jQuery 的一个插件。

首先,f2 向"信号中心"jQuery 订阅"done"信号。

jQuery.subscribe('done', f2);

然后,f1 进行如下改写:

function f1() {
  setTimeout(function () {
    // f1的任务代码
    jQuery.publish('done');
  }, 1000);
}

jQuery.publish(“done”)的意思是,f1 执行完成后,向"信号中心"jQuery 发布"done"信号,从而引发 f2 的执行。

此外,f2 完成执行后,也可以取消订阅(unsubscribe)。

jQuery.unsubscribe('done', f2);

这种方法的性质与"事件监听"类似,但是明显优于后者。因为我们可以通过查看"消息中心",了解存在多少信号、每个信号有多少订阅者,从而监控程序的运行。

摘自:Javascript 异步编程的 4 种方法

Promise

Promise 对象就是为了解决回调地狱而提出的。它不是新的语法功能,而是一种新的写法,允许将回调函数的嵌套,改成链式调用。采用 Promise,连续执行多个读取文件内容的异步任务,写法如下。

var readFile = require('fs-readfile-promise');

readFile(fileA)
  .then(function (data) {
    console.log(data.toString());
  })
  .then(function () {
    return readFile(fileB);
  })
  .then(function (data) {
    console.log(data.toString());
  })
  .catch(function (err) {
    console.log(err);
  });

上面代码中,我使用了fs-readfile-promise模块,它的作用就是返回一个 Promise 版本的readFile函数。Promise 提供then方法加载回调函数,catch方法捕捉执行过程中抛出的错误。

可以看到,Promise 的写法只是回调函数的改进,使用then方法以后,异步任务的两段执行看得更清楚了,除此以外,并无新意。

Promise 的最大问题是代码冗余,原来的任务被 Promise 包装了一下,不管什么操作,一眼看去都是一堆then,原来的语义变得很不清楚。

摘自:Generator 函数的异步应用

生成器(Generator/yield)

生成器中通过 yield 命令可以在异步任务执行过程时将程序的执行权移出 Generator 函数,并通过以下两种方法交回执行权:

(1)回调函数。将异步操作包装成Thunk 函数,在回调函数里面交回执行权。

(2)Promise 对象。将异步操作包装成 Promise 对象,用then方法交回执行权。

实现第二种方法的库有co,详细介绍见ECMAScript 6 入门教程:co-模块

async/await

async 函数是什么?一句话,它就是 Generator 函数的语法糖。

async函数就是将 Generator 函数的星号(*)替换成async,将yield替换成await

改进:

async函数对 Generator 函数的改进,体现在以下四点。

(1)内置执行器。

Generator 函数的执行必须靠执行器,所以才有了co模块,而async函数自带执行器。也就是说,async函数的执行,与普通函数一模一样,只要一行。

asyncReadFile();

上面的代码调用了asyncReadFile函数,然后它就会自动执行,输出最后结果。这完全不像 Generator 函数,需要调用next方法,或者用co模块,才能真正执行,得到最后结果。

(2)更好的语义。

asyncawait,比起星号和yield,语义更清楚了。async表示函数里有异步操作,await表示紧跟在后面的表达式需要等待结果。

(3)更广的适用性。

co模块约定,yield命令后面只能是 Thunk 函数或 Promise 对象,而async函数的await命令后面,可以是 Promise 对象和原始类型的值(数值、字符串和布尔值,但这时会自动转成立即 resolved 的 Promise 对象)。

(4)返回值是 Promise。

async函数的返回值是 Promise 对象,这比 Generator 函数的返回值是 Iterator 对象方便多了。你可以用then方法指定下一步的操作。

进一步说,async函数完全可以看作多个异步操作,包装成的一个 Promise 对象,而await命令就是内部then命令的语法糖。

摘自:async 函数