跳到主要内容

异步编程

同步行为异步行为的对立统一是计算机科学的一个基本概念。特别是在 JavaScript 这种单线程事件循环模型中,同步操作与异步操作更是代码所要依赖的核心机制。异步行为是为了优化因计算量大而时间长的操作。如果在等待其他操作完成的同时,即使运行其他指令,系统也能保存稳定,那么这样做就是务实的。

重要的是,异步操作并不一定计算量大或等待时间长。只要你不想为等待某个同步操作而阻塞线程执行,那么任何时候都可以使用异步操作。

同步与异步

同步行为对应内存中顺序执行的处理器指令。每条指令都会严格按照它们出现的顺序来执行,而每条指令执行后也能立即获得存储在系统本地(如寄存器或系统内存)的信息。这样的执行流程容易分析程序在执行到代码任意位置时的状态(比如变量的值)。

演示同步行为
let age = 18;
age = age + 1;
扩展

这两行 JavaScript 代码对应的低级指令(从 JavaScript 到 x86)并不难想象。首先,操作系统会在栈内存上分配一个存储浮点数值的空间,然后针对这个值做一次数学计算,再把计算结果写回之前分配的内存中。所有这些指令都是在单个线程中按顺序执行的。在低级指令的层面,有充足的工具可以确定系统状态。

在程序执行的每一步,都可以推断出程序的状态。这是因为后面的指令总是在前面的指令完成后才会执行。等到最后一条指定执行完毕,存储在 x 的值就立即可以使用。

异步操作类似于系统中断,即当前进程外部的实体可以触发代码执行。异步操作经常是必要的,因为强制进程等待一个长时间的操作通常是不可行的(同步操作则必须等待)。如果代码要访问一些高延迟的资源,比如向远程服务器发送请求并等待响应,那么就会出现长时间的等待。

演示异步行为
let age = 18;

// 使用定时器模拟异步行为
setTimeout(() => age = age + 1, 1000);

这段程序最终与同步代码执行的任务一样,都是把两个数加在一起,但这一次执行线程不知道 age 值 何时会改变,因为这取决于回调何时从消息队列出列并执行。

以往的异步编程模式

异步行为是 JavaScript 的基础,但以前的实现不理想。在早期的 JavaScript 中,只支持定义回调函数来表明异步操作完成。串联多个异步操作是一个常见的问题,通常需要深度嵌套的回调函数(俗称“回调地狱”)来解决。

演示使用了 setTimeout 在一秒钟之后执行某些操作
function double(value) {
setTimeout(() => setTimeout(console.log, 0, value * 2), 1000);
}
double(3);
// 6(大约 1000 毫秒之后)

这里的代码没什么神秘的,但关键是理解为什么说它是一个异步函数。setTimeout 可以定义一个在指定时间之后会被调度执行的回调函数。对这个例子而言,1000 毫秒之后,JavaScript 运行时会把回调函数推到自己的消息队列上去等待执行。推到队列之后,回调什么时候出列被执行对 JavaScript 代码就完全不可见了。还有一点,double() 函数在 setTimeout 成功调度异步操作之后会立即退出。

1. 异步返回值

假设 setTimeout 操作会返回一个有用的值。有什么好办法把这个值传给需要它的地方?广泛接受的一个策略是给异步操作提供一个回调,这个回调中包含要使用异步返回值的代码(作为回调的参数)。

演示异步返回值获取方法
function double(value, callback) {
setTimeout(() => callback(value * 2), 1000);
}
double(3, (x) => console.log(`I was given: ${x}`));
// I was given: 6(大约 1000 毫秒之后)

这里的 setTimeout 调用告诉 JavaScript 运行时在 1000 毫秒之后把一个函数推到消息队列上。这个函数会由运行时负责异步调度执行。而位于函数闭包中的回调及其参数在异步执行时仍然是可用的。

2. 失败处理

异步操作的失败处理在回调模型中也要考虑,因此自然就出现了成功回调函数和失败回调函数:

演示代码
function double(value, success, failure) {
setTimeout(() => {
try {
if (typeof value !== "number") {
throw "Must provide number as first argument";
}
success(2 * value);
} catch (e) {
failure(e);
}
}, 1000);
}

/**
* @handler successCallback 成功回调函数
* @handler failureCallback 失败回调函数
*/
const successCallback = (result) => console.log(`Success: ${result}`);
const failureCallback = (error) => console.log(`Failure: ${error}`);

double(3, successCallback, failureCallback); // Success: 6(大约 1000 毫秒之后)
double('b', successCallback, failureCallback); // Failure: Must provide number as first argument(大约 1000 毫秒之后)

注意

这种模式已经不可取了,因为必须在初始化异步操作时定义回调。异步函数的返回值只在短时间内存在,只有预备好将这个短时间内存在的值作为参数的回调才能接收到它。

3. 嵌套异步回调

如果异步返值又依赖另一个异步返回值,那么回调的情况还会进一步变复杂。在实际的代码中,这就要求嵌套回调:

演示异步嵌套
function double(value, success, failure) {
setTimeout(() => {
try {
if (typeof value !== "number") {
throw "Must provide number as first argument";
}
success(2 * value);
} catch (e) {
failure(e);
}
}, 1000);
}
const successCallback = (x) => {
// 依赖成功回调函数的返回 x
double(x, (y) => console.log(`Success: ${y}`));
};
const failureCallback = (error) => console.log(`Failure: ${error}`);

double(3, successCallback, failureCallback);
// Success: 12(大约 1000 毫秒之后)

显然,随着代码越来越复杂,回调策略是不具有扩展性的。“回调地狱”这个称呼可谓名至实归。嵌套回调的代码维护起来就是噩梦。