开发笔记(25) : 改进的 RPC
自从动了重新实现 skynet 的念头,最近忙的跟狗一样。每天 10 点醒来就忙着写代码,一句废话都不想说,一直到晚上 11 点回家睡觉。连续干了快一个月了。
到昨天,终于把全部代码基本移植到了新框架下,正常启动了起来。这项工作算是搞一段落。庆幸的是,我这个月的工作,并没有影响到其他人对游戏逻辑的开发。只是我单方面的同步不断新增的逻辑逻辑代码。
Skynet 的重写,实际上在半个月前就已经完成。那时,已经可以用新的服务器承载原有的独立的用户认证系统了。那么后半个月的这些琐碎工作,其实都是在移植那些游戏逻辑代码。
在 Skynet 原始设计的时候,api 是比较简洁的,原则上讲,是可以透明替换。但实际上,在使用中,增加了许多阴暗角落。一些接口层的小变动,增加的隐式特性,使得并不能百分百兼容。另外,原来的一些通讯协议和约定不算太合理,在重新制作时,我换掉了部分的方案,但需要编写一个兼容的链路层。
比如:以前,我们把通过 tcp 接入的 client 和 server 内部同进程内的服务等同处理。认为它们都是通过相同的二进制数据包协议通讯。但是,同进程内的服务间通讯明显是可以被优化的,他们可以通过 C 结构而不是被编码过的数据包交换信息,并可以做到由发起请求方分配内存,接受方释放内存,减少无谓的数据复制。在老的版本中,强行把两者统一了起来,失去了许多优化空间。在新版本里,我增加了较少的约定,修改了一点接口,就大幅度提升了进程内服务间信息交换的效率。
另一方面,一旦固定采用单进程多线程方案,之前的多进程共享数据的模块就显得过于厚重了。新的方案更为轻量,也更适合 lua 使用。这项工作在上一篇 blog 中提到过。这和 skynet 的重写原本是两件事情,但我强行放在一起做迁移,增加了许多难度。但考虑到,原本我就需要梳理一次我们的全部服务器端代码(包括大量我没有 review 过的),就把这两件事情同时做了。
在这个过程中,可以剔除许多冗余代码,去掉一些我们曾经以为会用到,到实际废弃的模块。彻底解决一些历史变更引起的问题。过程很痛苦,但很值得。新写的代码各种类型检查更严格,就此发现了老的逻辑层代码中许多隐藏的 bug 。一些原有用 erlang 实现的模块,重新用 lua 实现了一遍,混合太多语言做开发,一些很疼的地方,经历过的人自然清楚。以后如非必要,尽量不用 lua 之外的语言往这个系统里增加组件了。
btw, 新系统还没有经过压力测试。一些优化工作也没有展开。但初步看起来,还是卓有成效的。至少,改进了数据共享模块,以及提出许多冗余后,整个系统的内存占用量下降到原来的 1/5 不到。CPU 占用率也有大幅度的下降。当然,这几乎不关 C 还是 Erlang 做开发的事,重点得益于经过半年的需求总结,以及我梳理了大部分模块后做的整体改进。
今天想重点谈谈下面一段时间我希望做的改进。是关于服务间 RPC 的。
目前能找到年初的一篇记录 ,经过大半年的演化,已经不完全是记录的那个样子了。但大体上的思路一直沿用着。
这个方案的优点在于,使用通用的 google proto buffer 协议做严格的 RPC 协议定义。但缺点也是很明显的,最麻烦的地方在于,在一个需要大量服务间交互的应用环境内,新实现一组 RPC 需要做大量的工作,这对程序员是一个负担。
程序员需要:一,在一个协议描述文件中,定义出协议名以及对应的协议号;二,在对应名字的 proto buffer 文件中,定义出协议对应的输入参数和输出参数列表;三,在特定的位置,创建特定名字的 lua 源文件,在里面实现特定名字的协议过程。
有时,这个流程是好事,它能够在 lua 这种弱类型系统中,辅助检查出潜在的错误;但对开发效率的影响也是显著的。同时,这个长长的流程,以及对应的各种编解码工作,也引起了部分性能损失。
我希望在以后的系统中,引入更为方便简洁的 RPC 机制。
简单的说,我的最终需求是:程序员不需要为 RPC 调用和本地调用写不同的代码。程序员需要心里了解一次调用是远程调用,但他不必为实现这些方法做额外的事情。在他不需要把一些方法定义为远程方法时,只需要把源文件换个位置,或是重新组织一下代码加载的过程,就可以轻松的完成。远程对象和本地对象对调用者来说,也应该尽可能的透明。
我今天把这个想法基本实现了。
经过重写 skynet ,我对这类事务的处理稍微建立了一点模式。首先,应该把同进程内的服务间 RPC 调用同跨进程的调用区分开。
跨进程(跨机)调用,可以通过增加一个特定的服务,在链路层把它们接起来即可。应该专心实现同进程内,不同服务间的高性能 RPC 才是重点。等这一步完成,只需要为异地对象建立一个本地副本做消息中转就够了。
我们应该专心考虑 Lua State 实现的服务,而不必过于考虑不同语言间的 RPC 调用。一起以 Lua 为主语言来考虑功能。
首先我引入了之前实现好的 Lua 数据序列化模块 。并对它做了一些改动 ,合并到 skynet 项目中。
这个改动是,让序列化模块底层理解远程对象。增加了一个远程对象类型。因为所有的 Lua State 其实是在一个系统进程内的,数据交换工作通过 Lua 的 C 扩展库就可以完成。在同个进程内,每个远程对象都有唯一的数字 id 。传递远程对象,只需要传递这个 id 即可。
每个 Lua State 中,维护一张表,记录所有在这个 State 中创建出来的远程对象,以及对应的 id 。在序列化模块中,一旦发现提到的远程对象是自己进程内的,就自动翻译成本地对象。
序列化模块本身不处理任何远程对象的特殊行为。它把这个工作交给外部注入。skynet 模块把远程调用的方法注入到远程对象中。
所谓 RPC 调用,就是一个远程对象加一个方法名,加上若干参数。通过序列化方法,打包成一个数据包,查询到远程对象所在的服务地址,发送过去即可。
而所谓远程对象,其实只是在 lua table 里设置一个叫 __remote
的字段,设入数字 handle 就可以让序列化模块识别了。
这个模块并不复杂,我实现在了 skynet.lua 中,不到 100 行代码。
作为一个范例,我实现了一个叫 root 的远程对象,让它在一个独立服务 中启动。它可以提供一个基本的名字服务。在一个简单的 test 程序 中,我们可以看到一个远程对象把自己注册到 root 里,别的服务从 root 拿到这个对象,就可以向本地对象一样调用上面的方法了。