GS 中的异步编程

异步思维是后端架构设计的必备思维,尽管同步代码简单直观且易于维护,但为了构建健壮,可靠的后端系统,对于同步的选择是应该慎之又慎的。为了方便讨论同步可能带来的问题,我将同步请求大概分为三类:

  1. 不带超时的同步请求
  2. 带超时但非关键的同步请求
  3. 带超时且关键的同步请求

第一类同步请求的问题很明显,就是请求方无法对对端的响应有一个最坏预期,即有可能对端挂掉了,永远也不响应,那么请求方就永远被阻塞了。很多时候我们在实现同步语义的时候会忽略超时处理,或者说对对端的可用性作出过高的假设。而我们要在语言机制上实现同步语义是很简单的,比如Go的res := <-chanRet,Erlang的receive Answer -> ok,而对于同步调用失败或无响应的情况却没有过多考虑。好在Go的很多网络库都通过context来规范化了超时处理。

现在我们加上超时,来看看带超时的非关键同步请求,非关键请求是指请求方不必等该请求完成之后,再处理接下来的任务。即如果这个请求是异步的,那么其实请求方也可以先处理接下来的消息。比如地图线程在发起一个DB请求加载某个玩家数据时,这个过程中地图线程其实可以先处理来自其它玩家的请求。在这种情况下,很明显的,同步请求相比异步,降低了地图线程的吞吐量。前段时间公司一个项目出现CPU Load上不去,而Erlang消息队列有堆积的情况,排查了很久,发现是日志库log4erl里面有同步调用,导致写日志的API其实是有阻塞的,然后基本所有的逻辑线程都会写大量日志,导致CPU不能跑满。

最后来看看带超时且关键的同步请求,举个例子,玩家登录的时候,玩家的agent线程会向平台去认证鉴权,即使这个鉴权过程是异步的,agent线程也不能处理接下来的任务,因为玩家还没有鉴权成功,它发来的后续消息是没有意义的,agent要么将接下来收到的消息丢掉,要么将其缓存下来,等鉴权完成再处理。在这种情况下,你可能会觉得用同步总不会有什么问题了吧,既不会降低吞吐量,也不会有永久阻塞的问题,然而我们有项目也遇到过这种情况,玩家多点登录时,新的agent会同步等老的agent走完下线流程之后,再处理后续登录逻辑(这个地方的同步调用没有设置超时),然而如果老的agent在阻塞处理某些请求(这个请求的超时可能比较长),并无法即时响应登出请求,那么新的agent也会阻塞,然后玩家觉得几秒钟没登录上,可能又会再次重启游戏重新登录,这个时候新建的agent仍然会继续阻塞,然后agent数量就会暴增,内存增长等不稳定性。当然,这个事故的部分原因是没有正确设置超时,但也从另一方面揭露了关键同步请求相比异步的缺陷: 虽然异步请求过程中,agent也不能处理后续逻辑消息,但起码agent是可响应的,可响应意味着可以处理一些如终止消息,系统消息等高优先级的任务。前面定义的所谓关键二字,其实是对同等优先级的任务而言的,而往往在实践中,总有意外或者更重要的事情发生。

另外,关于同步请求的一个周知问题就是环形依赖,即A同步请求B,B又直接或间接同步请求A,同步意味着强依赖,随着逻辑复杂度的提升,理想的单向依赖会很难保证和检查,一旦出现环形依赖,轻则请求失败,伴随系统吞吐量降低(有超时的情况),重则环中的线程全部无响应(没做超时的情况)。因此对于以上的三类同步请求,环形依赖都不会带来好结果。

讲了这么多同步的缺点,不是说完全不用同步,而是说慎用同步,要理解同步可能带来的边界问题是什么,比如服务器启停服流程,各种数据模块的加载/保存可能会有顺序依赖,如果做成异步,可能需要维护非常复杂的状态机,并且代码维护成本也比较高,而同步则可以获得清晰的执行流程和错误处理。

