异步之回调

322 阅读6分钟

0. 前言

在这篇文章,以及接下来的几篇文章中,我们就来聊一聊异步。在JavaScript中,异步是经常见到的。最开始的异步解决方案就是回调。因此,在这篇文章中,我们重点关注异步和回调。相关的参考资料为:《你所不知道的JavaScript》中关于异步的部分。

1. 异步

程序中现在运行的部分和将来运行的部分之间的关系就是异步编程的核心。那么,我们如何区分程序现在运行的部分和将来运行的部分呢?我的理解是这样的:

程序现在运行的部分就是某个代码块执行的时候是上一个代码块执行完毕的时候,而程序将来运行的部分是某个代码块在某个特定的条件满足时才会运行,而这个条件并不是可以立即满足的。

ajax请求来说,当我们向服务器发起一个ajax请求,得到服务器返回的响应之后要对这个响应做处理。那么,对于服务器返回响应的处理就是一个异步的代码。因为服务器返回响应这个条件,并不是即刻就能满足的。服务器处理ajax请求并且返回响应需要时间。面对这段时间,我们有两个选择:第一,将程序阻塞,等服务器返回响应之后再进行处理;第二,跳过对服务器返回响应的处理,先去执行别的代码,等到服务器返回响应之后在进行处理。

显然,第二种处理方式会节省程序的等待时间,使得用户有更好的体验。这就是异步。关于异步,我们需要了解三个东西:

  • 事件循环队列
  • 异步和并行
  • 任务队列

1.1 事件循环队列

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()定时器的精度可能不高。

因此,我们可以这么理解事件循环队列: JavaScript程序被分成了很多小块,在事件循环队列中一个接一个地执行。

1.2 异步和并行

在这一部分,我们首先要搞清楚的是异步和并行不是一回事。异步是关于现在和将来的时间间隙,而并行是关于能够同时发成的事情。

  • 并行计算最常见的工具就是进程和线程。进程和线程独立运行,并可能同时运行:在不同的处理器,甚至不同的计算机上,但多个线程能够共享单个进程的内存。
  • 事件循环把自身的工作分成一个个任务并肾虚执行,不允许对共享内存的并行访问和修改。通过分立线程中彼此合作的事件循环,并行和顺序执行可以共存

我们举个例子来说明,请看如下代码:

var a = 20

function foo() {
    a = a + 1
}

function bar() {
    a = a * 2
}

ajax( "http://some.url.1", foo )
ajax( "http://some.url.2", bar )

根据 JavaScript 的单线程运行特性,如果 foo() 运行在 bar() 之前, a 的结果是 42,而如果bar() 运行在 foo() 之前的话,a 的结果就是 41。

如果共享同一数据的 JavaScript 事件并行执行的话,那么问题就变得更加微妙了。我们来看执行的情况:

  • 线程1(X 和 Y 是临时内存地址):
    • foo():
      1. 把 a 的值加载进 X
      2. 把 1 保存在 Y
      3. 执行 X 加 Y,结果保存在 X
      4. 把 X 的值保存在 a
  • 线程2(X 和 Y 是临时内存地址):
    • bar():
      1. 把 a 的值加载进 X
      2. 把 2 保存在 Y
      3. 执行 X 乘 Y,结果保存在 X
      4. 把 X 的值保存在 a

这个过程的同时使用了共享内存地址 X 和 Y 。这样,按照不同的顺序执行,会造成很多不同的结果。比如: 按照1-1 => 2-1 => 1-2 => 2-2 => 1-3 => 1-4 => 2-3 => 2-4这个顺序的执行结果是44;而按照1-1 => 2-1 => 2-2 => 1-2 => 2-3 => 1-3 => 1-4 => 2-4这样的顺序执行结果是21.

因此,多线程编程是很复杂的。所以,JavaScript从不跨线程共享数据。但这并不意味着JavaScript代码的执行结果是可预测的。这一点可以回顾之前的例子。

这里,我们可以提到另一个概念:在JavaScript的特性中,函数顺序的不确定性就是通常所说的竞态条件(race condition)

1.3 任务队列

在 ES6 中,有一个建立在事件循环队列之上的新概念,叫做任务队列。对于任务队列的理解,我就做一下搬运工,搬运一下书中的描述:

任务队列是挂载事件循环队列的每个tick之后的一个队列。在之间循环的每个tick中,可能出现的一部动作不会导致一个完整的新事件添加到事件循环队列中,而会在当前tick的任务队列末尾添加一个项目。

2. 回调

相信用过JavaScript的小伙伴们对于回调函数都不会陌生。回调函数在JavaScript代码中随处可见。比如:

// A
setTimeOut(function() {
    // C
},1000)
// B

在这段代码中,代码片段A和代码片段B都是同步的代码,而代码片段C则是异步代码。这段代码的执行顺序为:执行代码A,然后设定1000ms的延时,再执行代码B;等待1000ms延时之后再执行代码C。

异步的代码中,回调很好用。但是如果回调嵌套的太多了,就很容易陷入回调地狱。除此之外,回调还可能造成以下问题:

  • 调用回调过早
  • 调用回调过晚
  • 调用回调的次数太少或太多
  • 没有把所需的环境/参数成功传递给回调函数
  • 吞掉可能出现的错误和异常