并发编程模型浅谈

当我们设计一个业务模型的代码架构时,不同语言的选择对我们的思维有很大的影响。本文根据个人经验,以设计斗地主服务器为例,试图总结一下其中的区别。

概述

主流设计语言中,C/C++甚至没有线程的概念,全靠调用操作系统自己的接口;Python由于GIL的原因,多线程效率十分底下,一般采用多进程模型;Java是最早采用多线程模型的语言;Erlang是内置Actor模型;Golang是内置CSP模型。值得注意的是,对操作系统而言,最底层的就是线程(不过Windows有内置的Fiber),所以协程(coroutine)在任何语言中其实都可以自己实现,多个协程共同运行在同一个线程中。

多进程设计

如果选用Python作为目标任务的编程语言,显然应该采用多进程模型,在进程内部可以使用协程来加快速度。比如我们用Tornado来完成这个游戏:

  1. 首先可以设计一个路由服务(HTTP服务),完成用户身份校验后,根据负载均衡算法随机分配一个实例,将websocket监听地址返回给客户端。并将这个分配地址记录下来,供用户断线重连使用;
  2. Tornado是单进程单线程模型,这里主要完成游戏逻辑。一个新用户加入后,游戏服在内存中遍历有空闲的房间,将用户加入其中。每个房间有自己的id,游戏逻辑本身靠用户动作来驱动,即通过用户动作来修改房间上下文,直到游戏结束;同时游戏可以使用ioloop的定时器来定义超时,代替用户驱动游戏;
  3. 到游戏结算时,需要入库,可以通过线程池的方法避免阻塞,或者用async-http在路由层完成入库操作。

进程间通信

上述过程中,可以把路由和Tornado设计到一起,使用类似prefork的方案,在路由进程中启动tornado的进程池,这种情况下可以使用python内置的多进程通信组件(multiprocessing包里面的)。不过这个设计显然不是很好,不符合现在微服务的设计理念。

将路由和Tornado作为独立的服务,二者之间的进程通信可以用socket(有应答),或者mq等中间件(无应答),同时也将监听的handler加入ioloop的主循环中。

注意独立进程之间可能存在数据不一致问题,单节点挂掉的话需要考虑如何处理。

多线程设计

以Java的Netty为例。其实和多进程差不多,不过这时候路由和游戏本身肯定是在一个进程里作为一个整体的服务了,那么假设这里通过dns进行负载均衡,用户随机连接到一台服务器上。可以通过redis的setnx这种类分布式锁的机制保证用户断线重连到同一台机器。

  1. 一个单独的线程作为路由服务,将用户分配到不同房间;
  2. 建立房间游戏逻辑线程池。这个线程池可以用netty的,也可以用jdk的。不过棋牌类游戏需要使用大量定时器,所以一般还是用netty的。netty的I/O线程和业务线程在这里是分开的,避免相互阻塞;
  3. 只要保证同一个房间的游戏逻辑总是被同一个线程处理,即可达到无锁编程的目的;

线程间通信

线程间通信的可靠性显然比进程间强了许多,基本无需考虑数据不一致问题。

一般使用语言自带的组件来完成线程间通信,比如Future/Promise(有应答),或者直接submit到对应的线程(无应答)。耗时任务一般采用线程池的方法来避免阻塞。

协程设计

以Go为例。这里就简单多了:

  1. 一个单独的协程完成路由服务;
  2. 每个房间一个单独的协程完成无锁游戏逻辑计算;
  3. 耗时任务单开协程处理,随用随销毁;
  4. 协程间通过channel通信;

联系与区别

按着上面这些分析,其实他们的设计思路大体上是一样的。通过IO多路复用,使用尽量少的系统资源完成更多的任务,通过使同一个房间在同一个线程/协程里,尽量达到无锁编程的目的。

但是由于操作系统的一个基本运行单位是进程,多进程设计其实就是分布式设计。因此多进程需要更多考虑的到数据一致性的问题,进程间通信的代价昂贵。多线程编程和协程其实差不多,但是协程的代价更低,因此可以每个房间一个协程但不能每个房间一个线程。同样,某些耗时的任务协程可以随时开一个新的,用完再销毁。但是线程不行,这么操作的代价有点大,一般需要做一个池化处理。换句话来说,线程编程要更有总体规划一些,要更加精细的设计,因为一个进程最多有几千个线程,但是却可以有上百万个协程。所以Java这边还是建议把耗时操作封装成类,内部使用池化,同时类对外提供阻塞应答和异步调用等常用通信方式。

同时需要注意的是,IO操作还要关心外部资源的限制。比如MySQL读写,并发量不大的时候,可以随时开一个线程去读。但是并发量有限的情况下,就要池化以限制资源(在Go里面可以通过Channel扇入扇出来限制)。

总结

其实道理是相通的,上面的思路其实也大同小异,但是具体到代码的书写难度上,肯定是依次递减的。协程编程的时代早已到来,这也是为啥Go能这么快速流行的一个原因。但是go的channel并不能跨进程通信,所以实际上来说,Erlang这门古早的语言,才是集群设计最终的答案。

注意不管是哪种方案,都有data race的情况,这取决于你对业务的设计和架构。并发编程虽然比以前简单了很多,但是没想清楚的时候,还是很容易出问题的(而且很难调试)。