Promise不是Callback

uglee發表於2020-05-11

這一篇是在實際工程中遇到的一個難得的例子;反映在Node裡兩種程式設計正規化的設計衝突。這種衝突具有普適性,但本文僅分析問題本質,不探討更高層次的抽象。


我在寫一個類似HTTP的資源協議,叫RP,Resource Protocol,和HTTP不同的地方,RP是構建在一箇中立的傳輸層上的;這個傳輸層裡最小的資料單元,message,是一個JSON物件。

協議內建支援multiplexing,即一個傳輸層連線可以同時維護多個RP請求應答過程。

考慮客戶端request類設計,類似Node內建的HTTP Client,或流行的npm包,如requestsuperagent

可以採用EventEmitter方式emit errorresponses事件,也可以採用Node Callback的形式,需要使用者提供介面形式為(err, res) => {}的callback函式。

隨著async/await的流行,request類也可以提供一個.then介面,用如下方式實現(實際上superagent就是這麼實現的):

class Request extends Duplex {
    constructor () {
        super()
        ...
        this.promise = new Promise((resolve, reject) => {
            this.resolve = resolve
            this.reject = reject
        })
    }
    
    then (...args) {
        return this.promise.then(...args)
    }
}

RP的實際設計,形式和大家熟悉的HTTP Client有一點小區別,response物件本身不是stream,而是把stream做為一個property提供。換句話說,callback函式形式為:

(err, { data, chunk, stream }) => {}

如果請求返回的不是stream,則data或者chunk有值;如果返回的是stream,則僅stream有值,且為stream.Readable型別。

這個形式上的區別和本文要討論的問題無關。


RP底層從傳輸層取二進位制資料,解析出message,然後emit給上層;它採用了一個簡單方式,迴圈解析收到的data chunk,直到沒有完整的message為止。

這意味著可以在一個tick裡分發多個訊息。request物件也必須能夠在一個tick裡處理多個來自服務端的訊息。

我們具體要討論的情況是伺服器連續發了這樣兩條訊息:

  1. status 200 with stream
  2. abort

第一條意思是後面還有message stream,第二條abort指server發生意外無法繼續傳送了。

request物件收到第一條訊息時,它建立response物件,包含stream物件:

this.res = { stream: new stream.Readabe({...}) }
// this.emit('response', this.res)
// this.callback(null, this.res)
this.resolve(this.res)

象註釋中emit或trigger使用者提供的callback,都沒有問題;但如果呼叫resolve,注意,Promise是保證非同步的,這意味著使用者通過then提供的onFulfilled,不會在當前tick被呼叫。

接下來第二條訊息,abort,在同一個tick被處理;但這個時候,因為使用者還沒來得及掛載上任何listener,包括error handler,如果設計上要求這個stream emit error——很合理的設計要求——此時,按照Node的約定,error沒有handler,整個程式crash了。


這個問題的dirty fix有很多種辦法。

首先request.handleMessage方法,如果無法同步完成對message的處理,而message的處理順序又需要保證,它應該buffer message,這是node裡最常見的一種synchronize方式,代表性的實現就是stream.Writable

但這裡有一個困難,this.resolve這個函式沒有callback提供,必須預先知道執行環境的Promise實現方式;在node裡是nextTick,所以在this.resolve之後nextTick一下,同時buffer其它後續訊息的處理,可以讓使用者在onFulfilled函式中給stream掛載上handler。


這裡可以看出,callback和emitter實際上是同步的。

當呼叫callback或者listener時,request和使用者做了一個約定,你必須在這個函式內做什麼(在物件上掛載所有的listener),然後我繼續做什麼(處理下一個訊息,emit data或者error);這相當於是interface protocol對順序的約定。

我們可以稱之為synchronous sequential composition,是程式語義意義上的。

對應的asynchronous版本呢?

如果我們不去假設執行環境的Promise的實現呢?它應該和同步版本的語義一樣對吧。


再回頭看看問題,假如stream emit error不會導致系統crash,使用者在onFulfilled拿到{ stream }這個物件時,它看到了什麼?一個已經發生錯誤後結束了的stream。

