用一個簡易的 web chat 說說 Python、Golang、Nodejs 的非同步

wecatch發表於2019-02-22

在 web 程式設計中,經常有業務需要在處理請求時做非同步操作,比如耗時太長的 IO 操作,等非同步執行完成之後再結束請求返回 response 到 client,在這個過程中 client 和 server 一直保持著連線不釋放,也就是當前請求在從 client 的角度看一直處於阻塞狀態,直到請求結束。

之所以稱之為非同步,最重要的特徵就是 server 可以繼續處理其他 request 而不被阻塞

不同語言在處理這種非同步場景的方式是截然不同的,常見的處理策略有:訊息共享(非同步任務佇列)、多執行緒多程式、event(linux signals,nodejs event loop)、協程 coroutine(返回 Future、Promise 代表程式執行的未來狀態),其中 coroutine 是應用最廣泛的,這也是今天此篇的主題。

什麼是 coroutine?簡單來說就是一段可以在特定時刻自由被 suspend、execute、kill 的 program。程式對 coroutine 的控制就像作業系統對 process 的控制,但是代價要低廉的多。這也是很多語言都支援用 coroutine 的方式進行非同步操作的一個重要原因,其中就包括 Golang、Python、JavaScript(ES6)、Erlang 等。

Talk is cheap, show me your code. 在此我們用一個非常簡單的 web chat demo app 來一起表一表 Golang、Python、Nodejs 中的非同步。

Chat demo app 的簡單描述

Demo 只是為了說明 coroutine 在不同語言是如何應用的,因而場景非常簡單:一個內容輸入框,任意 client 傳送的訊息都能在其他 client 顯示。

專案地址

github.com/zhyq0826/ch…

Chat demo app 的工作原理

兩個主要的 API:

  1. /a/message/new 用於訊息傳送,在此稱之為 message-new
  2. /a/message/updates 用於訊息接受,在此稱之為 message-update

Client 通過 message-update 從 server 端獲取最新的訊息,如果沒有新訊息,當次 request 就被掛起,等待新訊息的傳送,當有新訊息來臨,獲取最新訊息之後,斷開 connection,一定間隔之後重新請求 server 繼續獲取新的訊息,並重復之前的過程。

由於 message-update 從 server 獲取訊息的時候有可能需要較長時間的等待,server 會一直持有 client 的連線不釋放,因而要求來自 message-update client 的請求不能阻塞 server 處理其他請求,並且 message-update 在沒有訊息到達時需要一直掛起。

Server 處理 message-update 的過程就是一個非同步的過程

Python 的實現

Python 中用 yield 來實現 coroutine,但是想要在 web 中實現 coroutine 是需要特別處理,在此我們用了 tornado 這個支援 asynchronous network 的 web framework 來實現 message-update 的處理。

Tornado 中一個 Future 代表的是未來的一個結果,在一次非同步請求過程中,yield 會解析 Future,如果 Future 未完成,請求就會繼續等待。

@gen.coroutine   #1
def post(self):
    cursor = self.get_argument("cursor", None)
    # Save the future returned by wait_for_messages so we can cancel
    # it in wait_for_messages
    self.future = GLOBAL_MESSAGE_BUFFER.wait_for_messages(cursor=cursor)
    messages = yield self.future #2
    if self.request.connection.stream.closed():
        return
    self.write(dict(messages=messages))複製程式碼

#1 出通過 tornado 特有的 gen.coroutine 讓當前請求支援 coroutine,#2 是當前請求等待的未來的執行結果,每個 message-update client 都通過 GLOBAL_MESSAGE_BUFFER.wait_for_messages 的呼叫生成一個 future,然後加入訊息等待的列表,只要 future 未解析完成,請求會一直掛起,tornado 就是通過 yield 和 future 的配合來完成一次非同步請求的。

理解 yield 是如何等待 future 完成的過程其實就是理解 Python generator 如何解析的過程,細節我們有機會在表。

Golang 的實現

