非同步與回撥的設計哲學

bestswifter發表於2017-02-28

本文的例子用 JavaScript 語法給出,希望讀者至少有使用過 Promise 的經驗,如果用過 async/await 則更好,對於客戶端的開發者,我相信語法不是閱讀的瓶頸,思維才是,因此也可以瞭解一下非同步程式設計模型的演變過程。

非同步程式設計入門

CPS

CPS 的全稱是 (Continuation-Passing Style),這個名詞聽上去比較高大上(背後涉及到很多數學方面的東西),實際上如果只是想了解什麼是 CPS 的話,並不是太難。

我們看下面這段程式碼,你肯定會覺得太簡單了:

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。

CPS 可以總結為一個很重要的思想: “我不用等執行結果,我先假設結果已經有了,然後描述一下如何利用這個結果,至於呼叫的時機,由結果提供方負責管理”

沒什麼卵用的 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 方法,執行了三次加一操作,因此會有三行輸出,分別是 1、2、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 實現詳解

相關文章