-
Notifications
You must be signed in to change notification settings - Fork 11
Home
stone是一个游戏服务器引擎。
- why stone?(为什么要搞这样一个游戏引擎?)
因为想搞一个NB的服务器引擎,那一个好的游戏服务器的设计是什么样的呢?我的理解它是这样的:
-
易用性。把这点放到第一位是因为我是一个非常追求代码质量的人,当然这包括很多个大小不同的维度,强烈鄙视不负责任的屎一样的代码以及coder,我是martin和kent的死忠追随者,所以更要写出junit一样好用又好看的东西。引擎的设计要和互联网产品的设计一样,要都很容易让用户上手,有很好的用户体验。那引擎的用户就是coder了,如何能设计足够良好以及简单的api以及架构,让即使是小白coder也不要望而却步,并且可以在使用过程中体会到代码如诗的重要性加深自己功底的同时可以有助于为这个行业构建更好的生态环境。
-
扩展性。这里分为两个方面,一方面是引擎本身的结构方面,就是说引擎要留给用户足够合理以及灵活的接口供用户进行业务的扩展等。另一方面就是就性能方面来说server引擎要方便scale up 纵向扩展,也要方便scale out 横向扩展。当然两者都能满足那就再好不过了。
-
并发性。这算是server引擎最重要的特性之一了,它会直接影响到引擎的承载能力,响应性等等。但是当前的Thread结构在处理共享状态和可变状态的时候都需要和锁Lock配合使用,Lock的机制是为了解决资源访问的同步,但是同步和并发本身就是水火不容的,更甚是这种结构本身就很复杂,需要coder有很好的并发问题处理功底,稍微不慎就会有死锁等等各种问题,如何处理?有没有更好的处理模型?
-
吞吐量。这个名词的解释可以看操作系统的从前往后的发展,核心解决的是引擎能如何有效的把物理服务器的资源利用起来,它跟引擎的并发性密切相关。
-
响应性。它说的是引擎处理请求的速度能有多快,高响应性可以提供良好的游戏体验。
那是否可以写一个可以解决上述所有问题的游戏服务器引擎呢?Stone就是为了这个目的开始码起的。
- fucking crazy concurrency?(高并发?)
首先是为什么需要高并发?因为单个线程的处理能力毕竟是有限的,拿游戏服务器来说一个非io请求的处理时间在1ms的级别,一个io请求的处理时间在100ms级别,那么随着单服游戏玩家的增加,单线程结构的吞吐量就会下降,响应时间变长,极端情况下消息队列里消息无限堆积,fullgc频繁,但是内存无法回收,最终OOM导致服务器crash。
那我们就要把单线程的结构换成多线程的结构来提高服务器的请求处理能力。最早做MMO的时候会在设计上把游戏世界分为多个场景这样的设计,所以在后端就会使用线程作为载体然后一个或者多个场景的处理分摊到指定的场景线程这种设计。再往后做SLG页游的时候,游戏设计上已经淡化了场景的概念,所以更倾向于把服务器的业务处理设计为多个责任节点,比如根据游戏玩家的状态机:GAMING - BATTLING,等把处理节点分为游戏中处理节点和战斗处理节点,而每个处理节点会有一个或者多个线程来分担处理,这其中由于db io请求的处理比较耗时,所以也会分一个数据处理节点出来,进行数据请求的异步处理,然后进一步回调结果通知给请求节点等。
上述的架构思想,基本可以解决目前网游服务器的需求,但是有2个问题是,如果使用把玩家分摊到多个游戏线程里,1需要解决跨线程的玩家如何安全的交互?2需要解决玩家和游戏中的一些全局管理器的安全交互?
解决上头两个问题最好的办法是在框架级别封装多线程的复杂性,不要让coder过多操心并发这类复杂的事情,但是如果你要做一个高承载量的东西,这个往往很难做到,无疑还是要和Thread以及Lock这类并发工具打交道,所以这就无疑为程序的鲁棒性添加了隐患。
我转而去尝试其他的并发模型,比如Actor, STM。
- concurrent design pattern?(并发设计模式?)
并发设计模式就是对常见并发问题的一些通用解决方案。大概一共12种类型:
-
single thread execution。这其实是最简单暴力的处理方式,字面意思就是单一时刻资源只有一个线程在处理。实现方式就是所有共享资源的方法都是用synchronized来标识。缺点也很明显就是极端情况下毫无并发性可言,如果某个资源竞争很激烈,就会成为热点,有很大的性能问题。
-
immutable。并发问题的根源就是共享的可变状态,所以如果所有的共享状态都是不可变的就可以完全规避这些问题。当然这种方案也有个问题就是极端情况下需要大量的对象复制,这样会有损效率。
-
guarder suspenson。这个模式说的是线程间的一种协作情况,线程去访问某个资源的时候,发现条件不满足,那么此线程就会在这个资源上进行等待,大部分实现方式是调用wait以及notify这种语法,等到条件到某一时刻满足了,然后再通过notify去唤醒当前线程,当前线程继续执行后续逻辑。java并发包中的BlockingQueue就是类似的实现。
-
balking。这个模式解决的状况跟上头是一样的,但是它的解决方式是当条件不满足的时候会立即返回,而不会阻塞的等待条件满足。比如java并发包中的原子类的很多CAS操作就是类似的实现。
-
producer comsumer。这个模式描述的是类操作系统中描述的生产者和消费者模型,这两个角色都是相对的。情形是生产者线程去生产需要处理的请求到一个中间层的队列中,消费者线程去这个队列中逐个取出请求,然后进行处理,所以这就要求中间层的数据结构是一个线程安全的结构,具体可以结合 guarder suspenson 和 balking来使用。
-
thread per msg。这个模式描述的是在处理消息的时候,使用单一消息使用单一线程的处理模式,这样在一定程度上可以提高系统的响应性和吞吐量,但是问题是线程的创建时需要消耗资源的,而且线程的调度也是很消耗资源和效率的,所以改良的方法是使用线程池。java的Servlet就是这种实现方式。早期游戏服务器在只有blocking io 的时候也会使用这种方式去实现。
-
worker thread。这是一种广大网络框架里都会使用的模式,也是 thread per msg 和 producer cosumer 的结合版本,应用程序会有多个工作者线程,工作者线程可能会跟不同类型的请求队列搭配使用来处理请求。
-
future。这是一个使用很广泛的而且也很有用的模式,白话叫做欠条,那也就是有一天这个欠条你要去兑现。描述的是在进行调用的时候,调用方不会阻塞到那里,而是立即返回一个future对象,你可以从future对象中get出你需要的计算结果,但是什么时候可以get到结果呢,大概有3中方式,其一是使用await的方式阻塞等待结果。其二是设计良好的api会提供timed await接口,支持定时的等待。其三是使用回调的方式,比如onSuccess和onFailed这类接口,这里要注意回调的执行线程不确定,所以要注意线程安全性。mina以及netty等网络框架里大量使用的这种模式,包括java本身的concurrent包里就使用了这种模式。
-
read write lock。这个模式在java并发包中也有实现,就是ReadWriteLock,这个模式的出发点是应对这样一种状况,当一个资源没有写线程而又很多个读线程的时候,资源其实是不需要去做同步的,所以这里的思想就是把读锁和写锁分离,在读资源的时候加读锁,在写资源的时候加写锁,这样在读多写少的时候可以很大程度上提高效率。
-
two phase termination。这个也是并发的常用模式,它描述的是如何正确的终止服务?因为多线程的结构其实分为两个部分:线程(载体) + 任务,线程作为载体在执行指定的任务,所以当我们要终结一个服务的时候,要分为两个阶段,第一阶段去处理当前队列里的任务,服务要有自己的明确策略去应对怎么处理这种情景,是直接丢弃还是要处理完成剩余的任务等等。第二阶段是终止线程,那其实当线程需要执行的任务都完成的时候,线程的生命周期也就结束了。这里需要注意的是要正确的应用线程的中断机制,也就是interrupt接口,很多api的设计是支持中断的,但是有些api设计是不支持的,比如io类似的一些状况,需要通过类似close这种api进行处理,还有就是像阻塞到synchronized方法的调用时无法终止的。
-
thread specific storage。这个模式描述的实现在java中也有实现就是ThreadLocal,也就是实现会为每个线程都分配或者关联独立的存储空间,这样就避免了状态的共享,这个模式有着和immutale类似的问题,也就是极端情况下会有大量的内存空间消耗。
-
active object。这是一个复合的并发模式,也是收尾的最终大Boss。活动对象描述的是这样的一种并发对象:并发世界里的所有对象都是活动对象,活动对象间的调用会被转化成一个消息,投递到活动对象的队列里,然后活动对象依次的从自己的的消息队列中取出消息然后进行处理。说它是个复合模式是因为它结合了上头的很多模式,比如 Producer consumer 以及 worker thread 等等。请仔细学习和理解这个模式,因为它基本就是Actor 模型的原型。
-
Actor model?
Actor模型并Erlang采用广泛应用于通信领域,获得了很大的成功。jvm上的多范型语言scala也把Actor模型内置到自己的语言体系中。
其实上头最后讲到的Active Object模式就是它的原型。我的理解Actor就是并发世界中最基本的一个计算单元。Actor之间不通过共享状态来分享资源,而是使用不可变消息,也就是说跟Actor通信的唯一方式是通过类似tell(msg:Object)这种方式来进行,Actor内部有自己的mailbox,它按序去处理信箱中的消息。没有了共享和可变那么原则上来说就不需要锁了。
- akka?
akka是Actor模型的一个实现库,同时支持java语言和scala语言,而且设计的无比强大,最早我想用java语言自己实现一个Actor,但是了解了akka以后发现完全没有这个必要,已经没有库可以设计的比akka更好用了!它同时支持UnTypedActor和TypedActor,而且TypedActor的实现就用了上头说道的Active Object模式。而且支持remote,且Location transparency。支持Cluster集群,支持灵活配置序列化层java,mina或者netty,尼玛还有比这更屌的么?只要你能想到的并发和分布式等等需要的东西它都有。
- scale out?scale up?
我很负责任的告诉你,akka可以搞定这一切
- data layer design(数据层设计)
数据层这里会额外多些设计,目的是为了缓解数据读和写的压力。读压力通过使用LRUHashMap缓存的这种方式来缓解。写压力主体通过子实体 + protobuf序列化的方式来减少写io来实现。
- UUID 服务
UUID就是(Universally Unique Identifier)全局唯一ID。数据库实体都会需要这个ID来进行唯一标示。那么这个ID应该如何生成?业内很通用的一个做法是使用DB主键的auto_increament来借助DB来生成,但是这种做法有几个弊端:
-
要拿到这个ID需要等待DB实体插入完成以后才可以,这往往会用起来很不舒服,因为有时候我们希望是先拿到这个ID去做一些业务。
-
DB端auto_increament实现方式无非也就是维护一个当前值,然后如果有并发的操作会对这个值进行加锁保护,一定程度上这个也是一种效率的消耗。
-
还有就是游戏到后期一般会有合服的需求,自增长的id不会根据我们需要的规律(大区ID_ServerId、等)来生成,所以处理id重复之类的是一个巨大无比的工作。
Stone使用提供UUID服务的方式,而且如果无意外的话,会抽出来使用无差别的Actor去实现,避免了上头的三个缺点。