相比同步而言,异步看起来更安全,但是相对复杂,比如可能会用到状态机,需要考虑异步请求的上下文等。另外,用异步不意味着上面讲的同步的问题就可以忽略,比如如果没有做超时,对同步而言的代价是可能永久阻塞,而对异步的代价则是”请求沉没”,即请求方在发出异步请求之后,如果对端没有响应,那么这个请求就没有后续处理了,如果日志记录得不好的话,可能都很难追溯到这个请求,因此对于异步请求而言,日志记录,超时机制都是要去考虑的事情。另外,由于异步本身的特性,消息时序性问题也要考虑,比如前面讲的agent在异步执行鉴权操作,如果没有做状态机保护,直接处理后续请求的话,那么在逻辑上来说都是错误的。有时候时序性的问题比这个更复杂,比如A请求的异步请求还没有响应,后续处理的B请求跟A请求有数据相关性,就可能导致数据不一致。一致性的问题我在这里有讨论。

总之,异步很多时候是让服务器不出现大问题(无响应/雪崩/系统吞吐量变低等),但同时也带来了开发复杂度,以及一些”小问题”(请求沉没/数据不一致/逻辑错误等)。

下面来简单谈谈后端几种常见的异步交互机制,及其优缺点。

基于消息

基于消息的通信模式可以说非常古老了,从 C/S 到服务器进程/线程间,这种方案的优点是扩展性很强,不管是跨主机/进程/线程/轻量级线程,可以用同一套通信方案。像 Erlang 这种 Actor 通信模型,就只有一套异步通信模型: 异步消息。这样最大的好处就是完全屏蔽了目标 Process 的物理位置(同一进程/跨进程/跨网络),获得非常好的系统扩展性和灵活性。比如某个 Process 被重新部署到其它节点上,已有代码几乎无需任何更改。基于消息的异步交互会去考虑数据和交互边界,维护尽可能少的上下文。谈了这么多优点,下面我们来谈谈它的缺点:

假设 A 向 B 发起一个请求,B无非分为以下三种情况: 无响应,单次响应,多次响应。这里的响应只是针对本次交互而言,该响应可能也是下次交互的请求,如 A -> B -> A -> B 模式,但为了便于理解,通常我们都会将异步交互简化为请求 -> 响应模式,即 A -> B -> A -> B 通常被理解为 A 对 B 发起了两次请求,第一次有响应,第二次无响应。

OK,理解了请求响应模式,我们来举个例子:

1
2
3
4
5
6
7
8
9
10
11
// A 线程
func handle (x,y,z int) int {
ack := CallB(&AddReq{A:x,B:y})
return ack.Result * z
}

// B 线程
func handleAddReq(req *AddReq) *AddAck {
result := req.A + req.B
return &AddAck{Result: result}
}

例子很简单,A线程需要B线程发起请求(这里是一个简单的加法),然后再继续自己的逻辑,A 通过同步 RPC 很简单地完成了逻辑,现在我们尝试将其换成异步消息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// A 线程
func handle(x,y,z int) {
Send2B(&AddReq{A:x,B:y})
}

// B 线程发送上下文
func handleAddReq(req *AddReq) {
result := req.A + req.B
Send2A(&AddAck{Result:result})
}

// A 线程
func handleAddAck(ack *AddAck) {
// handleReq 中的 z 参数去哪里了?
result = ack.result * z
}

这里忽略了消息编解码,消息注册等细节,但暴露了异步消息交互的基本问题之一: 发送上下文的保存(也叫调用上下文,但这容易将异步消息交互局限在函数调用),这里的上下文是指只有发送方需要用于后续处理,而接收方不关心的内容。在我的理解中,一个完整的异步请求应该考虑到如下四个部分:

  1. 消息内容本身,即上例中的 AddReq
  2. 发送上下文,即上例中的 z
  3. 消息响应路径(消息ID,消息来源)
  4. 超时机制

