异步与回调的设计哲学

本文的例子用 JavaScript 语法给出,但愿读者至少有使用过 Promise 的经验,若是用过 async/await 则更好,对于客户端的开发者,我相信语法不是阅读的瓶颈,思惟才是,所以也能够了解一下异步编程模型的演变过程。javascript

异步编程入门

CPS

CPS 的全称是 (Continuation-Passing Style),这个名词听上去比较高大上(背后涉及到不少数学方面的东西),实际上若是只是想了解什么是 CPS 的话,并非太难。java

咱们看下面这段代码,你确定会以为太简单了:python

function sum(a, b) {
    return a + b;
}

int a = sum(1, 2);   // 第一行业务代码 
console.log(a);   // 第二行业务代码复制代码

隐藏在这两行代码背后的是串行编程的思想,也就是说第一行代码执行出结果之后才会执行第二行代码。编程

可若是 sum 这个函数耗时比较久怎么办呢,通常咱们不会选择等待它执行完,而是提供一个回调,在执行完耗时操做之后再执行回调,同时避免阻塞主线程:数组

function asum(a, b, callback) {
    const r = a + b;
    setTimeout(function () {
        callback(r);
    }, 0);
}

asum(1, 2, r => console.log(r));复制代码

因而,业务方不用等待 asum 的返回结果了,如今它只要提供一个回调函数。这种写法就叫作 CPS。promise

CPS 能够总结为一个很重要的思想: “我不用等执行结果,我先假设结果已经有了,而后描述一下如何利用这个结果,至于调用的时机,由结果提供方负责管理”babel

没什么卵用的 CPS

扯了这么多 CPS,其实我想说的是,不少介绍 Promise 的文章上来就谈 CPS,更有甚者直接聊起了 CPS 的背后数学模型。实际上 CPS 对异步编程没什么卵用,主要是它的概念太广泛,太容易理解了,我敢打赌几乎全部的开发者都或多或少的用过 CPS。网络

毕竟回调函调每一个人都用过,只不过你不必定知道这是 CPS 而已。好比随便举一个 AFNetworking 中的例子:数据结构

NSURLSessionDataTask *dataTask = [manager dataTaskWithRequest:request completionHandler:^(NSURLResponse *response, id responseObject, NSError *error) {
    if (error) {
        NSLog(@"Error: %@", error);
    } else {
        NSLog(@"%@ %@", response, responseObject);
    }
}];复制代码

Promise

写过 JavaScript 的人应该都接触过 Promise,首先明确一个概念,Promise 是一些列规范的总称,现有的规范有 Promise/A、Promise/B、Promise/A+ 等等,每一个规范都有本身的实现,固然也能够本身提供一个实现,只要能知足规范中的描述便可。闭包

写过 Promise 或者 RAC/RxSwift 的读者估计对一长串 then 方法记忆深入,不知道你们是否思考过,为何会设计这种链式写法呢?

我固然不想听到什么“方法调用之后还返回本身”这种废话,要能反复调用 then 方法必然要返回同一个类的对象啊。。。要想搞清楚为何要这么设计,或者为何能够这么设计,咱们先来看看传统的 CPS(基于回调) 写法如何处理嵌套的异步事件。

若是我须要请求第一个接口,而且用这个接口返回的数据请求下一个接口,那代码看起来大概是这样的:

request(url1, parms1, response => {
    // 处理 response
    request(url2, params2, response => {
        // 处理第二个接口的数据
    })
})复制代码

上述代码用伪代码写起来看上去还能接受,不过能够参考 OC 的繁琐代码,试想一个双层嵌套就已经如此麻烦, 三层嵌套该怎么写是好呢?

CPS 的本质

咱们抽象一下上面的逻辑,CPS 的含义是不直接等待异步数据返回,而是传入一个回调函数来处理将来的数据。换句话讲:

回调事件是一个普通事件,内部可能还会发起一个异步事件

这种世界观的好处在于,经过事件的嵌套造成了一套递归模型,理论上可以解决任意多层的嵌套。固然缺点也是显而易见的,语义上的嵌套最终致使了代码上的嵌套,影响了可读性和可维护性。

这种嵌套模型能够用下面这幅图来表示:

CPS 回调的本质

能够看到图中只有两种图形,椭圆形表示通常性事件(回调也是一个事件),而圆角矩形表示一个异步过程,当执行完之后,就会接着执行它链接着的事件。

Promise 的本质

固然,咱们是有办法解决嵌套问题的,俗话说得好:

