用一个简易的 web chat 说说 Python、Golang、Nodejs 的异步

在 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 显示。

项目地址

https://github.com/zhyq0826/chat-app-tutorial

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 未完成,请求就会继续等待。

1
2
3
4
5
6
7
8
9
10
@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 进行通信,是不是更刺激。

1
2
3
4
5
6
7
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 的同学应该很了解了,不细表。

1
2
3
4
5
6
7
8
9
10
11
12
13
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 https://github.com/tornadoweb/tornado/tree/master/demos/chat

chat-app 地址 https://github.com/zhyq0826/chat-app-tutorial

Promise Bluebird http://bluebirdjs.com/docs/getting-started.html

tornado https://tornado.readthedocs.io/en/stable/guide/intro.html

Golang channel https://tour.golang.org/concurrency/2

三月沙 wechat
扫描关注 wecatch 的公众号