第一个不必多言,第二个发送上下文,在异步消息交互中,通常发送上下文保存方式有两种:

  1. 将发送上下文与消息内容本身一起发送给接收方(如AddReq{A,B,C}),然后发送方再返回回来,这种方案适用于部分逻辑,但当AddReq有多种上下文时(如AddReq(x,y)*z, AddReq(x,y)-a-b),则无法复用 AddReq,B 需要去关心和拷贝每个发送上下文,代码复用性差。当 B 是 DB 这类公用模块时,这类问题尤其突出。当然,你也可以通过框架去隐藏发送上下文,做到透明传输,但仍然带来了消息负载开销。还有一点就是这种方式很难实现异步超时(上下文丢了)。
  2. 在 A 线程保存发送上下文(通常叫做 Session),然后将这个发送上下文的ID(SessionID)传给B, A再通过这个SessionID找出发送上下文,如 DB 层通常有个 SessionMgr 来保存每次 DB 交互的上下文。这种方式可以根据 SessionID 来做超时。

第三点消息响应路径接收方和发送上下文类似,在上面的例子中,我们是在 handleAddReq 中直接调用 Send2A(&AddAck{Result:result}) 的,这意味这被请求方需要知道它应该返回哪一条消息哪一个线程,这是被硬编码在代码中的,那么当 C,D线程也会请求 B 时,就需要定义 AddReqForC, AddReqForD 请求,与发送上下文的问题一样,不利于代码复用和解耦,在异步消息中,可以通过框架层去解决这个问题,如每次请求附上对应响应 ID(AddAck),并且由框架层识别消息来源(A),这样 B 可以专注地处理 AddReq 本身,消息会被正确地响应给发送方。

第四点超时机制在这里不过多展开,简单来讲就是在具体实践中,我们很少在真正的异步消息机制中去做超时,一是因为这需要额外做更多的基础支撑,如 Session 管理,状态轮询等,二是标准库提供的网络 IO,文件 IO,以及第三方库如 etcd,grpc 都是同步甚至带超时选项的,基于其上封装异步我们可以感知到对端存活状态或消息是否正确到达,通常这个层次的超时就够了。基于响应到达的超时通常没有必要并且容易出错,比如刚超时处理,响应就到达了。

基于回调的异步 RPC

异步 RPC,咋一听可能会觉得很奇怪,这是因为我将 RPC 理解为请求-响应模式,而不是与同步绑定在一起。基于回调可以认为是基于消息之上的一层扩展,有一定的发送上下文保存能力,能够一定程度地简化异步交互,比如我们 GS 中封装的异步调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//  A 线程
func handle(x,y,z) {
self.AsynCall(bChan, &AddReq{A: x, B: y}, handleAddAck, CbCtx{"z":z})
}
// A 线程
// ret: 消息响应
// ctx: AsynCall 中传入的 CbCtx
// err: 异步调用过程中出现的错误
func handleAddAck (ret interface{}, ctx CbCtx, err error) {
// ...
}
// B 线程
// ci: 保存了调用方的 channel,调用上下文,回调函数等
func handleAddReq(ci *CallInfo) {
req := ci.Req.(*AddReq)
result := req.A + req.B
// 直接返回,无需关心调用上下文,响应路径等
ci.Ret(&AddAck{Result: result})
}

简单来讲,就是在 go channel 上封装了一些上下文保存操作,将CbCtx(调用上下文),发送方channel(消息来源),回调函数(相当于响应消息ID)保存到框架(发送方self)中,这种方案相对基于消息更方便,接收方专注于处理请求本身,更容易复用。但我们这里的AsynCall 限定了是一次请求一次响应的(如果是没有响应,则直接用 Cast,如果多个响应,则在最后一个 Ack 前通过 Cast 投递其它响应),因此不是纯正的异步消息语义(尽管基于 channel),而更像是异步 RPC( 也是 AsynCall 名字的来源)。另外这种方式的扩展性也不如消息强。

顺便提一下,用 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 的本意(并且CbCtx 本身的 KV 需要是值语义的),以避免一些不必要的 Bug。

基于回调的异步 RPC 本质上是将消息来源,消息响应路径都打包到了请求数据(CallInfo)中,只不过向应用层屏蔽了这一点,它的使用有一定的局限性,但是它能满足我们绝大部分的需求。

基于协程

基于协程的异步编程前面也讲过了,目前在后端的话, 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,但它们对异步的支持非常有限,比如 grpc 就只支持 C++版本的异步调用,通常依赖应用层的封装。另外,grpc 的双向Stream通信也是个很不错的 feature,我们目前将其用于 Gate 和 Game 的双工通信。

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