任何计算机问题均可以经过添加一个中间层来解决

而 Promise 的本质则是下面这幅图:

Promise 的本质

能够看到,咱们引入了新的 Promise 层,一个 Promise 内部封装了异步过程,和异步过程结束之后的回调。若是这个回调的内部能够生成一个新的 Promise。因而嵌套模型就变成了链式模型,这也是为何咱们常常能看到 then 方法的调用链。

须要强调的是,即便你用了 Promise,也能够在回调函数中直接执行异步过程,这样就回到了嵌套模型。因此 Promise 的精髓实际上在于回调函数中返回一个新的 Promise 对象。

Promise 的基本概念

数据结构学得好的读者看到上面这幅图应该会想到链表。不过一个 Promise 内部能够持有多个新的 Promise,因此采用的不是链表结构而是有些相似于多叉树。简化版的 Promise 定义以下:

function Promise(resolver) {
  this.state = PENDING;
  this.value = void 0;
  this.queue = [];   // 持有接下来要执行的 promise
  if (resolver !== INTERNAL) {
    safelyResolveThen(this, resolver);
  }
}复制代码

对一个 Promise 对象调用 then 方法,其实是判断 Promise 的状态是否仍是 PENDING,若是是的话就生成一个新的 Promise 保存在数组中。不然直接执行 then 方法参数中 block。

当一个 Promise 内部执行完之后,好比说是进入了 FULLFILLED 状态,就会遍历本身持有的全部的 Promise 并告诉他们也去执行 resolve 方法,进入 REJECTED 状态也是同理。

若是可以理解这层思想,你就能够理解为何有先后关系顺序的几个异步事件能够用 then 这种同步写法串联了。由于调用 then 其实是预先保留了一个回调,只有当上一个 Promise 结束之后才会通知到下一个 Promise。

Promise 小细节

关于 Promise 的实现原理,这篇文章不想描述太多,感兴趣的读者能够参考 深刻 Promise(一)——Promise 实现详解,读完之后能够看一下做者的后续文章中的四个题目,检验一下是否真的理解了: 深刻 Promise(二)——进击的 Promise

这里我只想强调一下几个容易理解错的地方。首先,Promise 会接受一个函数做为本身的参数,也就是下面代码中的 fucntion (resolve, reject){ /* do something */ }:

var p =    new Promise(function (resolve, reject) {
    resolve('hello');
});
console.log(ppppp);
// 打印出 Promise { 'hello' } 而不是 Promise { 'pedding' }
// 证实 Promise 已经在建立时就决议复制代码

在建立 Promise 时,这个参数函数就会被执行, 执行这个函数须要两个参数 resolevereject,它并非经过 then 方法提供而是由 Promise 在内部本身提供,换句话说这两个参数是已知的。

所以若是按照上述代码来写, 在建立 Promise 时就会马上调用 resolve('hello'),而后把状态标记为 FULLFILLED 而且让内部的 value 值为 "hello"。这样后来执行 then 的时候会判断到 Promise 已经决议,直接把 value 的值放到 then 的闭包中,并且这个过程是异步执行(参考文章中 immediate 的使用)。

有的文章会谈到 Promise 的错误处理,实际上这里没有什么高深的学问或者黑科技。若是在 Promise 内部调用 setTimeout 异步的抛出错误,外面仍是接不到。

Promise 处理错误的原则是提供了一个 reject 回调,而且用 reject 方法来代替抛出错误的作法。这样作至关于约定了一套错误协议,把错误直接转嫁到业务方的逻辑中。

另外一个须要重点理解的是 then 方法提供的闭包中,返回的内容,由于这才是链式模型的核心。

在 Promise 内部的 doResolve 方法中会有如下关键判断:

var then = getThen(value);
if (then) {
    safelyResolveThen(self, then);
} else {
    self.state = FULFILLED;
    self.value = value;
    self.queue.forEach(function (queueItem) {
    queueItem.callFulfilled(value);
    });
}复制代码

所以若是这里的 value 不是基本类型,就会从新走一遍 safelyResolveThen,至关于从新解一遍 Promise 了。

因此正确的异步嵌套逻辑应该是:

var p =    new Promise(function (resolve, reject) {
    resolve('hello');
})
p.then(value => {
    console.log(value);
    return new Promise(function (resolve, reject) {
        resolve('world')
    });
}).then(value => {
    console.log(value);
});

// 第一行打印出 hello
// 第二行打印出 world复制代码

生成器 Generator

