# 异步
事实上,程序中现在 运行的部分和将来 运行的部分之间的关系就是异步编程的核心。
实际上,所有重要的程序(特别是 JavaScript 程序)都需要通过这样或那样的方法来管理这段时间间隙,这时可能是在等待用户输入、从数据库或文件系统中请求数据、通过网络发送数据并等待响应,或者是在以固定时间间隔执行重复任务(比如动画)。
# 分块的程序
可以把 JavaScript 程序写在单个 .js 文件中,但是这个程序几乎一定是由多个块构成的。这些块中只有一个是现在 执行,其余的则会在将来 执行。最常见的块 单位是函数。
程序中将来 执行的部分并不一定在现在 运行的部分执行完之后就立即执行。换句话说,现在 无法完成的任务将会异步完成,因此并不会出现人们本能地认为会出现的或希望出现的阻塞行为。
从现在 到将来 的“等待”,最简单的方法(但绝对不是唯一的,甚至也不是最好的!)是使用一个通常称为回调函数 的函数:
代码语言:javascript复制ajax('http://example.com/', function(response) {
console.log(response);
});
任何时候,只要把一段代码包装成一个函数,并指定它在响应某个事件(定时器、鼠标点击、Ajax 响应等)时执行,就是在代码中创建了一个将来 执行的块,也由此在这个程序中引入了异步机制。
# 异步控制台
并没有什么规范或一组需求指定 console.*
方法族如何工作——它们并不是 JavaScript 正式的一部分,而是由宿主环境添加到 JavaScript 中的。
因此,不同的浏览器和 JavaScript 环境可以按照自己的意愿来实现,有时候这会引起混淆。
在某些条件下,某些浏览器的 console.log()
并不会把传入的内容立即输出。出现这种情况的主要原因是,在许多程序(不只是 JavaScript)中,I/O 是非常低速的阻塞部分。所以,(从页面 /UI 的角度来说)浏览器在后台异步处理控制台 I/O 能够提高性能,这时用户甚至可能根本意识不到其发生。
如果遇到这种少见的情况,最好的选择是在 JavaScript 调试器中使用断点,而不要依赖控制台输出。次优的方案是把对象序列化到一个字符串中,以强制执行一次“快照”,比如通过 JSON.stringify()
。
# 事件循环
JavaScript 的宿主环境提供了一种机制来处理程序中多个块的执行,且执行每块时调用 JavaScript 引擎,这种机制被称为事件循环 。
换句话说,JavaScript 引擎本身并没有时间的概念,只是一个按需执行 JavaScript 任意代码片段的环境。“事件”(JavaScript 代码执行)调度总是由包含它的环境进行。
代码语言:javascript复制// eventLoop 是一个队列
var eventLoop = [];
var event;
while (true) {
// 一次 tick
if (eventLoop.length > 0) {
event = eventLoop.shift();
try {
event();
} catch (error) {
reportError(error);
}
}
}
事件循环,可以简单看做,有一个用 while 循环实现的持续运行的循环,循环的每一轮称为一个 tick。对每个 tick 而言,如果在队列中有等待事件,那么就会从队列中摘下一个事件并执行。这些事件就是回调函数。
注意!setTimeout()
并没有把回调函数挂在事件循环队列中。它所做的是设定一个定时器。当定时器到时后,环境会把回调函数放在事件循环中,这样,在未来某个时刻的 tick 会摘下并执行这个回调。
所以换句话说就是,程序通常分成了很多小块,在事件循环队列中一个接一个地执行。严格地说,和你的程序不直接相关的其他事件也可能会插入到队列中。
# 并行线程
异步是关于现在 和将来 的时间间隙,而并行是关于能够同时发生的事情。
并行计算最常见的工具就是进程 和线程 。进程和线程独立运行,并可能同时运行:在不同的处理器,甚至不同的计算机上,但多个线程能够共享单个进程的内存。
事件循环把自身的工作分成一个个任务并顺序执行,不允许对共享内存的并行访问和修改。通过分立线程中彼此合作的事件循环,并行和顺序执行可以共存。
并行线程的交替执行和异步事件的交替调度,其粒度是完全不同的。
JavaScript 从不跨线程共享数据,这意味着不需要考虑这一层次的不确定性。但是这并不意味着 JavaScript 总是确定性的。
代码语言:javascript复制// 尽管 later() 的所有内容被看作单独的一个事件循环队列表项,
// 但如果考虑到这段代码是运行在一个线程中,
// 实际上可能有很多个不同的底层运算。
function later () {
answer = answer * 2;
console.log(answer);
}
在单线程环境中,线程队列中的这些项目是底层运算确实是无所谓的,因为线程本身不会被中断。但如果是在并行系统中,同一个程序中可能有两个不同的线程在运转,这时很可能就会得到不确定的结果。
代码语言:javascript复制var a = 20;
function foo () {
a = a 1;
}
function bar () {
a = a * 2;
}
ajax('/foo', foo);
ajax('/bar', bar);
所以,多线程编程是非常复杂的。因为如果不通过特殊的步骤来防止中断和交错运行的话,可能会得到出乎意料的、不确定的行为,通常这很让人头疼。
由于 JavaScript 的单线程特性,foo()
(以及 bar()
)中的代码具有原子性。也就是说,一旦 foo()
开始运行,它的所有代码都会在 bar()
中的任意代码运行之前完成,或者相反。这称为完整运行 (run-to-completion)特性。
在 JavaScript 的特性 中,函数顺序的不确定性就是通常所说的竞态条件 (race condition),foo()
和 bar()
相互竞争,看谁先运行。具体来说,因为无法可靠预测 a 和 b 的最终结果,所以才是竞态条件。
# 并发
设想一个展示状态更新列表(比如社交网络新闻种子)的网站,其随着用户向下滚动列表而逐渐加载更多内容。要正确地实现这一特性,需要(至少)两个独立的“进程”同时运行(也就是说,是在同一段时间内,并不需要在同一时刻)。
第一个“进程”在用户向下滚动页面触发 onscroll
事件时响应这些事件(发起 Ajax 请求要求新的内容)。第二个“进程”接收 Ajax 响应(把内容展示到页面)。
“进程”之所以打上引号,是因为这并不是计算机科学意义上的真正操作系统级进程。这是虚拟进程,或者任务,表示一个逻辑上相关的运算序列。之所以使用“进程”而不是“任务”,是因为从概念上来讲,“进程”的定义更符合这里使用的意义。
两个或多个“进程”同时执行就出现了并发,不管组成它们的单个运算是否并行 执行(在独立的处理器或处理器核心上同时运行)。可以把并发看作“进程”级(或者任务级)的并行,与运算级的并行(不同处理器上的线程)相对。
# 非交互
两个或多个“进程”在同一个程序内并发地交替运行它们的步骤 / 事件时,如果这些任务彼此不相关,就不一定需要交互。如果进程间没有相互影响的话,不确定性是完全可以接受的 。
# 交互
更常见的情况是,并发的“进程”需要相互交流,通过作用域或 DOM 间接交互。如果出现这样的交互,就需要对它们的交互进行协调以避免竞态的出现。
代码语言:javascript复制var res = [];
function response (data) {
res.push(data);
}
ajax('/foo', response);
ajax('/bar', response);
这里的并发“进程”是这两个用来处理 Ajax 响应的 response()
调用。它们可能以任意顺序运行。
这种不确定性很有可能就是一个竞态条件 bug。所以,可以协调交互顺序来处理这样的竞态条件:
代码语言:javascript复制var res = [];
function response(data) {
if (data.url == '/foo') {
res[0] = data;
} else if (data.url == '/bar') {
res[1] = data;
}
}
ajax('/foo', response);
ajax('/bar', response);
通过简单的协调,就避免了竞态条件引起的不确定性。
# 协作
还有一种并发合作方式,称为并发协作 (cooperative concurrency)。这里的重点不再是通过共享作用域中的值进行交互(尽管显然这也是允许的!)。这里的目标是取到一个长期运行的“进程”,并将其分割成多个步骤或多批任务,使得其他并发“进程”有机会将自己的运算插入到事件循环队列中交替运行。
比如,遍历一个很长的列表进行值转换的 Ajax 响应处理函数,会使用 Array.prototype.map()
让代码更简洁:
var res = [];
function response(data) {
res = res.concat(
data.map(function (val) {
return val * 2;
})
);
}
ajax('/foo', response);
ajax('/bar', response);
如果列表记录有上千万条,可能需要运行很久。这样的“进程”运行时,页面上的其他代码都不能运行,包括不能有其他的 response()
调用或 UI 刷新,甚至是像滚动、输入、按钮点击这样的用户事件。
所以,要创建一个协作性更强更友好且不会霸占事件循环队列的并发系统,可以异步地批处理这些结果。每次处理之后返回事件循环,让其他等待事件有机会运行。
一种简单的处理方法:
代码语言:javascript复制var res = [];
function response(data) {
var chunk = data.splice(0,, 1000);
res = res.concat(
chunk.map(function (val) {
return val * 2;
})
);
if (data.length > 0) {
// 异步调度下一次批处理
setTimeout(function () {
response(data);
}, 0);
}
}
ajax('/foo', response);
ajax('/bar', response);
这里使用 setTimeout(fn, 0)
(hack)进行异步调度,基本上它的意思就是“把这个函数插入到当前事件循环队列的结尾处”。不能保证会严格按照调用顺序处理,所以各种情况都有可能出现,比如定时 器漂移,在这种情况下,这些事件的顺序就不可预测。
# 任务
在 ES6 中,有一个新的概念建立在事件循环队列之上,叫作任务队列 (job queue)。
任务队列可以理解为,是挂在事件循环队列的每个 tick 之后的一个队列。在事件循环的每个tick 中,可能出现的异步动作不会导致一个完整的新事件添加到事件循环队列中,而会在当前 tick 的任务队列末尾添加一个项目(一个任务)。像是在说:“这里还有一件事将来 要做,但要确保在其他任何事情发生之前就完成它。”
一个任务可能引起更多任务被添加到同一个队列末尾。所以,理论上说,任务循环 (job loop)可能无限循环(一个任务总是添加另一个任务,以此类推),进而导致程序的饿死,无法转移到下一个事件循环 tick。
任务和 setTimeout(fn, 0)
hack 的思路类似,但是其实现方式的定义更加良好,对顺序的保证性更强:尽可能早的将来。
# 语句顺序
代码中语句的顺序和 JavaScript 引擎执行语句的顺序并不一定要一致。
# 回调
# continuation
代码语言:javascript复制// A
ajax('/foo', function (data) {
// C
});
// B
// C
,会在未来的某个时刻被执行。回调函数包裹或者说封装了程序的延续(continuation)。
# 嵌套回调与链式回调
嵌套回调常常被称为回调地狱 (callback hell),有时也被称为毁灭金字塔 (pyramid of doom)。
可以通过手工硬编码更好地线性(追踪)代码。但一旦你指定(也就是预先计划)了所有的可能事件和路径,代码就会变得非常复杂,以至于无法维护和更新。这才是回调地狱的真正问题所在!
我们的顺序阻塞式的大脑计划行为无法很好地映射到面向回调的异步代码。这就是回调方式最主要的缺陷:对于它们在代码中表达异步的方式,我们的大脑需要努力才能同步得上。
# 信任问题
代码语言:javascript复制// A
ajax('/foo', function (data) {
// C
});
// B
// C
会延迟到 将来 发生,可能是在第三方的控制下。从根本上来说,这种控制的转移通常不会给程序带来很多问题。
但是,请不要被这个小概率迷惑而认为这种控制切换不是什么大问题。实际上,这是回调驱动设计最严重(也是最微妙)的问题。它以这样一个思路为中心:有时候 ajax()
(也就是你交付回调 continuation 的第三方)不是你编写的代码,也不在你的直接控制下。多数情况下,它是某个第三方提供的工具。
把这称为控制反转 (inversion of control),也就是把自己程序一部分的执行控制交给某个第三方。在你的代码和第三方工具(一组你希望有人维护的东西)之间有一份并没有明确表达的契约。
不管你怎么做,类型的检查 / 规范化的过程对于函数输入是很常见的,即使是对于理论上完全可以信任的代码。大体上说,这等价于那条地缘政治原则:“信任,但要核实。”
但是,回调并没有为我们提供任何东西来支持核实检查行为。我们不得不自己构建全部的机制,而且通常为每个异步回调重复这样的工作最后都成了负担。
回调最大的问题是控制反转,它会导致信任链的完全断裂。
# 回调变体
- 分离回调
在这种设计下,API 的出错处理函数 failure()
常常是可选的,如果没有提供的话,就是假定这个错误可以吞掉。
function success (data) {
console.log(data);
}
function error (data) {
console.error(data);
}
ajax('/foo', success, error);
- “error-first 风格”
有时候也称为“Node 风格”,因为几乎所有 Node.js API 都采用这种风格,其中回调的第一个参数保留用作错误对象(如果有的话)。如果成功的话,这个参数就会被清空 / 置假(后续的参数就是成功数据)。不过,如果产生了错误结果,那么第一个参数就会被置起 / 置真(通常就不会再传递其他结果):
代码语言:javascript复制function response(err, data) {
if (err) {
console.error(err);
} else {
console.log(data);
}
}
ajax('/foo', response);
不过这样还是存在一些问题,首先,这并没有像表面看上去那样真正解决主要的信任问题。这并没有涉及阻止或过滤不想要的重复调用回调的问题。另外,不要忽略这个事实:尽管这是一种你可以采用的标准模式,但是它肯定更加冗长和模式化,可复用性不高,所以你还得不厌其烦地给应用中的每个回调添加这样的代码。
可以通过设置超时来取消事件,来应对完全不调用这个信任问题。
代码语言:javascript复制function timeoutify (fn, delay) {
var intv = setTimeout(function () {
intv = null;
fn(new Error('timeout'));
}, delay);
return function () {
if (intv) {
clearTimeout(intv);
fn.apply(null, arguments);
}
};
}
function foo (err, data) {
if (err) {
console.error(err);
} else {
console.log(data);
}
}
ajax('/foo', timeoutify(foo, 1000));