# YDKJS-异步和性能
如何表达和控制持续一段时间(程序的一部分现在运行,而另一部分则在将来运行——现在和将来之间有段间隙,在这段间隙中,程序没有活跃执行)的程序行为?
几乎所有重要的程序(特别是 JavaScript 程序)都需要通过这样或那样的方法来管理这段时间间隙,这时可能是在等待用户输入、从数据库或文件系统中请求数据、通过网络发送数据并等待响应,或者是在以固定时间间隔执行重复任务(比如动画)。
事实上,程序中现在运行的部分和将来运行的部分之间的关系就是异步编程的核心。
# 分块的程序
可以把 JavaScript 程序写在单个文件中,但是这个程序几乎一定是由多个块构成的。这些块中只有一个是现在执行,其余的则会在将来执行。最常见的块单位是函数。
可能会遇到的问题是程序中将来执行的部分并不一定在现在运行的部分执行完之后就立即执行。
// ajax(..) 是某个库中提供的某个 Ajax 函数
var data = ajax('http://some.url.1')
// data 中通常不会包含 Ajax 结果
console.log(data)
从现在到将来的“等待”,最简单的方法是使用一个通常称为回调函数的函数:
// ajax(..) 是某个库中提供的某个 Ajax 函数
ajax('http://some.url.1', function myCallbackFunction(data) {
console.log(data) // 在这里可以得到 Ajax 结果
})
任何时候,只要把一段代码包装成一个函数,并指定它在响应某个事件(定时器、鼠标点击、Ajax 响应等)时执行,你就是在代码中创建了一个将来执行的块,也由此在这个程序中引入了异步机制。
另外,在许多程序(不只是 JavaScript)中,I/O 是非常低速的阻塞部分。所以,(从页面 /UI 的角度来说)浏览器在后台异步处理控制台 I/O 能够提高性能。
如果在调试的过程中遇到对象在 console.log(..)
语句之后被修改,可你却看到了意料之外的结果,要意识到这可能是这种 I/O 的异步化造成的。
# 事件循环
JavaScript 引擎并不是独立运行的,它运行在宿主环境中。
所有这些环境都有一个共同“点”(thread,也指线程),即它们都提供了一种机制来处理程序中多个块的执行,且执行每块时调用 JavaScript 引擎,这种机制被称为事件循环。
换句话说,JavaScript 引擎本身并没有时间的概念,只是一个按需执行 JavaScript 任意代码片段的环境。“事件”(JavaScript 代码执行)调度总是由包含它的环境进行。
那么具体什么是事件循环?借助下面的伪代码可以很好的说明这个问题:
// eventLoop是一个用作队列的数组
// (先进,先出)
var eventLoop = []
var event
// “永远”执行
while (true) {
// 一次tick
if (eventLoop.length > 0) {
// 拿到队列中的下一个事件
event = eventLoop.shift()
// 现在,执行下一个事件
try {
event()
} catch (err) {
reportError(err)
}
}
}
有一个用 while
循环实现的持续运行的循环,循环的每一轮称为一个 tick
。对每个 tick
而言,如果在队列中有等待事件,那么就会从队列中摘下一个事件并执行。这些事件就是你的回调函数。
需要注意的是 setTimeout(..)
并没有把你的回调函数挂在事件循环队列中。它所做的是设定一个定时器。当定时器到时后,环境会把你的回调函数放在事件循环中,这样,在未来某个时刻的 tick 会摘下并执行这个回调。
如果此时队列中已经存在多个项目,那么就会进入等待,直到前面的任务完成后才会执行,这也解释了为什么 setTimeout(..)
定时器的精度可能不高。
# 并行线程
异步是关于现在和将来的时间间隙,而并行是关于能够同时发生的事情。
并行计算最常见的工具就是进程和线程。进程和线程独立运行,并可能同时运行:在不同的处理器,甚至不同的计算机上,但多个线程能够共享单个进程的内存。
与之相对的是,事件循环把自身的工作分成一个个任务并顺序执行,不允许对共享内存的并行访问和修改。通过分立线程中彼此合作的事件循环,并行和顺序执行可以共存。
并行线程的交替执行和异步事件的交替调度,其粒度是完全不同的。
function later() {
answer = answer * 2
console.log('Meaning of life:', answer)
}
尽管 later()
的所有内容被看作单独的一个事件循环队列表项,但如果考虑到这段代码是运行在一个线程中,实际上可能有很多个不同的底层运算。
在单线程环境中,线程队列中的这些项目是底层运算确实是无所谓的,因为线程本身不会被中断。但如果是在并行系统中,同一个程序中可能有两个不同的线程在运转,这时很可能就会得到不确定的结果。
JavaScript 从不跨线程共享数据,这意味着不需要考虑这一层次的不确定性。但是这并不意味着 JavaScript 总是确定性的。
var a = 20
function foo() {
a = a + 1
}
function bar() {
a = a * 2
}
// ajax(..) 是某个库中提供的某个 Ajax 函数
ajax('http://some.url.1', foo)
ajax('http://some.url.2', bar)
由于 JavaScript 的单线程特性,foo()
(以及 bar())中的代码具有原子性。也就是说,一旦 foo()
开始运行,它的所有代码都会在 bar()
中的任意代码运行之前完成,或者相反。这称为完整运行(run-to-completion)特性。
但就像示例中所展示的,foo()
和 bar()
的相对顺序改变可能会导致不同结果(41 或 42)。但是,这种不确定性是在函数(事件)顺序级别上,而不是多线程情况下的语句顺序级别。
在 JavaScript 的特性中,这种函数顺序的不确定性就是通常所说的竞态条件(race condition),foo()
和 bar()
相互竞争,看谁先运行。具体来说,因为无法可靠预测最终结果,所以才是竞态条件。
# 并发
现在让我们来设想一个展示状态更新列表的网站,其随着用户向下滚动列表而逐渐加载更多内容。要正确地实现这一特性,需要(至少)两个独立的“进程”同时运行(也就是说,是在同一段时间内,并不需要在同一时刻)。
第一个“进程”在用户向下滚动页面触发 onscroll 事件时响应这些事件(发起 Ajax 请求要求新的内容)。第二个“进程”接收 Ajax 响应(把内容展示到页面)。
如果用户滚动页面足够快的话,在等待第一个响应返回并处理的时候可能会看到两个或更多 onscroll
事件被触发,因此将得到快速触发彼此交替的 onscroll
事件和 Ajax 响应事件:
/*
onscroll, 请求1 <--- 进程1启动
onscroll, 请求2
响应1 <--- 进程2启动
onscroll, 请求3
响应2
响应3
onscroll, 请求4
onscroll, 请求5
onscroll, 请求6
响应4
onscroll, 请求7 <--- 进程1结束
响应6
响应5
响应7 <--- 进程2结束
*/
“进程”1 和“进程”2 并发运行(任务级并行),但是它们的各个事件是在事件循环队列中依次运行的。单线程事件循环是并发的一种形式。
# 交互性
两个或多个“进程”在同一个程序内并发地交替运行它们的步骤/事件时,如果这些任务彼此不相关,就不一定需要交互。如果进程间没有相互影响的话,不确定性是完全可以接受的。
var res = {}
function foo(results) {
res.foo = results
}
function bar(results) {
res.bar = results
}
// ajax(..) 是某个库提供的某个 Ajax 函数
ajax('http://some.url.1', foo)
ajax('http://some.url.2', bar)
foo()
和 bar()
是两个并发执行的“进程”,按照什么顺序执行是不确定的。但是,我们构建程序的方式使得无论按哪种顺序执行都无所谓,因为它们是独立运行的,不会相互影响。
这并不是竞态条件 bug
,因为不管顺序如何,代码总会正常工作。
更常见的情况是,并发的“进程”需要相互交流,通过作用域或 DOM 间接交互。如果出现这样的交互,就需要对它们的交互进行协调以避免竞态的出现。
var res = []
function response(data) {
res.push(data)
}
// ajax(..) 是某个库中提供的某个 Ajax 函数
ajax('http://some.url.1', response)
ajax('http://some.url.2', response)
我们假定期望的行为是 res[0]
中放调用 "http://some.url.1"
的结果,res[1]
中放调用 "http://some.url.2"
的结果。有时候可能是这样,但有时候却恰好相反,这要视哪个调用先完成而定。
所以,可以协调交互顺序来处理这样的竞态条件:
var res = []
function response(data) {
if (data.url == 'http://some.url.1') {
res[0] = data
} else if (data.url == 'http://some.url.2') {
res[1] = data
}
}
// ajax(..) 是某个库中提供的某个 Ajax 函数
ajax('http://some.url.1', response)
ajax('http://some.url.2', response)
现在,不管哪一个 Ajax 响应先返回我们都可以得到想要的结果。
另一种可能遇到的并发交互条件有时称为竞态(race),但是更精确的叫法是门闩(latch)。它的特性可以描述为“只有第一名取胜”。在这里,不确定性是可以接受的,因为它明确指出了这一点是可以接受的:需要“竞争”到终点,且只有唯一的胜利者。
# 协作
还有一种并发合作方式,称为并发协作(cooperative concurrency)。这里的重点不再是通过共享作用域中的值进行交互(尽管这也是允许的)。这里的目标是取到一个长期运行的“进程”,并将其分割成多个步骤或多批任务,使得其他并发“进程”有机会将自己的运算插入到事件循环队列中交替运行。
比如,我们需要遍历很长的结果列表进行值转换的 Ajax 响应处理函数:
var res = []
// response(..) 从 Ajax 调用中取得结果数组
function response(data) {
// 添加到已有的 res 数组
res = res.concat(
// 创建一个新的变换数组把所有 data 值加倍
data.map(function(val) {
return val * 2
}),
)
}
// ajax(..) 是某个库中提供的某个 Ajax 函数
ajax('http://some.url.1', response)
ajax('http://some.url.2', response)
这样的“进程”运行时,页面上的其他代码都不能运行,包括不能有其他的 response(..)
调用或 UI 刷新,甚至是像滚动、输入、按钮点击这样的用户事件。
所以,要创建一个协作性更强更友好且不会霸占事件循环队列的并发系统,你可以异步地批处理这些结果。每次处理之后返回事件循环,让其他等待事件有机会运行。
var res = []
function response(data) {
// 一次处理 1000 个
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(..) 是某个库中提供的某个 Ajax 函数
ajax('http://some.url.1', response)
ajax('http://some.url.2', response)
我们把数据集合放在最多包含 1000 条项目的块中。这样,我们就确保了“进程”运行时间会很短,并使用 setTimeout(..0)
(hack)进行异步调度。
# 任务
在 ES6 中,有一个新的概念建立在事件循环队列之上,叫作任务队列(job queue)。
我认为对于任务队列最好的理解方式就是,它是挂在事件循环队列的每个 tick
之后的一个队列。在事件循环的每个 tick
中,可能出现的异步动作不会导致一个完整的新事件添加到事件循环队列中,而会在当前 tick
的任务队列末尾添加一个项目(一个任务)。
事件循环队列类似于一个游乐园游戏:玩过了一个游戏之后,你需要重新到队尾排队才能再玩一次。而任务队列类似于玩过了游戏之后,插队接着继续玩。
设想一个调度任务(直接地,不要 hack)的 API,称其为 schedule(..)
。考虑:
console.log('A')
setTimeout(function() {
console.log('B')
}, 0)
schedule(function() {
console.log('C')
schedule(function() {
console.log('D')
})
})
可能你认为这里会打印出 A B C D,但实际打印的结果是 A C D B。因为任务处理是在当前事件循环 tick
结尾处,且定时器触发是为了调度下一个事件循环 tick
(如果可用的话)。
# 语句顺序
代码中语句的顺序和 JavaScript 引擎执行语句的顺序并不一定要一致。
var a, b
a = 10
b = 30
a = a + 1
b = b + 1
console.log(a + b) // 42
这段代码中没有显式的异步(除了前面介绍过的很少见的异步 I/O),所以很可能它的执行过程是从上到下一行行进行的。
但是,JavaScript 引擎在编译这段代码之后可能会发现通过(安全地)重新安排这些语句的顺序有可能提高执行速度。比如,引擎可能会发现,其实这样执行会更快:
var a, b
a = 10
a++
b = 30
b++
console.log(a + b) // 42
// 或者这样
var a, b
a = 11
b = 31
console.log(a + b) // 42
// 或者甚至这样
// 因为 a 和 b 不会被再次使用
// 我们可以 inline,从而完全不需要它们
console.log(42) // 42
所以说,代码编写的方式(从上到下的模式)和编译后执行的方式之间的联系非常脆弱。