咱们先看一个 Python 中的例子,如何打印斐波那契数列的前五个元素:

def fab(max): 
    n, a, b = 0, 0, 1 
    while n < max: 
        print b 
        a, b = b, a + b 
        n = n + 1复制代码

得益于 Python 简洁的语法,函数实现仅用了六行代码:

fab 函数

不过缺点在于, 每次调用函数都会打印全部数字,不能实现按需打印:

for n in fab(5): 
    print n复制代码

咱们先不考虑为何 fab(5) 能放在 in 关键字后面,至少能分次打印就意味着咱们须要一个对象,内部保存上一次的结果,这样才能正确的生成下一个值。

感兴趣的读者能够用对象来实现一下上述需求, 而且对比一下引入对象后带来的复杂度增长。一种既不增长复杂度,也能保留上下文的技术是使用生成器,只须要修改一个单词便可:

def fab(max): 
    n, a, b = 0, 0, 1 
    while n < max: 
        yield b  #原来是 print b
        a, b = b, a + b 
        n = n + 1复制代码

yield 关键字的含义是 当外界调用 next 方法时生成器内部开始执行,直到遇到 yield 关键字,此时把 yield 后面的值传递出去做为 next() 的结果,而后继续执行函数,直到再次遇到 yield 方法时暂停

Generator in JavaScript

上面举 Python 的例子是由于生成器在 Python 中最为简单,最好理解。在 JavaScript 中,生成器的概念稍微复杂一点,主要涉及两个变化。

  1. 要求在 function 后面加上星号(*) 表示这是一个生成器而不是普通函数。
  2. next() 方法能够传递参数,在生成器内部表现为 yield 的返回值。

举个例子:

function* generator(count) {
    console.log(count);
    const result = yield 100
    console.log(result + count);
}

const g = generator(2);  // 什么都不输出
console.log(g.next().value);  // 第一次打印 2,随后打印 100
g.next(9); // 打印 11复制代码

逐行解释一下:

  1. 调用 generator 时,生成器并无执行,因此什么都没有输出。
  2. 调用 g.next 时,函数开始执行,打印 2,遇到 yield,拿到了 yield 生成的内容,也就是 100,传递给 next() 的调用结果,因此第二行打印 100。
  3. 再次调用 next() 方法,生成器内部恢复执行,因为 next() 方法传入参数 9,因此 result 的值是 9,第三行打印 11。

可见 JavaScript 中的生成器经过 yield valuenext(value) 实现了值的内外双向传递。

Generator 的实现

我不知道 Generator 在 JavaScript 和 Python 中的实现原理,然而用 Objective-C 确实能够模拟出来。考虑到生成器内部 运行 -> 等待 -> 恢复运行 的特色,信号量是最佳的实现方案。

yield 实际上就是信号量的 wait 方法,而 next() 实际上就是信号量的 signal 方法。固然还要处理好数据的交互问题。总的来讲思路仍是比较清晰的。

Async/Await

咱们先举一个例子,看一下 Promise 的使用,每次调用函数 p() 都会生成一个新的 Promise 对象,内部的操做是把参数加一并返回,不妨把函数 p 想象成某个耗时操做。

function p(t) {
    return new Promise(function (resolve, reject) {
        setTimeout(function () {
            resolve(t + 1);
        }, t);
    });
}复制代码

假设我须要反复的、线性的执行这个耗时操做,代码将是这样的:

p(0).then( r => {
    console.log(r);
    return p(r);
}).then( r => {
    console.log(r);
    return p(r);
}).then( r => {
    console.log(r);
    return p(r);
});复制代码

可见咱们调用三次 then 方法,执行了三次加一操做,所以会有三行输出,分别是 一、二、3。

回调改成线性

文章的一开头就说了,代码老是线性执行, 遇到异步操做不会进行等待,而是直接设置好回调函数并继续向后执行。

实际上,若是借助于 Generator 暂停、恢复的特性,咱们能够用同步的方式来写异步代码。好比咱们先定义一个生成器 linear() 表示内部将要线性执行异步代码:

function* linear() {
    const r1 = yield p(0);
    console.log(r1);
    const r2 = yield p(r1);
    console.log(r2);
    const r3 = yield p(r2);
    console.log(r3);
}复制代码

咱们看到 yield 的值是一个 Promise 对象,为了拿到这个对象,须要调用 g.next().value。所以为了让第一个输出打印来,代码是这样的:

