GS 中的异步编程

基于消息

基于消息的通信模式可以说非常古老了,从 C/S 到服务器进程/线程间,这种方案的优点是扩展性很强,不管是跨主机/进程/线程/轻量级线程,可以用同一套通信方案。像 Erlang 这种 Actor 通信模型,就只有一套异步通信模型: 异步消息。这样最大的好处就是完全屏蔽了目标 Process 的物理位置(同一进程/跨进程/跨网络),获得非常好的系统扩展性,比如某个 Process 被重新部署到其它节点上,已有代码几乎无需任何更改。基于消息的异步编程应该是最正统的异步编程思维了,每次异步交互都去考虑数据和交互边界,去考虑中间状态,并且维护尽可能少的上下文。基于消息通信的缺点就是麻烦,每次异步交互都涉及到消息定义/注册,上下文传递。

基于回调

基于回调可以认为是基于消息之上的一层扩展,有一定的上下文保存能力,能够一定程度地简化异步调用,比如我们 GS (基于go)中的异步调用:

1
2
3
4
5
6
7
8
// 发起异步调用
self.AsynCall(dbChan, &DBLoaReq{PlayerId: 123}, DBLoadCompleted, CbCtx{"PlayerId":123})
// ret: 消息响应
// ctx: AsynCall 中传入的 CbCtx
// err: 异步调用过程中出现的错误
func DBLoadCompleted(ret interface{}, ctx CbCtx, err error) {
// ...
}

本质上只是在消息机制之上做了一些上下文保存操作,这种方案相对基于消息要方便一些,我们也只是基于 chan 上封装了异步调用,并且限制了 AsynCall 必定是一次请求一次响应的,因此扩展性较弱。另一点是写逻辑时如果用匿名函数或闭包的方式写回调,可能带来的上下文不一致问题,即如果回调函数不重新获取上下文,而通过external local value的方式,会有不一致的风险:

1
2
3
4
5
6
7
func f(p *player) {
lv := player.Level
asynCall(req, func(){
// 此时访问的 lv 为 external local value,可能已经过时! 针对指针数据则更危险
fmt.Println(lv)
})
}

因此我们在实践中都尽量通过多段函数+回调上下文而不是闭包的方式来写回调,这也是 CbCtx 的本意,以避免一些不必要的 Bug。

基于协程

基于协程的异步编程前面也讲过了,目前在后端的话, Lua 用得相对多些,比如skynet,另外,tencent 在 C++ 上实现了一个协程库libco。基于协程实现异步的核心点在于对外部IO使用IO复用+IO线程来实现真正的异步,对逻辑交互通过手动控制切换来在单线程上模拟多个执行体,比如这里的示例。当然,由于要自己来调度任务,享受效率与线程安全性的同时,逻辑实现也要相对复杂一些。我认为协程用于写一些底层交互框架是不错的,但是具体到逻辑开发中,并不是那么易用。

基于轻量级线程

传统多线程开发的难题很多时候都是由于线程新建和切换开销过大导致的,因此现代很多语言都实现了自己的轻量级线程,如 Go 的 goroutine,Erlang 的 process,它们和协程的不同之处在于轻量级线程的调度是语言级的调度器决定的,而不是开发者决定的,实际上,自己用 C 实现一个 thread pool + service 的结构并不难(我以前写了个小 Demo ngserver),真正难的其实在调度器上,比如当某个轻量级线程执行时间过长时,是否应该抢占,如何抢占,何时抢占,如果此时正在执行系统调用呢?在调度器上,Go 和 Erlang 有不同的策略,Go 调度器总的来说就是轻量,去除了 OS 调度器时间片,优先级的概念,抢占机制也比较简单,相对易于理解(参考Go 调度模型),而 Erlang调度器则更看重公平性,时间片,优先级应有尽有,抢占机制也及其复杂,更像一个 OS 调度器。

扯远了,回到我们的轻量级线程上来,现在由于我们有了更轻量级的线程,创建和切换的开销都很低,因此我们可以尝试将逻辑粒度分得更细,比如每个玩家一个轻量级线程,这样玩家在执行一些阻塞操作时,就让它阻塞好了,这其实就是 Actor 编程模型,也是异步编程的一个思路:将服务拆分得更细,阻塞和错误的影响降到更低,这样对于玩家登录这种操作,就不需要复杂的状态机来维护了,直接同步调用就行。目前在我们的Go 服务器中没有用 Actor 模型,主要出于对 SLG 游戏交互复杂度的考量。

基于消息中间件

本质上仍然是消息通信,但加了中间件这一层之后,解耦了服务之间的直接依赖,可以灵活实现如一对一,组播,发布/订阅等通信模式,并且避免了全联通网络。消息中间件是个很好用的东西,我目前也在学习如何在项目中更好地应用它,比如将所有服务器内部服务间的通信由 etcd + grpc/protobuf 换成 RabbitMQ,这样每个服务不需要知道其它服务的地址,通信协议,状态等,只需要知道有这样一个服务,对服务本身来讲,也只有两种外部通信协议,RabbitMQ 消息协议以及 C/S 通信协议,集群的扩展性更强。当然,加了中间件之后,状态性就变弱了,比如可能收到过时消息。

基于 RPC

目前流行的 RPC 框架主要是 grpcthrift,RPC框架和AsynCall类似,这种请求-响应模式会有局限性,比如服务间部分通信是没有响应或者有多个响应的,并且 RPC 框架通常对异步的支持比较有限,比如 grpc 就只支持 C++版本的异步调用。因此我们目前轻度使用 grpc,主要用于Gate 和 Game的双向Stream通信,其它逻辑节点还是以消息为主,相对更加灵活。

总结一下,不同于Web异步编程,GS 更看重可扩展性,讲究组件与组件间的解耦,并且组件之间的交互也更加灵活,因此通常服务之间会通过消息而不是回调或RPC的方式来交互。另外,不同的语言或框架可能提供了不同的轻量级执行体(轻量级线程,协程),在使用时在确定阻塞造成的影响,这需要结合语言调度机制(是否会占用调度线程,是否抢占等),以及执行体划分粒度来分析。