這個可能使用上會難過一點,需要判斷一下,但還感覺不出是多大的問題。

再進一步,如果是另一種情況呢?Server在一個chunk裡發來了3個訊息;

  1. status 200 with stream
  2. data
  3. abort

這個時候使用者看到的還是一個errored stream,data去哪裡了呢?你還能說asynchronous sequential composition的語義和synchronous的一致麼?不能了對吧,同步的版本處理了data,很可能對結果產生影響。

在理想的情況下,sequential composition,無論是synchronous的,還是asynchronous的,語義(執行結果)應該一致。

那麼來看看如何做到一個與Promise A+的實現無關的做法,保證非同步和同步行為一致。

如果你願意用『通訊』理解計算,這個問題的答案很容易思考出來:假想這個非同步的handler位於半人馬座阿爾法星上,那我們唯一能做的事情是老老實實按照事件發生的順序,傳送給它,不能打亂順序,就像我們收到他們時一樣。

但是當我們把進來的message,翻譯實現成stream時,沒能保證這個order,包括:

  1. abort訊息搶先/亂序
  2. data訊息丟失了

這是問題的root cause,當我們非同步處理一個訊息序列時,前面寫的實現break了順序和內容的完整性。


在數學思維上,我們說Promise增加了一個callback/EventEmitter不具備的屬性,deferred evaluation,是一個程式設計中罕見的temporal屬性;當然這不奇怪,因為這就是Promise的目的。

同時Promise -> Value還有一個屬性是它可以被不同的使用者訪問多次,保持了Value的屬性。

這也不奇怪。

只是Stream作為一種體積上可以為無窮大的值,在實踐中不可能去cache所有的值,把它整體當成一個值處理,所以這個可以被無限提取的『值』屬性就消失了。


但是這不意味著stream作為一個物件,它的行為,不能延遲等到它被構造且使用後才開始處理訊息。

一種方式是寫一個stream有這種能力的;stream.Readable有一個flow屬性,必須通過readable.resume開始,這是一個觸發方式;另一個方式是有點tricky,可以截獲response.stream的getter,在它第一次被訪問時觸發非同步處理buffered message。

這樣的做法是不需要依賴Promise A+的實現的;但不是百分百asynchronous sequential composition,因為stream的handler肯定是synchronous的。

完全的asynchronous可以參照Dart的使用await消費stream的方式。

它的邏輯可以這樣理解:把所有Event,無論哪裡來的,包括error,都寫到一個流裡去,用await消費這個流;但實際上在await返回的時候仍然面對一個狀態機,好處是

  1. throw給力;
  2. 流程等待方便,即處理流輸出的物件時還可以有await語句,在取下一個流輸出的物件之前,相當於一種blocking;但這種blocking需要慎重,它是反併發的;

總結:

Node的Callback和EventEmitter在組合時handler/listener是同步的;Promise則反過來保證每個handler/listener都是非同步組合,這是兩者的根本區別。

在順序組合函式(或者程式代數意義上的程式)上,同步組合是緊耦合的;它體現在一旦功能上出現什麼原因,需要把一個同步邏輯修改成非同步時,都要大動干戈,比如本來是讀取記憶體,後來變成了讀取檔案。

如果程式天生寫成非同步組合,類似變化就不會對實現邏輯產生很大影響;但是細粒度的非同步組合有巨大的效能損失,這和現代處理器和編譯器的設計與實現有關。

真正理想的情況應該是開發者只表達“順序”,並不表達它是同步還是非同步實現;就像前面看到的,實際上同步的實現都有可以對應的非同步實現,差別只是執行效率和記憶體使用(buffer有更多的記憶體開銷,同步處理實際上更多是『閱後即焚』);

但我們使用的imperative langugage不是如此,它在強制你表達順序;而另外一類號稱未來其實狗屎的語言,在反過來強制你不得表達順序。

都是神經病。學術界就不會真正理解產業界的實際問題。

相關文章