g.next().value.then(value => {  // 实际上是 Promise.then 的模式
    // 正如上一节 Generator 的例子中所述,第一个 next 会启动 Generator,而且卡在第一个 yield 上
    // 为了让程序向后执行,还须要再调用一次 next,其中的参数 0 会赋值给 r1。
    g.next(0).value.then()
})复制代码

如何模拟完整的三个 Promise 调用呢,这要求咱们的代码不断向内迭代,同时用一个值保存上一次的结果:

let t = 0;
var g = linear();
g.next().value.then(value => {
    t = value;
    g.next(t).value.then(value => {
        t = value;
        g.next(t).value.then(value => {
            t = value;
            g.next(t)
        })
    })
})复制代码

这种写法的运行结果和以前用 then 语法的运行结果彻底一致。

有的读者可能会想问,这种写法彻底没有看到好处啊,反而像是回退到了最初的模式,各类嵌套不利于代码阅读和理解。

然而仔细观察这段代码就会发现,嵌套逻辑中更多的是架构逻辑而非业务逻辑,业务逻辑都放在 Promise 内部实现了,所以这里的复杂代码其实是能够作精简的,它是一个结构高度一致的递归模型。

咱们注意到 g.next().value.then的内部其实是重复了外面的调用过程,如何描述这样的递归呢,有一个小技巧,只要在最外层包一个函数,而后递归执行函数就行:

// 递归必然要有能够递归的函数,所以咱们在外面包装一层函数
function recursive() {
    g.next(t).value.then(value => {
        t = value;
        return value;
    }).then( result => recursive())
}

recursive();复制代码

然而有一个问题在于,咱们必须在 recursive() 函数外面建立生成器 g,不然放在函数内部就会致使递归建立新的。所以咱们能够加一个内部函数处理核心的递归问题,而外部函数处理生成器和临时变量的建立:

function recursive(generator) {
    let t; // 临时变量,用来存储
    var g = linear();  // 建立整个递归过程当中惟一的生成器

    function _recursive() {
        g.next(t).value.then(value => {
            t = value;
            return value;
        }).then(() => _recursive())
    }
    _recursive();
}

recursive(linear);复制代码

能够看到这个 recursive 函数彻底与业务无关,对于任何生成器函数,好比说叫 g,均可以经过 recursive(g) 来进行调用。

这也就经过实际例子简单的证实了即便是异步事件也能够采用同步写法。

须要注明的是,这并非 async/await 语法的真正实现,这种写法的问题在于,await 外面的每一层函数都要标注为 async,然而没办法把每个函数都转换成生成器,而后调用 recursive()

感兴趣的同窗能够了解一下 babel 转换先后的代码

“同步” 写法的设计哲学

标记了 async 的函数返回结果老是一个 Promise 对象,若是函数内部抛出了异常,就会调用 reject 方法并携带异常信息。不然就会把函数返回值做为 resolve 函数的参数调用。

理解了这一点之后,咱们会发现 async/await 实际上是异步操做的向外转移

好比说 p 是一个 Promise 对象,咱们可能会这样写:

async function test() {
  var value = await p;
  console.log('value = ' + value);
  return value;
}
test().then(value => console.log(value));复制代码

咱们必定程度上能够把 test 当作生成器来看:

  1. 调用 test 方法时,首先会执行 test 内部的代码,直到遇到 await。
  2. test 方法暂时退出,执行正常的逻辑,此时 test 的返回值尚不可用,可是它是一个 Promise,能够设置 then 回调。
  3. await 等待的异步操做结束,test 方法返回,执行 then 回调

所以咱们发现异步操做并无消失,也不可能消失,只是从 await 的地方转移到了外面的 async 函数上。若是这个函数的返回值有用,那么外部还得使用 await 进行等待,而且把方法标记为 async

因此我的建议在使用 await 关键字的时候,首先应该判断对异步操做的依赖状况,好比如下场景就很是合适:

async sendRequest(url) {
    const response = await fetch(url);  // 异步请求网络
    const result = await asyncStore(response);  // 获得结果后异步存储数据
}复制代码

考虑到 await 会阻塞执行,若是某个 Promise 后面的代码任然须要执行(好比存储、统计、日志等),则不建议盲目使用 await:

async function test() {
  var s = await fetch(url);
  console.log('这里输出不了啊');
}复制代码

参考资料

  1. Python yield 使用浅析
  2. JavaScript Promise迷你书(中文版)
  3. 36个代码块,带你读懂异常处理的优雅演进
  4. 深刻 Promise(一)——Promise 实现详解
相关文章
相关标签/搜索