异步:现在和将来
使用像 JavaScript 这样的语言编程时,很重要但常常被误解的一点是,如何表达和控制持续一段时间的程序行为。
这不仅仅是指从 for 循环开始到结束的过程,当然这也需要持续一段时间(几微秒或几毫秒)才能完成。它是指程序的一部分现在运行,而另一部分则在将来运行——现在和将来之间有段间隙,在这段间隙中,程序没有活跃执行。
事实上,程序中现在运行的部分和将来运行的部分之间的关系就是异步编程的核心。
一、分块的程序
可以把 JavaScript 程序写在单个 .js 文件中,但是这个程序几乎一定是由多个块构成的。这些块中只有一个是现在执行,其余的则会在将来执行。最常见的块单位是函数。
大多数 JavaScript 新手程序员都会遇到的问题是:程序中将来执行的部分并不一定在现在运行的部分执行完之后就立即执行。换句话说,现在无法完成的任务将会异步完成,因此并不会出现人们本能地认为会出现的或希望出现的阻塞行为。
举例来说,考虑一下下面这段代码:
function now() {
return 24
}
function later() {
result = result * 2
console.log('result: ', result)
}
let result = now()
setTimeout(later, 1000) // result: 48
这个程序中有两块:现在执行的部分,以及将来执行的部分。这两块的内容很明显,但这里我们还是要明确指出来。
/* 现在 */
function now() {
return 24
}
function later() {...}
let result = now()
setTimeout(later, 1000)
/* 将来 */
result = result * 2
console.log('result: ', result)
现在这一块在程序运行之后就会立即执行。 setTimeout(...)
还设置了一个定时事件在将来执行,所以函数 later()
的内容会在1000毫秒之后执行。
任何时候,只要把一段代码包装成一个函数,并指定它在响应某个事件(定时器、鼠标点击、Ajax 响应等)时执行,你就是在代码中创建了一个将来执行的块,也由此在这个程序中引入了异步机制。
二、事件循环
JavaScript 引擎并不是独立运行的,它运行在宿主环境中,对多数开发者来说通常就是Web 浏览器。
所有这些环境都有一个共同“点”(thread,也指线程),即它们都提供了一种机制来处理程序中多个块的执行,且执行每块时调用 JavaScript 引擎,这种机制被称为事件循环。
换句话说,JavaScript 引擎本身并没有时间的概念,只是一个按需执行 JavaScript 任意代码片段的环境。“事件”(JavaScript 代码执行)调度总是由包含它的环境进行。
举例来说,如果你的 JavaScript 程序发出一个 Ajax 请求,从服务器获取一些数据,那你就在一个函数(通常称为回调函数)中设置好响应代码,然后 JavaScript 引擎会通知宿主环境:“嘿,现在我要暂停执行,你一旦完成网络请求,拿到了数据,就请调用这个函数。”
然后浏览器就会设置侦听来自网络的响应,拿到要给你的数据之后,就会把回调函数插入到事件循环,以此实现对这个回调的调度执行。
那么,什么是事件循环?
先通过一段伪代码了解一下这个概念 :
// eventLoop是一个用作队列的数组
let eventLoop = []
let event
// "永远"执行
while(true) {
// 一次 tick
if (eventLoop.length > 0) {
// 拿到队列中的下一个事件
event = eventLoop.shift()
// 执行下一个事件
try {
event()
} catch(err) {
console.log(err)
}
}
}
这当然是一段极度简化的伪代码,只用来说明概念。
你可以看到,有一个用 while 循环实现的持续运行的循环,循环的每一轮称为一个 tick。对每个 tick 而言,如果在队列中有等待事件,那么就会从队列中摘下一个事件并执行。这些事件就是你的回调函数。
注意:setTimeout(...)
并没有把你的回调函数挂在事件循环队列中。它所做的是设定一个定时器。当定时器到时后,环境会把你的回调函数放在事件循环中,这样,在未来某个时刻的 tick 会摘下并执行这个回调。
如果这时候事件循环中已经有 20 个项目了会怎样呢?你的回调就会等待。它得排在其他项目后面——通常没有抢占式的方式支持直接将其排到队首。这也解释了为什么 setTimeout(...)
定时器的精度可能不高。
所以换句话说就是,程序通常分成了很多小块,在事件循环队列中一个接一个地执行。
三、任务
在 ES6 中,有一个新的概念建立在事件循环队列之上,叫作任务队列。这个概念给大家带来的最大影响可能是 Promise
的异步特性。
对于任务队列最好的理解方式就是:它是挂在事件循环队列的每个 tick 之后的一个队列。在事件循环的每个 tick 中,可能出现的异步动作不会导致一个完整的新事件添加到事件循环队列中,而会在当前 tick 的任务队列末尾添加一个项目(一个任务)。
一个任务可能引起更多任务被添加到同一个队列末尾。所以,理论上说,任务循环可能无限循环(一个任务总是添加另一个任务,以此类推),进而导致程序的饿死,无法转移到下一个事件循环 tick。
任务和 setTimeout(..0)
hack 的思路类似,但是其实现方式的定义更加良好,对顺序的保证性更强:尽可能早的将来。
小结
- JavaScript 程序总是至少分为两个块:第一块现在运行;下一块将来运行,以响应某个事件。尽管程序是一块一块执行的,但是所有这些块共享对程序作用域和状态的访问,所以对状态的修改都是在之前累积的修改之上进行的。
- 一旦有事件需要运行,事件循环就会运行,直到队列清空。事件循环的每一轮称为一个tick。用户交互、IO 和定时器会向事件队列中加入事件。
参考资料
- 《你不知道的JavaScript(中卷)》
还不错