Golang 天生就在語言層面支援了 coroutine go func() 就可以開啟 coroutine 的執行,是不是很簡單,是不是很刺激,比起 tornado 必須特別處理要賞心悅目的多,而且 go 自帶的 net/http 包實現的 http 請求又天生支援 coroutine,完全不需要類似 tornado 這種第三方 library 來支援了(此處為 Python 2 )。Golang 比 Python 更牛逼的地方在於支援 coroutine 之間使用 channel 進行通訊,是不是更刺激。

func MessageUpdatesHandler(w http.ResponseWriter, r *http.Request) {
    client := Client{id: uuid.NewV4().String(), c: make(chan []byte)} #1
    messageBuffer.NewWaiter(&client) #2
    msg := <-client.c //掛起請求等待訊息來臨 #3
    w.Header().Set("Content-Type", "application/json")
    w.Write(msg)
}複製程式碼

#1 為每個 client 生成 一個唯一的身份和 channel,然後 client 加入訊息 #2 等待列表等候訊息的來臨,#3 就是掛起請求的關鍵點:等待 channel 的訊息。Channel 的通訊預設就是阻塞的,即當前 message-update 這個 coroutine 會被 #3 的等待而掛起不會執行,也就達到了 client 連線不能斷的要求。

Nodejs 的實現

Nodejs 天生非同步,通過 callback 來完成非同步通知的接收和執行。為了演示方便我們用了 express ,在 express 中如果一個請求不主動呼叫 res.endres.sendres.json 請求就不會結束。在 nodejs 中請求如何才能知道訊息到達了,需要 response?Python 我們用了 Future,Golang 用了 channel,Nodejs 實現也絕不僅僅只有一種,在此我們用了事件Promise

Promise 類似 Future,代表的是一次未來的執行,並且在執行完成之後通過 resolve 和 reject 來完成執行結果的通知,then 或 catch 中獲取執行結果。通過 Promise 能有效解決 nodejs 中回撥巢狀以及非同步執行錯誤無法外拋的問題。

Promise 作為一種規範,nodejs 中有多種第三方庫都做了實現,在次我們用了 bluebird 這個 library。

事件是 nodejs 中常用的程式設計模型,熟悉 JavaScript 的同學應該很瞭解了,在此不細表。

app.post(`/a/message/updates`, function(req, res){
    var p = new Promise(function(resolve, reject){
        messageBuffer.messageEmitter.on("newMessage", function(data){  #1
            resolve(data); #2
        });
    });
    var client = makeClient(uuidv4(), p);
    messageBuffer.newWaiter(client);
    p.then(function(data){  #3
        res.set(`Content-Type`, `application/json`);
        res.json({`messages`: data});
    });
});複製程式碼

每個 message-update client 都會生成一個 Promise,並且 Promise 在訊息來臨事件 newMessage #1 觸發以後執行 Promise 的 resolve #2 來告知當前 client 訊息來臨。

小結

三種語言實現非同步的策略是不盡相同的,其中 Golang 的最容易理解也最容易實現,這完全得意於 go 天生對 coroutine 的支援以及強大的 channel 通訊。文中 Python 的實現是基於 Python 2,在 Python 3 中 coroutine 的使用有了很大的改善,但是相比 Golang 還是美中不足。Nodejs 作為天生的非同步後端 JavaScript,想要完全使用 Promise 來發揮其優勢還是需要很多技巧來讓整個呼叫棧都完美支援,不過 ES6 中的 yield,ES7 中的 await/async 對非同步的操作都有了很大改善,他們的處理方式神似 Python(據說 ES6 草案的實現就是一群 Python 程式設計師)。

下次需要非同步支援的專案,你會用哪個語言?

Python 的例子改自 tornado github.com/tornadoweb/…

chat-app 地址 github.com/zhyq0826/ch…

Promise Bluebird bluebirdjs.com/docs/gettin…

tornado tornado.readthedocs.io/en/stable/g…

Golang channel tour.golang.org/concurrency…

掃碼關注 wecatch,獲取最新文章資訊

相關文章