smallnewer / bugs

18 stars 4 forks source link

关于JS游戏服务端的新思考 #134

Closed smallnewer closed 6 years ago

smallnewer commented 6 years ago

我用JS写游戏服务器也有蛮久了,用它支撑起一款轻量级的MMORPG(手游这种基本都算中轻度以下了)已经没问题。 虽说带起来是没问题,但还是很不顺手,究其原因大概就是一句话:写的爽但性能低,性能高写的就不爽。这就让我一直想把JS带到类似Golang的负载体量上来。 JS写游戏服务器真的有太多的槽点:

  1. GC总让人不放心,NodeJS至今仍然没放开默认的内存限制。大量玩家在线时,老得想着GC的事。
  2. 没有类型分析,一个需要高性能的单态函数,很可能被其他人维护的时候搞成多态,性能骤降。
  3. Object写着爽,但动不动的for in也让人受不了。还有obj['1']会比obj['a']这种访问会超慢的隐性坑,你也是防不胜防。
  4. 没有零开销的immutable,游戏服务器配置表的数据都是只读的。但是一个大数组你用frozen,访问会慢到爆。你不用,又总是担心数据被其他人的函数篡改(谁让传的都是引用,改引用数据,那只是一不小心又不会立马报错的-.-)
  5. 多线程支持不好,目前社区的多线程方案都不适用于游戏服务器(更多的是多核CPU计算方向)
  6. 和C++的交互难,不能完整共享Object和Array数据,比如你把一个JS对象传给另外一个V8 Isolate,必须得经过序列化和反序列化。这极大的限制了多线程的性能上限。 等等等等。

今天就在想,这样的设计下,JS承载了太多责任(许多是JS不适合的),就如同NodeJS的思路,连协议解析这类的工作都交给JS,这样留给业务的时间就更少了(虽说现在优化的很牛,和Golang可以一战,但那只是压测而已,实际业务中,JS对性能的影响很不稳定,这也并不是出套规范能解决的)

那么我们按照Skynet的Actor模型来设计会怎样呢? 首先我希望的是:

  1. JS在单进程里具备利用多核CPU的能力,在需要提高负载的时候,4核和16核是线性的增长。
  2. 在满足第1的时候,内存不是瓶颈,可以利用大内存而不用关心GC。
  3. 核心函数性能有保证(无论是否由JS实现)
  4. JS只要能满足写业务即可,底层和通用类都可以放弃。
  5. immutable最好要有,或者允许JS方便的读只读数据。

下意识想到的实现方案:

  1. 限制JS的书写方式,只允许写类,一个类的实例就是一个actor。每个actor有onmessage回调来接受并处理外部来的消息。
  2. 进程启动时,开启多个Isolate,由Native层负责多线程网络通信(1:1模型),每个Isolate可以实例化出来多个actor,排队接受消息进行处理。(之所以是多个,是考虑到异步操作的可能,如果全是同步操作,只需要一个actor:一个Isolate即可)

比如我们可以做成一个玩家就是一个actor。当来消息时,快速的传到原型链上的函数(可以被高度JIT优化),进行简单的逻辑运算,返回数据。虽然会限制JS的自由度,但为了性能,完全可以接受。 和Skynet有很大不同的是,一个actor是一个独立的作用域,而JS这边的actor只是一个类。不过这个也有解决办法,我们可以利用闭包来给每个actor一定的作用域。但这有一些副作用,比如容易影响到对函数的JIT优化,比如对内存的利用率等等。不过应该也在可以接受的范围内。 主要的问题是出现在和其他actor交互时,比如完成一场战斗,我们需要增加对战记录。那么有actor A会发起对actor B的请求,要求B自己也增加一份记录。 正如上面说的,这个方案传递消息也不例外,必然要涉及到序列化,而不能像其他语言一样,直接一个memcpy过去,甚至连copy都没有。性能会大打折扣。 不过反过来我们也可以这么看,这个消息的开销是否可接受的,毕竟在大部分游戏里,和其他人的数据交互不是高频操作。 地图actor可以分布在任何线程里,由玩家actor接受客户端消息,直接转发到地图actor里(最多多一次消息copy,若在同线程内则无需copy-约等于0开销),甚至可以由客户端直接给某个actor发消息。 为了提高性能,我们可以会把同分区的玩家安排在同一进程内,减少跨进程的通信。该分区的数据也可以做成一个actor(这里就有问题,和分区数据交互可能是一个高频操作,需要频繁读取,待解决)。 Actor模型的一个大问题就是吃内存。JS的内存占用会显得比较严重。 到此为止,此方案应该可以解决JS如果更有效的利用多线程的问题,但内存问题仍然没解决。 留作疑问,日后思考。

smallnewer commented 6 years ago

内存方面可以采用Buffer存储数据+定义数据类型+数据操作抽象接口+接口预编译为对Buffer的操作的组合拳。 各有其目的:

  1. Buffer是为了避免GC,可以更自由的使用内存,甚至可以多线程共享(比如提高消息传输性能)
  2. 定义数据类型,是为了尽量避免Buffer的扩容操作。比如一个对象,如果要不断扩容,那再设计Buffer容器时,就无法零开销的避免对象的内存迁移操作。但最终不可避免的还是数组的扩容。这个以后展开说。
  3. 数据操作抽象接口,是为了避免JS的引用机制,改为值传递。这样会更安全,少出BUG。
  4. 数据操作的接口可以预编译或运行时编译为对Buffer的操作,数据接口都是函数,很低效,不过我们可以直接编译为对Buffer的偏移量操作,这个就很划算了。

其中最麻烦的,就是Buffer如何管理数据了。目前我还没具体方案,但感觉应该会模拟现有语言如C++的一些数据类型的内存布局了,比如Map、Vector、定长数组、链表这些东西。字符串为了提高操作性能,可能也会借鉴V8的一些优化技巧吧。

之前测过,对hidden class的属性读操作和对Buffer的下标读操作,Buffer是落后的,可能是有越界判断。但是也很接近了。比无法JIT优化的代码或者使用hash的对象要快多了。

为了用JS,我也是拼了。不过这也是乐趣所在。

smallnewer commented 6 years ago

关了。