Main

March 01, 2024

以非阻塞方式执行一个函数

在 skynet 中,服务之间并行运行,而每个服务自身的业务都是串行的。一个服务由开发者自行切分成多个时间片,每个时间片串行运行在不同的工作线程上。最常见的做法是在每个服务中运行一个 Lua 虚拟机,用 coroutine 切分时间片,这样从编写代码的角度看,任务是连续的。

这个设计的目的是,让开发者轻松享受到多核带来的并发性能优势,同时减轻编写多线程程序带来的心智负担。

用过 skynet 的应该都碰到过:当我们在服务中不小心调用了一个长时间运行而不返回的 C 函数,会独占一个工作线程。同时,这个被阻塞的服务也无法处理新的消息。一旦这种情况发生,看似是无解的。我们通常认为,是设计问题导致了这种情况发生。skynet 的框架在监测到这种情况发生时,会输出 maybe in an endless loop

如果是 Lua 函数产生的死循环,可以通过发送 signal 打断正在运行运行的 Lua 虚拟机,但如果是陷入 C 函数中,只能事后追查 bug 了。

November 17, 2022

skynet 1.6.0

最近半个月,因为广州防疫政策,我一直居家办公。找了点时间,给 skynet 做了一年一度的 release 。

这次的 1.6.0 版,相比去年的 1.5.0 版,没有太大的变化。主要是平时积累的一些 bugfix 。它所依赖的第三方库,例如 Lua , jemalloc 我都更新到了最新的版本。

值得一提的是,mongo 的 driver 也更新了。因为 mongo 在最新的版本中已经淘汰了旧的 wire protocol ,如果再不更新,就无法连接新版 mongo server 了。具体讨论可以参看 PR #1649 。因为 mongo 自己的设计原因,我们无法给出一个同时兼容新老版本协议的方案。从这点来看,我个人认为 redis 的底层协议设计就稳定的多。 这么多年更迭基本不需要修改。

September 07, 2022

多线程串行运行 Lua 虚拟机

ltask 是一个功能类似 skynet 的库。我主要将它用于客户端环境。由于比 skynet 后设计,所以我在它上面实验了许多新想法。

最近碰到一个需求:我们的游戏引擎中嵌入的第三方 C/C++ 模块以 callback 形式提供了若干接口。在把这些模块嵌入 ltask 的服务中运行时,这些 callback 函数是难以使用全部 ltask 的特性。比如,我们的 IO 操作全部在一个独立服务中,引擎读取文件时,很可能是通过网络异步远程加载数据的。这些第三方模块通常没有考虑异步 IO 操作,都是以同步 IO 方式给出一个读文件的 callback 函数让使用者填写。

那么,怎样才能在这个 C callback 中挂起当前任务,等待 IO 的异步完成呢?

March 25, 2021

服务的创建和退出问题

TL;DR 系统中每个和系统活得一样久的单元(服务),都应该提供一个关闭接口,而不是释放接口。关闭只做必要操作,不必释放资源,不必和其它单元协调。整个系统退出时,只需要命令所有单元关闭,然后让世界戛然而止。


最近在和同事一起完善 ltask 。这个项目可以看成是对 skynet 的一个回顾。我们打算把它用在客户端引擎中。

在 ltask 中,原来在 skynet 里用 C 实现的 timer network 等线程都被移到了 lua 中实现。它们被成为 exclusive service ,会独占一个操作系统线程,但之外的部分和普通服务并没有太大区别。

因为这样一些基础服务也挪到了 lua 中实现,以前在 skynet 中天然而成的层级被模糊了。在 skynet 中,这些独占线程都是先于其它服务启动,而晚于大多数服务退出。它们的启动和退出流程都在 C 代码中固定下来。

February 27, 2021

skynet 处理 TCP 连接半关闭问题

TCP 连接是双工的,既可以上行数据,又可以下行数据。连接断开时,两侧通道也是分别关闭的。

从 API 层面看,如果 read 返回 0 ,则说明上行数据已经关闭,后续不再会有数据进来。但此时,下行通道未必关闭,也就是说对端还可能期待收取数据。

同样,如果 write 返回 -1 ,错误是 EPIPE ,则表示下行通道已经关闭,不应再发送数据。但上行通道未必关闭,之后的 read 还可能收到数据。

用 shutdown 指令可以主动关闭单个通道(上行或下行)。

February 07, 2021

ltask :Lua 的多任务库

ltask 是我前两周实现的一个 lua 的多任务库

这个项目复用了我之前的一个类似项目的名字 。目的是一样的,但是我做了全新的设计。所以我干脆将以前的仓库移除,以同样的名字创建了新的仓库。

和之前的版本设计不同。在消息通讯机制上,这个更接近 skynet 的模型,但它是一个库而不是一个框架。调度模块是按前段时间的想法实现的,不过只做了一个初步的模型,细节上还有一些工作待完善。

January 22, 2021

关于 skynet 调度器的一点想法(续)

这篇是继续上次的一些想法

我最近想重新做一个新的基于消息管道的 N:M 多任务调度器。主要用在我们的客户端上。算是解决在使用 skynet 多年来总结出来的一些问题。

首先,我想改变 skynet 服务直接将待发消息投递到对方消息队列的做法。发出消息会让服务挂起,等待投递。

然后,我设计了一个叫做调度器的模块,用于把带发消息投递到对应的接收管道中。调度器是发送消息的消费者,接收管道的唯一生产者;而每个服务是它自己的发送消息的唯一生产者,接收管道的唯一消费者。这样没有竞争,当消息队列是定长的时候,也无需使用锁(但引入了消息队列满的新状态)。

January 12, 2021

把 skynet 的原子操作换成了 stdatomic

stdatomic 已经是 C11 的标准,并且成为了 C++ 标准的一部分。msvc 也将会支持 stdatomic 。在 skynet 项目开始的时候,还没有这个可以用,所以我采用的是更早一点的 gcc sync 系列的扩展

我想,用 stdatomic 来实现原子操作,会有利于 skynet 未来的发展。上周花了点时间熟悉这套 api 并在 skynet 中实现。同时保留了之前的实现,如果编译器定义了 __STD_NO_ATOMICS__ 就会切换回老版本。

October 20, 2020

skynet 1.4.0

又是一年过去了,skynet 目前保持着一年一个发布版的开发进度。skynet 1.4.0 发布版将于近期冻结。

这次的主要更新是将 Lua 更新到了 5.4.2 (尚未发布,但 github 仓库中的版本号已经到了 5.4.2 )。可能会让 skynet 的许多项目享受到分代 gc 的好处。如果使用大量 agent 服务的模式,将会降低整体的内存峰值开销(GC 更加及时)。lua 5.4 中 table 的内存开销也比之前的版本要小,运行性能也有所提升。

升级到 lua 5.4 基本不需要修改过往的 Lua 代码。C 库需要重新编译,但基本不需要修改。但如果可以改用新版的 lua_newuserdatanv 取代 lua_newuserdata 会更好。

skynet 依然提供了针对多 vm 共享 proto 的补丁。和以前一样,这是一个可选项,可以自行编译官方版本的 lua 。

October 18, 2020

cache server 问题总结

这周,我们的 cache server 服务面临了很多的挑战。项目资源超过了 30G ,有几十个用户在同时使用。每天都有版本切换工作(导致重新上传下载 30G 的数据)。在这个过程中,我对 cache server 程序修修补补,终于没有太大的问题了。

总结一下,我认为 cache server 的协议设计,以及 Unity 客户端实现,均存在很大的问题。这些问题是无法通过改进服务器的实现彻底解决的,只能做一些缓解工作。真正的完善必须等 Unity 的客户端意识到这些问题并作出改进。

cache server 的协议设计非常简陋。就是顺序的提交请求,然后每个请求会有序的得到一个回应。这些请求要么是获取 GET 文件,要么是上传 PUT 文件。其中 PUT 文件在协议上不必回应。

由于 PUT 文件没有回应,所以客户端无法直接确定文件是否全部上传完毕;如果必须确认,只能在 PUT 文件结束后,再提交一个 GET 请求。如果收到了后续 GET 的回应,可以理解为前一个 PUT 已经结束。实际上,Unity 客户端没想去确认 PUT 是否结束,从 log 分析,它只是简单的在最后一个 PUT 结束后等待了一段时间再断开连接。

PUT 实际上是个小问题,真正的问题是:这种依赖严格次序的协议,在面对两边数据量不对等、网络速度不对等的近况时,很难有一个健壮的实现。

September 21, 2020

skynet 版的 cache server 引出的一点改进

我们自己做的 cache server 已经工作了很长时间了。上次出问题是在 2 月在家工作期间

这个月又出了一起事故,依旧是 OOM 导致的崩溃。一开始,我百思不得其解,感觉上次已经处理完了所有极限情况,按道理,这是个重 IO 而轻内存的业务,不太可能出现 OOM 的。

通过增加一些 log 以及事后的分析,我才理解了问题。并对应做了修改。

September 03, 2020

为 skynet 增加并行多请求的功能

skynet 在设计时,就拒绝使用 callback 的形式来实现请求回应模式。我认为,callback 会导致业务中回应的流程和请求的流程割裂到两个执行序上,这不利于实现复杂的业务逻辑。尤其是对异常的支持不好。

所以,在 skynet 中发起请求时,当前执行序是阻塞的,一直等到远端回应,再继续在同一个执行序上延续。

如果依次发起请求,会有不该有的高延迟。因为在同一个执行序上,你必须等待前一个请求回应后,才可以提起下一个请求。而原本这个过程完全可以同时进行。

但是,如果我们想同时发起多个不相关的请求就会比较麻烦。为每个请求安排一个执行序的话,最后汇总所有请求回到一个执行序上又是一个问题。目前,只能用 fork/wait/wakeup 去实现,非常繁琐。

这类需求一直都存在。我一直想找到一个合适的方法来实现这样一类功能:同时对外发起 n 个请求,依回应的次序来处理这些请求,直到所有的请求都回应后,再继续向后延续业务。实现这样的功能在目前的 skynet 框架下并不复杂,难点在于提供怎样的 api 形式给用户使用。

June 16, 2020

skynet 并发模型的一点改进思路

skynet 的内核是一个多线程的消息分发器。每个服务有一个消息队列,任何服务都可以向其它任意服务的消息队列投递消息,而每个服务只可以读自己的消息队列,并处理其中的消息。

目前的工作原理是,在任意消息队列不为空的那一刻,将该消息队列关联的服务对象放在一个全局队列中。框架启动固定数量的工作线程,每个工作线程分头从全局队列中获取一个服务对象,并从关联的消息队列中获取若干条消息,顺序调用服务设置的回调函数。如果处理完后消息队列仍不为空,则将服务对象重新放回全局队列。

这样,就完成了尽量多(远超过工作线程数量)的并发服务的调度问题。

我这些年一直在考虑这个模型可否有改进之处。能不能设计得更简单,却还能在简化设计的基础上进一步提高并发性。同时,还可以更好的处理消息队列过载问题。

February 05, 2020

skynet 版的 cache server 改进

去年我实现的 Unity cache server 的替代品 已经逐渐在公司内部取代 Unity 官方的版本。反应还不错,性能,可维护性以及稳定性都超过官方版本。

最近疫情严重,公司安排所有人员在家办公,今天是开工第三天。前两天比较混乱,毕竟在家办公的决定是在假期中间做出的,并没有预先准备,我们的这个 cacheserver 也在第一天受到了极大的考验,暴露出一个问题,好在只是内存用量预警,并没有出任何差错。我花了一天多的时间排查和解决问题,感觉这是一个极好的案例,值得记录一下。

November 07, 2019

skynet 网络层的一点小优化

在 2017 年的时候,我对 skynet 网络层的写操作做了一些优化 ,优化点是:如果 socket 并不繁忙,就不必把数据转达到网络线程写,而是直接写入 socket 。这可以减轻单线程网络层的负担,对于写操作频繁的场景会有极大的提升。

最近两天我想起来,如果大部分场合都可以通过直接写入 socket 而不必转发到网络线程发送数据的话,其实我们可以进一步的减少一次内存拷贝。

July 08, 2019

用 skynet 实现 unity 的 cache server

我们公司的一些 Unity 项目,当 cache server 的数据上涨到几百 G 后,经常遇到问题。最近一次是 nodejs 内存使用太多导致进程挂掉。

我不太想的明白,一个几乎不需要在内存中保留什么状态的服务,为啥会吃掉那么多内存。简单看了一下 cache server 的官方实现 感觉实现的挺糟糕的。它的业务很简单,还不如按协议自己实现一个。

March 14, 2019

Lua 虚拟机间函数原型共享的改进

在我们的服务器框架 skynet 中,往往会在同一进程启动非常多的 lua 虚拟机,而大多数几乎运行着相同的代码(为不同的用户服务)。

因为 Lua 的代码也是数据,和 Erlang 这种天生设计成多服务的语言不通,Lua 并没有为多虚拟机同时运行做内存上的优化。所以,我在 5 年前给 Lua 制作了一个 patch ,可以把不同虚拟机间的函数原型数据提取出来共享一份,节省了不少内存。

不过,这个方案只能功能函数原型中的字节码以及调试信息,无法共享函数原型用到的常量表。这是因为,字符串常量是一个对象,尤其是短字符串,Lua 在虚拟机内部做了 interning 已加快字符串比较速度,使得它们很难在虚拟机间共享。

November 06, 2018

Skynet 1.2.0

今天我发布了 skynet 1.2.0。

距离上次发布 1.1.0 已经有一年了。虽然我觉得给 skynet github 仓库里某个特定版本起个有意义的名字并无太大意义,因为我也不会刻意去维护一个所谓稳定版。但在 issues 中还是发现有一些同学还在基于上个 1.1.0 的 release 版提问题,我认为还是保持一年一个版本号比较好。

其实,对于活跃项目,最好的方法还是始终跟进 github 上的 master 比较好。我也刻意在维持代码的向前兼容性。skynet 的 api 已经很稳定,不用太担心更新造成项目跑不起来。话说回来,即使某次更新打破了兼容性,每次一小步的跟进也比隔上一年才同步一次,或是永不升级来得好。

跟进及时可以减少更新带来的新问题。有麻烦可以马上反馈,我更容易帮助解决;不更新容易让 bug 滞留,原本已经解决的 bug 可能在未来再次困扰你。随着 skynet 的用户越来越多,隐藏在犄角旮旯的 bug 更容易被找出来。在 issues 板块,已经有很多问题其实是在 issue 提出时已经被解决了的,仅仅只是因为未更新代码。这种问题无疑浪费了大家的时间。

September 12, 2018

给 skynet 增加网络统计

skynet 在这个阶段的工作,主要是增强运行时内部信息的透明性。多提供运行时的统计数据可以为运维工作提供方便,也能为性能调优给出指导方向。

最近,我给 debug console 增加了 netstat 指令,以及提供了配套的 socket.netstat api 来获取这些数据。

这个指令可以获取 skynet 创建的所有 socket 的列表。每个 socket 归属于哪个 service ,每个 socket 上读写的字节数,最后一次读和写发生的时间,未写入系统挂在代写链表上的字节数。当然还有每个 fd 对应的 ip 地址和端口(如果是 socket )。如果是 listen fd ,会纪录 accept 成功的次数,

May 26, 2018

跟踪 skynet 服务间的消息请求及性能分析

skynet 中每个服务都是独立 lua 虚拟机,虽然服务间相互发起请求很容易,但业务复杂后,追踪业务逻辑却很麻烦。

我最近给 skynet 加了一个 skynet.trace() 命令,可以开启对当前执行序的跟踪统计。比如,在 example 的 agent 服务中,我们在每条 client 发起的请求处理时调用一次 skynet.trace() 就会得到下面这样的 log :

[:01000012] <TRACE :01000012-5> 9563528587255135 trace
[:01000012] <TRACE :01000012-5> 9563528587272881 call : @./examples/agent.lua:17 @./examples/agent.lua:36 @./examples/agent.lua:58
[:0100000d] <TRACE :01000012-5> 9563528587326048 request
[:0100000d] <TRACE :01000012-5> 9563528587344784 response
[:0100000d] <TRACE :01000012-5> 9563528587352027 end
[:01000012] <TRACE :01000012-5> 9563528587367443 resume
[:01000012] <TRACE :01000012-5> 9563528587423140 end

April 17, 2018

skynet cluster 模块的一点优化

上周末,我对 skynet 的 cluster 模块做了一点优化。

cluster 模式 是 skynet 的一种集群方案,用于将多台机器更为弹性的组成一个集群。我们将每台机器都赋予一个名字,然后就可以在集群间用这个名字向对方推送消息或发起请求。

集群的管理是一项非常复杂的工作,skynet 作为一个轻量化的框架,只实现了最基本的基础设施。cluster 这个基础设施已实现的部分并不复杂。在每个 skynet 进程中,我们启动了一个叫 clusterd 的服务,专门用于集群间的通讯。由 clusterd 再启动了一个 gate 服务,监听其它节点连过来的连接;同时,当前节点如果要对其它节点发送数据,也通过 clusterd 向外连接。业务要使用 cluster 的时候,都是通过 require "cluster" 这个库,然后这个库中的 api 负责和本节点的 clusterd 交换数据。

我们正在开发的一个 mmorpg 项目重度依赖了 cluster ,在过去的多次压力测试中,发现 clusterd 是测试中单位时间内消耗 CPU 最多的服务。它是整个 skynet 节点中的一个性能热点。所以我考虑了对这个 clusterd 做一些优化。

October 31, 2017

skynet 1.1.0 发布

skynet 1.1 正式发布了。

这个版本的意义主要在于修补了一年前 2016 年 7 月发布 1.0 以来已经发现的 bug 。受益于 skynet 被越来越多的项目使用,很多我们自己使用时未能发现的 bug 通过 github 被定位和修复。

在这个版本中, lua 版本同步到了最新的 5.3.4 并打上了官方发布的 5 个 bugfix ,其中有几个还是我们在使用 skynet 时发现并汇报给 lua 官方的。jemalloc 也更新到了 5.0.1 。有同学报告说在某些测试环境下,jemalloc 的第 5 版性能比前一个版本要差,但我认为随着版本更新,性能有所下降是正常的。只要项目没有大的分叉,使用新的稳定版本都是值得的。

不过 skynet 的这个新版本在 IO 方面应该是比旧版本有性能提高。因为 1.1 版中,网络写操作会尝试先在 IO 线程之外完成。在需要做大量数据发送的场合(例如做流媒体广播),性能会有明显的提升。

1.1 版的 lua 模块放进了专门的名字空间下,这点可能会造成一定的对 1.0 版的兼容性问题。不过长远看,在工程方面是有利的,修改老项目适配 1.1 版的成本也不大。

还有一些小的改进,具体可见 HISTORY.md ,基本都向前兼容。建议还在维护的使用 skynet 1.0 的项目都应尽可能更新到 1.1 版。

September 07, 2017

日程表服务

skynet 的用户中,问的比较多的一个问题是,为什么我改了系统时间对 skynet 却没有生效?继续追问发现,有这个需求的人大多是想实现一个日程表,到某个特定时间触发特定的任务,修改系统时间是为了测试。

不得不说,通过修改系统时间来测试是个直接、却很糟糕的主意。skynet 的定时器也不依赖系统时间驱动,修改系统时间自然也不会生效。

日程服务是个普遍的需求。在国内网游里,你要不做什么节日任务、每周副本,基本不可能上线。这篇 blog 就谈谈这类需求应该在 skynet 中如何实现。

July 18, 2017

skynet 1.1

拖了好多天,终于决定发布 skynet 1.1 了。

距上次计划做这件事 ,除了零星的 bugfix ,还多了一些比较大的变动。

skynet 的 lua 模块全部加上了 skynet 前缀,部分数据库 driver 放到了 skynet.db 下。如果需要兼容 1.0 的路径,可以在 config 中配置 lualib/compat10 这个目录。

网络线程针对有大量写操作的应用做了很大的优化 ,在一个 实际案例 中提高了 3.5 倍的效率。

增加了一个叫做 DataSheet 的新模块,可以作为 ShareData 的一个替代选择。

June 19, 2017

skynet 网络线程的一点优化

skynet 是一个注重并行业务处理的框架,设计它的初衷是可以充分利用多核 CPU 更好的处理那些比较消耗 CPU 的,天然可以并行的业务,比如网络游戏。网络 I/O 并不是优化重点。

基于这个设计动机,skynet 的网络层使用单线程实现。因为我认为,即使是代码量稍大一些的单线程程序,也会比代码量较小的多线程程序更容易理解,出 bug 的机会也更少。而且经典的网络服务程序,如 redis nginx 并没有因为单线程处理网络 IO 而变现得不堪,反而有不错的口碑。

所以,skynet 的 epoll 循环并不像 erlang 那样,只关注读写事件,而让每个 actor 自己去处理真正的 socket 读写。那样固然可以获得更高的网络处理能力,但势必让网络 API (由存在多个工作线程里的多个 actor 分别调用)依赖锁来保证正确性。这是我不太希望看到的。目前的设计是,所有网络请求,都通过把指令写到一个进程内的 pipe ,串行化到网络处理线程,依次处理,然后再把结果投递到 skynet 的其它服务中。

这个做法未必最好,但也恰恰能用,一般网络游戏服务器,根据我们的实际项目数据,在其它业务处理的 CPU 占用到极限时,单台机器网络带宽不大会超过 30MB 左右的上下行带宽。一个核每秒处理 60MB 的数据是绰绰有余的。

June 06, 2017

sharedata 的替代品:datasheet

skynet 中有一个用来在多个服务间共享数据表的模块,叫做 sharedata

它的设计动机是:当我们有很多服务时,如果需要共享一份只读的数据表,把数据表分别在每个服务类加载会很浪费内存。而且,一旦数据表有热更新的需求,分散在多个服务中的数据更新起来会比较麻烦。

我试过很多方案来达成这个需求,一直都不是特别满意。目前的 sharedata 模块是用的最久、使用项目最多的一个。虽然它基本可用,但使用它的同学也提出了一些问题,我对这些问题做了一些思考。

June 02, 2017

skynet 模块命名空间调整

前段时间有同学抱怨说 skynet 下提供的 lua 模块都没有名字空间,平坦的命名,容易和自己项目开发的模块命名冲突。虽然自己项目开发的模块可以单独给一个名字空间,但混杂在一起使用还是不美观。

我考虑了几天,决定在 skynet 1.1 版本中把大部分的模块都加上 skynet 前缀。

May 27, 2017

epoll 的一个设计问题

问题的起因是 skynet 上的一个 issue ,大概是说 socket 线程陷入了无限循环,有个 fd 不断的产生新的消息,由于这条消息既不是 EPOLLIN 也不是 EPOLLOUT ,导致了 socket 线程不断地调用 epoll_wait 占满了 cpu 。

我在自己的机器上暂时无法重现问题,从分析上看,这个制造问题的 fd 是 0 ,也就是 stdin ,猜想和重定向有关系。

skynet 当初并没有处理 EPOLLERR 的情况(在 kqueue 中似乎没有对应的东西),这个我今天的 patch 补上了,不过应该并不能彻底解决问题。

我做了个简单的测试,如果强行 close fd 0 ,而在 close 前不把 fd 0 从 epoll 中移除,的确会造成一个不再存在的 fd (0) 不断地制造 EPOLLIN 消息(和 issue 中提到的不同,不是 EPOLLERR)。而且我也再也没有机会修复它。因为 fd 0 被关闭,所以无法在出现这种情况后从 epoll 移除,也无法读它(内核中的那个文件对象),消息也就不能停止。

May 18, 2017

Lua 表的差异同步

最近同事碰到的一个需求:需要频繁把一组数据在 skynet 中跨网络传递,而这组数据实际变化并不频繁,所以做了大量重复的序列化和传输工作。

更具体一点说,他在 skynet 中设计了一个网关节点,这个网关服务可以负责把一条消息广播给一组客户端,每个客户端由内部的一个 uuid 串识别,而每条消息都附带有客户端 uuid 列表。而实际上这些 uuid 列表组有大量的重复。每条广播消息都重复打包了列表组,且列表组有大量重复信息。

一开始我想的方法是专门针对这个需求设计一组协议,给发送过的数据组编上 id ,然后在发送方和接收方都根据 id 压缩通讯数据。即,第一次发送时,发送全量信息,之后再根据数据变化发送差异;如果完全没有变化,则只需要发送 id 。

之后我想,能不能设计一种较为通用的差异同步方法,可以在跨节点传递数据组的时候,避免将相同的数据重复传输,而采用差异同步的方法同步对象。

March 25, 2017

skynet cluster 模块的设计与编码协议

skynet 在最初的设计里,希望做一个分布式系统,抹平 actor 放在本机和处于网络两端的差别。所以,设计了 master/slave 模式。利用 4 个字节表示 actor 的地址,其高 8 位是节点编号,低 24 位是进程(节点)内的 id 。这样,在同一个系统中,不管处于哪个进程下,每个 actor (在 skynet 中被成为服务)都有唯一的地址。在投递消息时,无需关心目的地是在同一个进程内,还是通过网络来投递消息。

随后,我发现试图抹平网络和本地差异的想法不那么靠谱。想把一个分布式系统做得(和单一进程同样)可靠,无论如何都简单不了。而 skynet 的核心希望可以保持简单稳定。所以我打算把分布式的支持放在稍上一点的层次实现。

先来说说同一进程下的服务通讯和跨网络的通讯到底有什么不同。

  1. 进程内的内存是共享的,skynet 是用 lua 沙盒来隔离服务状态,但是可以通过 C 库来绕过沙盒直接沟通。如果一个服务生产了大量数据,想传给您一个服务消费,在同一进程下,是不必经过序列化过程,而只需要通过消息传递内存地址指针即可。这个优化存在 O(1) 和 O(n) 的性能差别,不可以无视。

  2. 同一进程内的服务从底层角度来说,是同生共死的。Lua 的沙盒可以确保业务错误能够被正确捕获,而非常规代码不可控的错误,比如断电、网络中断,不会破坏掉系统的一部分而另一部分正常工作。所以,如果两个 actor 你确定在同一进程内,那么你可以像写常规程序那样有一个共识:如果我这个 actor 可以正常工作,那么对端协作的另一个 actor 也一样在正常工作。就等同于,我这个函数在运行,我当然可以放心的调用进程内的另一个函数,你不会担心调用函数不存在,也不会担心它永远不返回或是收不到你的调用。这也是为什么我们不必为同一进程内的服务间 RPC 设计超时的机制。不用考虑对方不相应你的情况,可以极大的简化编写程序的人的心智负担。比如,常规程序中,就没有(非 IO 处理的)程序库的 API 会在调用接口上提供一个超时参数。

  3. 同一进程内所有服务间的通讯公平共享了同一内存总线的带宽。这个带宽很大,和 CPU 的处理速度是匹配的。可以基本不考虑正常业务下的服务过载问题。也就是说,大部分情况下,一个服务能生产数据的速度不太会超过另一个服务能消费数据的速度。这种情况会造成消费数据的服务过载,是我们使用 skynet 框架这几年来 bug 出现最多的类型。而跨越网络时,不仅会因为生产速度和消费速度不匹配造成过载,更会因为传递数据的带宽和生产速度不匹配而过载。如果让开发者时刻去考虑,这些数据是投递到本地、那些数据是投递到网络,那么已经违背了抹平本地和网络差异这点设计初衷。

所以我认为,除非你的业务本来就是偏重 IO 的,也就是你根本不打算利用单台硬件的多核心优势来增强计算力,抹平本机和网络的差异是没有意义的。无论硬件怎样发展,你都不可能看到主板上的总线带宽和 TCP 网络的带宽工作在同一数量级的那一天,因为这是物理基本规律决定的。

当你的业务需要高计算力,把 actor 放在一台机器上才可以正常的发挥 CPU 能力去合作;如果你的系统又需要分布式扩展,那么一定是有很多组独立无关的业务可以平行处理。这两类工作必须由构架系统的人自己想清楚,规划好怎么部署这些 actor ,而不可能随手把 actor 扔在分布式系统中,随便挑台硬件运行就够了。

恰巧网络游戏服务就是这种业务类型。多组服务器、多个游戏场景之间交互很弱,但其中的个体又需要很强的计算力。这就是 skynet 切合的应用场景。

March 20, 2017

skynet 1.1 发布候选版本

skynet 1.0 于 2016 年 8 月 1 日正式发布,到今天已经有 7 个多月了。这段时间积累了很多小修改,我想是时候发布 1.1 版了。

很高兴这段时间 skynet 社区继续壮大,有更多的公司选择基于 skynet 开发。

现打算在下个月以目前 github 仓库 master 分支为基础发布 1.1 正式版,这两周如果同学们还有什么问题请尽快提 issue 。

下面是从 1.0 开始积累的更新:

March 14, 2017

sproto 的一些更新

sproto 是我设计的一个类 google protocol buffers 的东西。

在很多年前,我在我经手的一些项目中使用 google protocol buffers 。用了好几年,经历了几个项目后,我感觉到它其实是为静态编译型语言设计的协议,其实并没有脱离语言的普适性。在动态语言中,大家都不太愿意使用它(json 更为流行)。一个很大的原因是,protobuffers 是基于代码生成工作的,如果你不使用代码生成,那么它自身的 bootstrap 就非常难实现。

因为它的协议本身是用自身描述的,如果你要解析协议,必须先有解析自己的能力。这是个先有鸡还是先有蛋的矛盾。过去很多动态语言的 binding 都逃不掉引入负责的 C++ 库再加上一部分动态代码生成。我对这点很不爽,后来重头实现了 pbc 这个库。虽然它还有一些问题,并且我不再想维护它,这个库加上 lua 的 binding 依然是 lua 中使用 protobuffer 的首选。

July 13, 2016

在 skynet 中如何实现多 actor 协作的事务

今天在 qq 群中,有个同学问,在 skynet 中,如果多个 actor 需要协作时,能否有事务来保证一系列操作的过程中,状态不会被破坏。

他举了个例子:

如果有一段业务逻辑是:

local a = skynet.call(A, ...)
local b = skynet.call(B, ...)
if a and b then
  dosomething()
end

如何保证 a and b 这个条件有效呢?也就是说,在第一行从 A 处获取状态后,还要向 B 查询一个状态;如果希望两者查询完毕,一直到 dosomething() 结束前,A 和 B 的状态都不要改变。期间,如果有请求发到了 A 或者 B ,最后都暂时挂起。

July 11, 2016

Skynet 1.0.0 发布

经历了 5 个 RC 版后,skynet 终于迎来了第一个正式发布版 1.0.0 。

目前这个版本基于 2012 年 7 月开始编写的代码,几乎从一开始就以开源模式发展。我们可以看到 2012 年 8 月 1 日在 github 上的第一次提交,到上周一正式版前的最后一次修改,已经过去了 4 年。

1403 次提交;

59 个代码贡献者;

3962 个 star ;

1850 个 fork ;

579 个邮件列表订阅者;

1700 个 qq 群成员。

June 30, 2016

skynet 的一个简单范例

之前已经介绍过,skynet 只是一个轻量框架,不是一个开箱即用的引擎。能不能用好它,取决于使用者是否清楚知道自己要干什么,如果是用 skynet 做网络游戏服务器,那么就必须先知道网络游戏服务器应该如何设计。

在 skynet 发布版中带的 example 中,有类似 gate watchdog agent 之类的服务,它们并不是唯一的用 skynet 构建游戏服务器的模式。我想另外写一个范例,示范依旧基于 skynet 但用不同的模式构建游戏服务器的方法。

我花了两天时间写了这么一个 sample ,放在 github 上

June 23, 2016

正确的序列化 Lua 中带元表的对象

在 Lua 5.2 之后的版本,约定了在元表中可以给出一个 __pairs 方法,而 lua 的基础库 pairs 会使用这个元方法来迭代一个对象。

Lua 5.3 之后的版本,取消了 lua 5.2 中的 __ipairs 约定,而统一使用 lua_geti 来访问整数为索引的数组。

可惜的是,许多 lua 序列化库对此支持的并不好。今天我在改进 bson 的序列化库时,重新考虑了这个问题,看看这个序列化过程怎么做,才能更好的支持 lua 5.3 以后的约定。

June 07, 2016

skynet 入门指南

随着 lua 5.3.3 的正式发布,我不想再拖了,希望下周就完全冻结 skynet 1.0 的 rc 版本,正式发布。

鉴于 skynet 一直都没有专门的上手指南,过去写的 skynet 设计综述 太老,之后 skynet 又有了很大的变化,所以我想是时候补一篇面向刚接触 skynet 的同学的文章了。

我在 wiki 上补充了一篇 GettingStarted ,欢迎大家多提意见。特别是没有接触过 skynet 又有兴趣的同学,可以看看这篇文章是否讲清楚了 skynet 到底是什么 :)

May 13, 2016

代理服务和过载保护

在 skynet 中,有时候为一个服务实现一个前置的代理服务是很有必要的。

比如,你希望对这个服务发起的请求是支持超时的,就不必在功能实现的服务中实现,那样会增加无谓的复杂性。你可以在功能实现的服务前加一个代理服务,当超时发生时,通知请求方。关于这个实现,我在 blog 中给过一个示例

同理,当你需要做一些负载均衡的处理的时候,也可以做一个代理服务,让请求分摊到多个可以完成类似功能的服务中去,实现比较简单,本文就不展开了。

今天想谈一下怎么利用代理服务更好的为一些热点服务提供过载保护。

May 07, 2016

skynet 服务的沙盒保护

昨天我们新的 MMO 游戏第一次上线小规模测试,暴露了一些问题。

服务器在开服 3 小时后,突然内存暴涨,CPU 占用率提升不多。当时 SA 已经收到报警邮件,但刚巧在午餐时间,而游戏功能还正常,耽误了半个小时。处理不够及时,导致在最终没有能收集到足够在线数据前,服务器已不能正常操作。另外,忘记配置 core dump 文件输出是另一个原因。

在最后几分钟,我们收集到一些信息:某个 lua 服务陷入 C 代码中的死循环,在 skynet 控制台发 signal 无法中断它(skynet 的 signal 可以中断 lua vm 的运行 )。从 log 分析,内存暴涨是突发的,几乎在一瞬间吃光了所有内存,而并非累积。

第一次宕机后,迅速重启了服务器。同时在内网又同步运行了机器人压力测试,但是无论是外网环境和内网环境均无法重现故障。

March 23, 2016

重载一个 skynet 中的 lua 服务

今天有同事问到,能不能不关闭 skynet 进程,直接重新加载一个 lua 服务。

简单的回答的不能。如果要详细回答,并非完全不行,但这个需求需要使用 skynet 的人自己定制出来。

其实,这涉及服务的热更新问题。由于 lua 的函数是 first class 对象,所以,它即好做热更新,又无法做成业务完全无关的。

容易做的一方面在于,lua 函数本身就是一个对象,只要你找到 lua vm 里的需要更新的函数对象被哪些地方应用,就可以生成(通过 load 新的脚本代码)新对象,取代值前的引用即可。

不容易做的一方面是,lua 完全不区分什么是代码,什么是数据,所以没有 C 语言中所谓符号表。所以并没有统一的地方可以查找 lua vm 里已有的函数。甚至函数也没有名字,你不能用名字索引出新版本的函数,替换掉老版本的。除非你的业务框架做了适当的约定。

另外,在 lua 中,所有函数都是闭包。如果你只想替换闭包中函数原型的实现,那么还需要做 upvalue 的重新关联。这是一个繁杂的过程,如果没有适当的约定,也无法彻底做对。btw, snax 里就做了一些约定,可以一定程度的做到热更新。

March 21, 2016

在 skynet 中处理 TCP 的分包

skynet 的核心并没有规定怎样处理 TCP 的数据流,但在开发网络游戏时,我们往往需要按传统,把 TCP 连接上的数据流分割为一个个数据包。

将数据流转换为数据包,比较常见的做法是给数据包加一个长度信息,组装在数据流中。我个人比较推荐使用两个字节表示包长度。在 skynet 中提供了一个 GateServer 的模板来帮助用户实现这样一个网关。

这个网关模板采用推送的模式,一旦用户初始化完毕,就会自动分割连接上的数据流,按协议(两字节长度加内容)分割为数据包,回调处理函数。在回调函数中,你可以为新连接启动一个独立服务来处理这个连接上的请求,也可以在单一服务中处理。这些不在本文中讨论。

January 06, 2016

基于引用计数的对象生命期管理

最近在尝试重新写 skynet 2.0 时,把过去偶尔用到的一个对象生命期管理的手法归纳成一个固定模式。

先来看看目前的做法:旧文 对象到数字 ID 的映射

其中,对象在获取其引用传入处理函数中处理时,将对象的引用加一,处理完毕再减一。这就是常见的基于引用计数的对象生命期管理。

常规的做法(包括 C++ 的智能指针)是这样的:对象创建时,引用为 1 (或 0)。每次要传给另一个处地方处理,或保留待以后处理时,就将其引用增加;不再使用时,引用递减。当引用减为 0 (或负数)时,把对象引用的资源回收。

由于此时对象不再被任何东西引用,这个回收销毁过程就可视为安全且及时的。不支持 GC 的语言及用这些语言做出来的框架都用这个方式来管理对象。


这个手法的问题在于,对象的销毁时机不可控。尤其在并发环境下,很容易引发问题。问题很多情况是从性能角度考虑的优化造成的。

加减引用本身是个很小的开销,但所有的引用传递都去加减引用的话,再小的开销也会被累积。这就是为什么大多数支持 GC 的语言采用的是标记扫描的 GC 算法,而不是每次在对象引用传递时都加减引用。

大部分情况下,你能清楚的分辨那些情况需要做引用增减,哪些情况下是不必的。在不需要做引用增减的地方去掉智能指针直接用原始指针就是常见的优化。真正需要的地方都发生在模块边界上,模块内部则不需要做这个处理。但是在 C/C++ 中,你却很难严格界定哪些是边界。只要你不在每个地方都严格的做引用增减,错误就很难杜绝。


使用 id 来取代智能指针的意义在于,对于需要长期持有的对象引用,都用 id 从一个全局 hash 表中索引,避免了人为的错误。(相当于强制从索引到真正对象持有的转换)

id 到对象指针的转换可以无效,而每次转换都意味着对象的直接使用者强制做一个额外的检查。传递 id 是不需要做检查的,也没有增减引用的开销。这样,一个对象被多次引用的情况就只出现在对象同时出现在多个处理流程中,这在并发环境下非常常见。这也是引用计数发挥作用的领域。

而把对象放在一个集合中这种场景,就不再放智能指针了。


长话短说,这个流程是这样的:

将同类对象放在一张 hash 表中,用 id 去索引它们。

所有需要持有对象的位置都持有 id 而不是对象本身。

需要真正操作持有对象的地方,从 hash 表中用 id 索引到真正的对象指针,同时将指针加一,避免对象被销毁,使用完毕后,再将对象引用减一。

前一个步骤有可能再 id 索引对象指针时失败,这是因为对象已经被明确销毁导致的。操作者必须考虑这种情况并做出相应处理。


看,这里销毁对象的行为是明确的。设计系统的人总能明确知道,我要销毁这个对象了。 而不是,如果有人还在使用这个对象,我就不要销毁它。在销毁对象时,同时有人正在使用对象的情况不是没有,并发环境下也几乎不能避免。(无法在销毁那一刻通知所有正在操作对象的使用者,操作本身多半也是不可打断的)但这种情况通常都是短暂的,因为长期引用一个对象都一定是用 id 。

了解了现实后,“当对象的引用为零时就销毁它” 这个机制是不是有点怪怪的了?

明明是:我认为这个对象已经不需要了,应该即使销毁,但销毁不应该破坏当下正在使用它的业务流程。


这次,我使用了另一个稍微有些不同的模式。

每个对象除了在全局 hash 表中保留一个引用计数外,还附加了一个销毁标记。这个标记只在要销毁时设置一次,且不可翻转回来。

现在的流程就变成了,想销毁对象时,设置 hash 表中关联的销毁标记。之后,检查引用计数。只有当引用计数为 0 时,再启动销毁流程。

任何人想使用一个对象,都需要通过 hash 表从 id 索引到对象指针,同时增加引用计数,使用完毕后减少引用。

但,一旦销毁标记设置后,所有从 id 索引到对象指针的请求都会失败。也就是不再有人可以增加对象的引用,引用计数只会单调递减。保证对象在可遇见的时间内可被销毁。

另外,对象的创建和销毁都是低频率操作。尤其是销毁时机在资源充裕的环境下并不那么重要。所以,所有的对象创建和销毁都在同一线程中完成,看起来就是一个合理的约束了。 尤其在 actor 模式下, actor 对象的管理天生就应该这么干。

有了单线程创建销毁对象这个约束,好多实现都可以大大简化。

那个维护对象 id 到指针的全局 hash 表就可以用一个简单的读写锁来实现了。索引操作即对 hash 表的查询操作可遇见是最常见的,加读锁即可。创建及销毁对象时的增删元素才需要对 hash 表上写锁。而因为增删元素是在同一线程中完成的,写锁完全不会并发,对系统来说是非常友好的。

对于只有唯一一个写入者的情况,还存在一个小技巧:可以在增删元素前,复制一份 hash 表,在副本上慢慢做处理。只在最后一个步骤才用写锁把新副本交换过来。由于写操作不会并发,实现起来非常容易。

skynet 消息队列的新设计(接上文)

接前一篇文 ,谈谈 skynet 消息队列的一些新想法。

之前谈到,每个服务的消息接收队列可以是定长的,且不必太长。因为正常运行中,每个服务都应该尽量消化掉需要处理的消息,否则会预示着某种上层设计的问题。

但是,在接收队列满的时候直接丢掉消息显然是不合理的。那意味着必须有更健全的错误传播机制,让发送失败方可以出错而中断业务。允许发送消息出错可能使上层结构设计更难。

让发送方阻塞在 skynet 中显然也不是个好方案。因为 skynet 的服务是允许阻塞时重入执行另一条新 session 的,这是和 erlang 最大的不同。这可以让单个 lua vm 的性价比更高,可以在要需要的时候,做共享状态,而不必全部业务都通过相对低效的消息通讯来完成;但其负面代价是重入会引发一些隐讳的 bug 。很多已有的 skynet 项目都依赖 send 消息不阻塞这点来保证逻辑正确,不能轻易修改。

我的解决方案是给每个服务再做一组发送队列。最接收方忙的时候,把待发消息放在自己这里的发送队列中。这样就可以由框架来确保消息都能正确的依次发送(这里不保证目的地不同的消息的先后次序,但保证目的地相同的消息次序)。

January 04, 2016

skynet 消息分发及服务调度的新设计

这个月 skynet 的 1.0 就会 release 最终版了,除了维护这个稳定版本。我考虑可以对一些不太满意的地方尝试做大刀阔斧的改变(当然不放在目前的稳定版本中)。

我对 skynet 解决的核心问题:多服务任务调度以及内部消息传播这块不是很满意,觉得如果换个方式实现可能会好一些。下面先把想法记下来。

目前,每个服务都有一个唯一的消息队列,且在内存足够的前提下,会无限增长。也就是说,向一个服务发消息是没有失败的可能的。多数情况下,单个服务的消息队列不会太长,在生产消费模型中,也不允许太长。太长意味着消费速度远远低于生产速度,情况多半会恶化。在历史上发生过多起事故,都是和服务过载 有关。

虽然 skynet 提供了 mqlen 这种方法供使用者查询当前服务的消息队列长度,以做出应变,但治标不治本。我想做一个大的设计改动来重新考虑这一块。

December 28, 2015

Skynet 1.0.0 RC 版发布

拖了很久,终于决定给 skynet 1.0.0 封版了。比预期的时间 足足晚了半年,好在还是在 2015 年把这个事情启动了。

其实已经很久没有对已有特性做修改了,如果的项目是在今年 3 月份以后跟进的 1.0 alpha 版的话,升级到目前的最新版本应该不会有太大痛苦。最近几个月几乎没有增加新的特性,反而是在裁减一些多余的,用的人不多的东西(为了兼容,把这样一些 API 移到了一些独立的模块中,方便废弃)。

据我所知,skynet 用于的商业游戏项目(以及一些非游戏项目)早已经超过了 2 位数,收获了不错的口碑。它不再是我们自己公司的内部项目,持续收到不同人的 PR 说明很多同学不仅仅在使用,更是用心在 review 代码,让它真正成为一组公众视野下的代码。我相信这是开源的终极意义:众目睽睽之下, Bug 无所遁形。

December 18, 2015

skynet 里的 coroutine

skynet 本质上只是一个消息分发器,以服务为单位,给每个服务一个独立的 id ,可以从任意服务向另一个服务发送消息。

在此基础上,我们在服务中接入 Lua 虚拟机,并将消息收发的 api 封装成 lua 模块。目前用 lua 编写的服务在最底层只有一个入口,就是接收并处理一条 skynet 框架转发过来的消息。我们可以通过 skynet.core.callback (这是一个内部 API ,用 C 编写,通常由 skynet.start 调用)把一个 lua 函数设置到所属的服务模块中。每个服务必须设置,且只能设置一个回调函数。这个回调函数在每次收到一条消息时,接收 5 个参数:消息类型、消息指针、消息长度、消息 session 、消息来源。

消息大致分两大类,一类是别人对你发起的请求,一类是你过去对外的请求收到的回应。无论是哪类,都是通过同一个回调函数进入。

在实际使用 skynet 时,你可以直接使用 rpc 的语法,向外部服务发起一个远程调用,等对方发送了回应消息后,逻辑接着走下去。那么,框架是如何把回调函数的模式转换为阻塞 API 调用的形式呢?

这多亏了 lua 支持 coroutine 。可以让一段代码运行了一半时挂起,在之后合适的时候在继续运行。

November 30, 2015

RPC 之恶

起因是最近有人在 skynet 邮件列表里贴了段错误 log ,从 log 显示,他在 table.sort 的比较函数里调用了 skynet 的 snax rpc 去获取远程数据。然后被 lua 无情的报了 attempt to yield across a C-call boundary 。

就 table.sort 不能 yieldable 的问题,其实在 lua 邮件列表里讨论过 。老大的说法是,这个 C 实现是递归的,想要在 C 层面保留上下文非常困难,如果勉强实现,也会大大降低正常不需要 yield 的 case 的性能,非常不划算。

通过这件事,我反而觉得 none-yieldable 的限制反而提前阻止了一个错误的实现,其实是应该庆幸的。

November 16, 2015

skynet 中实现一个 crontab 的方法

很多人问起,为什么 skynet 的时钟不随系统时钟变更。我的答案是,skynet 系统保证内部的计时机构是单调递增的,有了这个约束,用起来可以回避很多问题。

那么怎么做一个定时任务,好像 crontab 那样,而且可以随系统时间调整而变化。我的答案是,这是个纯业务层的需求,你爱怎么做怎么做,根据你的需求(业务量多少,定时是否频繁等)可以有不同的实现方式。但是总而言之,你需要在 skynet 里定制一个服务,可以按时驱动某种消息。而这个服务的定时机构,不应该完全依赖于 skynet 自己的内部时钟。

October 14, 2015

给 skynet.call 加上超时

不断的有人问在 skynet 里怎么给跨服务调用加上超时处理。我不太想再解释为什么在 skynet 这样的系统中,加入超时机制会使得在其上构建的系统增加不必要的复杂度。那只是为了想不到更好的方法而做的一点变通。

你应把 skynet 视为一个整体,正如你平常所写的程序,大多数 api 调用的时候不会设置超时一样。只有在明确的特殊需求时,才在上层在封装一层带超时参数的调用(而不是直接使用 skynet.call )。

当昨天再次收到这个特性请求时,我仔细考虑了一下。发现了一个有趣的方案,可以非入侵性的在现在的框架下加上超时返回这个特性,下面做一个记录:

August 20, 2015

共享 lua vm 间的小字符串

lua 中 40 字节以下的字符串会被内部化到一张表中,这张表挂在 global state 结构下。对于短字符串,相同的串在同一虚拟机上只会存在一份。

在 skynet 中,有大量的 lua vm ,它们很可能加载同一份 lua 代码。所以我之前改造过一次 lua 虚拟机,[让它们可以共享 Proto] 。这样可以加快多个虚拟机初始化的速度,并减少一些内存占用。

但是,共享 Proto 仅仅只完成了一半的工作。因为一段 lua 代码有一很大一部分包含了很多字符串常量。而这些常量是无法通过共享 Proto 完成的。之前的方案是在 clone function 的时候复制一份字符串常量。

或许,我们还可以做的更进一步。只需要让所有的 lua vm 共享一张短字符串表。

August 12, 2015

一个内存泄露 bug

起因是 skynet 的一个 Issue ,同时,这两天我们正在开发的一个项目也反应貌似有内存泄露。

我觉得两件事同时发生不太正常,就决定好好查一下。

其实在 skynet 里查内存泄露要比一般的项目容易的多。因为 skynet 天生就分成了很多小模块,叫作服务。模块申请的内存是独立的,内聚性很高。模块的生命期比整个进程短的多,模块的规模也不会太大,可以独立分析。一般说来,如果有内存申请没有归还,应该是 C 模块里的 bug 。而 skynet 会用到的 C 模块也很少,一旦有这样的问题,很快就能定位。

August 05, 2015

去掉 skynet 中 cluster rpc 的消息长度限制

之前写过一篇 为什么 skynet 提供的包协议只用 2 个字节表示包长度 里面提到, 如果有体积很大的消息传递需求,那么应该在上一层去处理。

从另一方面来说,我们应该正视长消息的处理,而不应该将其和普通(较短)消息的处理混为一谈,在底层抹平之间的区别。

最近,需求就来了。

我们的一个新项目希望在 cluster 间通讯的时候,可以支持较大的消息。原本提出需求的同学想自己修改 skynet 的 cluster 模块,修改底层协议的包头长度的。我即使阻止了他,并自己动手做了修改。

July 28, 2015

lua 分配器的一些想法及实践

从周末开始, 我一直在忙一个想法。我希望给 skynet 中的 lua 服务定制一个内存分配器。

倒不是为了提升性能。如果可以单独为每个 lua vm 配置一个内存分配器,自己调用 mmap 映射虚拟内存,就可以为独立的服务制作快照了。这样可以随时 fork 出子进程,只保留关心的 vm 的内存快照。主要可以有三个用途:

  1. 可以在快照上做序列化,并把结果返还父进程。通常做序列化有一定的时间代价,如果想定期保存的话,这个代码很可能导致服务暂停。

  2. 可以利用快照监控检查泄露。定期做快照相比较,就能找到累积的对象。我曾经做过这样的工具

  3. 可以在镜像上对快照做一些调试工作而不会影响主进程。

June 07, 2015

skynet 对客户端广播的方案

这是去年在 skynet mailling list 里讨论过的一个话题:skyent中socket中使用共享指针的方式是否有价值? ,今天 review 代码的时候又看到,觉得应该在 blog 记录一下。

简单说就是,当从 skynet 向外部发送相同内容的数据包时,虽然依旧需要一个个包的发送(除非在局域网做 UDP 组播),但在某些特定需求下,复制多份相同的数据包 buffer 是个不必要的开销。

尤其是在上面的帖子中提到的音视频广播的场合,几乎所有的业务都是在向 IO 写同样的数据。所以当时我在考虑后,给 skynet 的 socket 层增加了一个接口,供用户定义共享 buffer ,可以自己在待发送 buffer 对象中自己增加引用计数。

May 12, 2015

sproto 的缺省值处理

sproto 由于主要供 lua 使用,是不能在协议定义中描述缺省值的。所有不编码的字段都呈现为 lua 中的 nil 。

注:这里还有个实现上的小问题。如果一个数组为空,也会呈现为 nil 而不是空表,所以应当注意处理。

我最近给 sproto 增加了一个新的 api ,可以为一类消息生成一个全部为缺省值的 table 。这样,使用者就可以方便把这个 table 作为别的 table 的 metatable 中的 index 方法了。相当于实现了原来在 protobuf 中的缺省值的特性。

只不过,缺省值不能主动定义。所有的 integer 缺省为 0 、boolean 缺省为 false 、string 缺省为空串、子类型为空表,并附加有一项 __type 为子类型的名字。无数据的数组也缺省为一个空表。

April 30, 2015

ltask :用于 lua 的多任务库

2021 年 2 月 7 日:这个库已经删除,用新的实现代替了。见:https://blog.codingnow.com/2021/02/ltask.html


写这个东西的起源是,前段时间我们的平台组面试了一个同学,他最近一个作品叫做 luajit.io 。面试完了后,他专门找我聊了几个小时他的这个项目。他的核心想法是基于 luajit 做一个 web server ,和 ngx_lua 类似,但撇开 nginx 。当时他给我抱怨了许多 luajit 的问题,但是基于性能考虑又不想放弃 luajit 而转用 lua 。

我当时的建议是,不要把 lua/luajit 作为嵌入语言而自己写 host 程序,而是想办法做成供 lua 使用的库。这样发展的余地要大很多,也就不必局限于用户使用 lua 还是 luajit 了。没有这么做有很多原因是设计一个库比设计一个 host 程序要麻烦的多,不过麻烦归麻烦,其实还是可以做一下的,所以我就自己动手试了一下。

Lua 的多任务库有很多,有兴趣的同学 可以参考一下 lua user wiki

基于 skynet 的 MMO 服务器设计

最近,我们的合作方 陌陌 带了他们的一个 CP 到我们公司咨询一下 skynet 做 mmo 游戏项目中遇到的一些问题。因为他们即将上线一款 MMO ,在压力测试环节暴露了许多问题。虽然经过我们的分析,有很多问题出在他们的压力测试程序本身编写的 bug ,但同时也暴露出服务器的设计问题。

核心问题是,他们在实现 mmo 服务器时,虽然使用了 skynet 框架,但却把所有的业务逻辑都放在了同一个 lua 服务中,也就是一切都运行在一个 lua states 里。这样,几乎就没能利用上 skynet 原本想提供的东西。压力是一定存在的。

我花了一下午探讨了应该如何设计一个 MMO 的服务器。下面记录一下:

April 10, 2015

对象到数字 ID 的映射

skynet 中使用了一个 hash 表结构来保存服务和 32bit 数字地址的映射关系。

一个 skynet 的服务,其实是一个 C 对象。在有沙盒的系统中,尤其是并行构架,我们很少直接用 C 对象指针来标识一个 C 对象,而是采用数字 id 。用数字做 handle 可以让系统更健壮,更容易校验一个对象是否还有效,还可以减少悬空指针,多次释放对象等潜在问题。比如,操作系统为了把用户程序隔离在用户态,像文件对象就是用数字 id 的形式交给用户程序去用的。

和操作系统通常管理文件句柄的方式不同,skynet 尽量不复用曾经用过的 id 。这样,当你持有了一个已经不存在的 id ,不太会误当作某个新的对象。(操作系统的文件 handle 很容易造成这样的问题:你记住了一个文件,在别的流程中关闭了它,然后又打开了新文件,结果复用了过去的 handle ,导致你操作了错误的文件。很多年前,我就在自己的线上产品中碰到过这样的 bug 。)

但是,一旦尽量不复用 id 。用简单的数组来做映射就很难了。必须使用 hash 表来保证映射效率。在 skynet 中我是这样做的:

每次分配新的 id 时,首先递增上次分配出去的 id (允许回绕,但会跳过 0 。0 被当成无效 id )。然后检查 hash 表中对应的 slot 是否有冲突,若有冲突就继续递增。如果 hash 表的池不够用了,则成倍扩大池,并将内部的数据重新 hash 一次。

这样虽然会浪费一些 id ,但是可以保证 hash 表类的 key 永远不发生碰撞,这样可以让查询速度最快。hash 表的实现也相对简单一些。

我觉得这样的一个数据结构有一定的通用性,今天花了一点时间把 skynet 的这个部分单独抽出来,当成一个独立开源项目重新写了一遍。有兴趣的同学可以在 github 上查看

April 07, 2015

skynet 近期更新及 sproto 若干 bug 的修复

skynet 的 1.0 版已经发布了 3 个 alpha 版,等稳定以后将发布 beta 版本。

最近的问题主要集中在一些我们在老项目中没有使用到的特性上面。尤其是 sproto 这个模块,我希望它将来作为 skynet 推荐的通讯协议,但我们老的项目开始的比 sproto 的项目早,所以早期项目全部使用的是 google protocol buffers (以及我自己做的实现)。 随着新项目的开展,我们公司内部开始大面积使用 sproto ,也就发现了一些 bug ,在最近集中修复。

March 11, 2015

跳出死循环

在 skynet 中,有一个叫 monitor 的内部模块,它会监测是否有服务可能陷入了死循环。

工作原理是这样的:每次处理一个服务的一个消息时,都会在一个和服务相关的全局变量处自增 1 。而 monitor 是一个独立线程,它每隔一小段时间(5 秒左右)都检测一下所有的工作线程,看有没有长期没有自增的,若有就认为其正在处理的消息可能陷入死循环了。

而发现这种异常情况后,skynet 能做的也仅仅是输出一行 log 。它无法从外部中断消息处理过程,而死循环的服务,将永久占据一个核心,让系统整体性能下降。

采用 skynet 的 kill 指令是无法杀掉死循环的服务的。


当服务用 lua 编写时,我们则有可能做多一点工作。

February 28, 2015

skynet 1.0 发布计划

按原定计划,在 lua 5.3 正式发布后, skynet 也将发布 1.0 版了。这样,可以方便维护一个稳定的版本,让使用它的同学们更放心。

目前,github 仓库的主分支已经切换到 lua53 ,也就是将内置版本升级到 lua 5.3 后的分支。未来的 1.0 版也将基于这个分支开发(打上 1.0 的 tag 后,会合并回 master 分支)。

基本特性方面,2014 年 11 月 0.9 版之后,skynet 就没有什么变化过。我们公司内部也有 2 个项目长期线上运行,没有发现明显的问题。

虽然近期还在开发一些新特性。但都是可以完全和旧特性独立开的,也就是说,如果你不使用新特性,就不会引入新特性可能引入的新 bug 。

February 11, 2015

在线调试 Lua 代码

一直有人问,如何调试 skynet 构建的服务。

我的简单答案是,仔细 review 代码,加 log 输出。长一点的答案是,尽量熟悉 skynet 的构造,充分利用预留的监控接口,自己编写工具辅助调试。

之前的好多年,我也写过很多 lua 的调试器,这里就不一一翻旧帖了。今天要说的是,我最终还是计划加入 1.0 正式版的调试控制台。

也就是单步跟踪调试单个 lua coroutine 的能力。这对许多新手来说是个学走路的拐杖,虽然有人一辈子都扔不掉。

February 10, 2015

怎样在运行时插入运行一段 Lua 代码

最近想给 skynet 加一个在线调试器,方便调试 Lua 编写的服务。

Lua 本身没有提供现成的调试器,但有功能完备的 debug api 。通常、我们可以在代码中插入 debug.debug() 就可以进入一个交互环境,输入任何 Lua 指令。当然,你也可以在 debug hook 里调用它。

但这种交互方式有一个缺点:lua 直接用 load 翻译输入的文本,转译为一个 lua 函数并运行。这注定了这个输入的代码中不能直接访问到上下文的局部变量和 upvalue 。

如果想读写上下文中的局部变量或 upvalue ,还得使用 debug.getlocal 等函数。这无疑是相当麻烦的。

January 30, 2015

Lua 5.3 升级注意

最近在慢慢把公司的几个项目从 Lua 5.2 迁移到 Lua 5.3 ,为发布 skynet 1.0 alpha 版做准备。

在更新代码时发现了一些注意点,罗列一下:

Lua 5.3 去掉了关于 unsigned 等的 api ,现在全部用 lua_Integer 类型了。这些只需要换掉 api ,加上强制转换即可。通常不会有什么问题。

最需要细致 review 代码升级的是和序列化相关的库。在 skynet 里是序列化库、sproto、bson 等。我们还用到了 protobuffer ,也和序列化有关。

这是因为,Lua 5.3 提供了整型支持,而序列化工作通常需要区分浮点和整数分开处理。json 这种文本方式则不需要,同样还有 redis 的通讯协议也是如此。

过去判断一个 number 是浮点还是整数,需要用 lua_tonumberlua_tointeger 各取一份做比较。虽然到了 Lua 5.3 这种代码理论上可以不用改动,但正确的方法应该是使用 lua_isinteger

January 08, 2015

为什么 skynet 提供的包协议只用 2 个字节表示包长度

skynet 提供了一个 lua 库 netpack ,用来把 tcp 流中的数据解析成 长度 + 内容的包。虽然使用 skynet 的同学可以不使用它,而自己另外实现一套解析协议来解析外部 TCP 数据流(比如 skynet 中的 redis driver 解析 redis server 的数据流就是用的换行符分割包),但依然有很多同学询问,能不能自定义包头长度。

这里的这个库定义的协议中,包长度是用 big-endian 的 2 个字节表示的,也就是说一个包的长度不得超过 64K 。这让很多人很为难。已经几次有同学建议,把长度放宽成 4 个字节,因为他们的应用环境中大部分包不会超过 64K ,但总有少量超过这个限制的。

January 06, 2015

从 Lua 5.2 迁移到 5.3

在 2015 年的新年里,Lua 5.3 发布了 rc3 版

如果回顾 Lua 5.2 的发布历史,Lua 5.2 的 final 版是在 rc8 之后的 2011 年 12 月 17 日发布的,距离 rc1 的发布日 2011 年 11 月 24 日过去不到 1 个月。我们有理由相信正式版不远了。( 5.3 的 rc1 是 2014 年 12 月 17 日发布的)

这次升级对 Lua 语言层面的影响非常的小,但新增加的 int64 支持,以及 string pack 、utf8 库对开发帮助很大。所以我强烈建议正在使用 Lua 5.2 的项目尽快升级到 5.3 。相对而言,当初 5.1 向 5.2 升级的时候就痛苦的多(去掉了 setfenv ,增加了 _ENV)。

我计划在 Lua 5.3 正式发布后,将 skynet 内置的 Lua 版本升级到 5.3 ,然后着手进行 skynet 1.0 的发布工作。

December 14, 2014

skynet 社区广州聚会小记

昨天(2014 年 12 月 13 日),忙了一天,终于把说道半年的心事了了。skynet 社区的第一次线下聚会。

一直在 qq 群里说想搞一次线下聚会,让使用 skynet 的同学有机会当面交流一下,忙这忙那的拖到年底。直到又人有重提,择日不如撞日,就选在了本周六。

原来计划搞此 30 人的小聚会就好了,可以就在办公室会议室里。结果没料到公司最近扩张太快,新办公地又暂时没有搞定,会议室都被塞满了。那么转移到楼下餐厅吧,大概可以装下 40 人。

故意没有宣传,想着不会有太多人报名。可到了最后几天,邮件列表上的报名人数超过了 60 人,赶紧去找新场地。最终昨天来了 80 多人。btw. 南京远道来的同学辛苦了。

December 08, 2014

乐观锁和悲观锁

最近晓靖给 skynet 提了一个 pr

提之前我们讨论了好久,据说是因为查另外一个问题时改写了 skynet 的消息调度部分发现在某些情况下可以提高 CPU 的使用率。

之前 skynet 的消息调度采用的是基于 cas 的无锁结构。但本质上,并发队列这种数据结构,无论是采用 spin-lock 还是 cas 无锁结构,为了保证时序,进队列或出队列的部分都必须是依次进行的,也就是说,多核心无助于提高队列的性能。

使用无锁结构,无非是对发生冲突保有乐观态度,觉得大多数情况下冲突不会发生,一旦发生就采取重来一次的策略。

而使用 spin lock ,则是对冲突采取悲观策略,认为冲突经常发生,所以在操作共享字段时,锁住资源独享操作。

最终,都必须等前一件事情做完,才能接着做下一件事。

November 13, 2014

skynet 的 UDP 支持

考虑了很久, 最终还是给 skynet 加上了基本的 UDP 支持. 虽然大多数情况下, 我不赞成使用 UDP 协议. 尤其是在网络游戏领域. 但考虑到 skynet 已经不仅仅应用于游戏领域, 我想, 加入有限的 UDP 支持是有意义的.

btw, 根据最近的反馈看,有人把 skynet 用于交换机(由于使用的是 powerpc 的 CPU ,帮助解决了一些大小端 bug );有应用于证券领域;还有做视频广播的。另外,把 skynet 用于 web 开发的应该也有人在,就简单的测试来看,性能方面不比把 lua 集成到 nginx 差。

目前,UDP 这部分代码已经完成,放在 github 里一个叫 udp 的分支上,不久以后会合并到主干上。由于我自己没有什么这方面的需求,所以还需要有 udp 需求的同学读一下代码,实际使用,这样才可能发现潜在的问题。

关于这部分的设计以及 api 文档,我补充在 wiki 上了

October 09, 2014

skynet 服务的过载保护

最近我们的新游戏《天天来战》上了腾讯平台,由于瞬间用户量过大,发现了几个 bug。

这几个 bug 都是在最后一周赶进度时编写业务的同学写的太仓促,在一些处理请求的流水线上使用了时间复杂度 O(n) 以上的算法导致的问题。这些时间开销大的操作,虽然并不常见,但操作误放在了和用户登录相关的服务中,导致一旦阻塞,使得用户登录受到影响。

具体 bug 没什么好谈的,把业务拆分开,以及用 O(Log N) 或 O(1) 的算法重新实现后就好了。但发生 bug 后,skynet 的整体表现值得一提。

按原有的设计,skynet 可以视为一个简单的操作系统。每个服务都是这个系统中的进程。不相关的业务应该互不干扰(使用多核的硬件,核心越多,就可以表现的越好)。在这次事件中,的确也做到了受影响的部分(登录)处理能力不足,用户无法正常操作时;另一些做无关操作(副本游戏)的用户没有收到影响。但在服务过载后的恢复环节却做的不够。

由于 bug 的影响,有类消息的处理能力只有 20 次/s 左右。当需要处理的消息频率超过 20 次后(在线玩家超过 8000 人以后出现),该服务过载,导致整个系统处于半瘫痪状态。新用户无法正常进入,直到在线人数下降到 4000 人都没有好转。但已在玩游戏的用户没有受到影响,所以没有做任何处理。大约在 2 个多小时后,系统自己维护正常。这比预期时间要长得多。

这是上周遇到的问题。昨天又在新一轮导量中出现了类似的问题。由于配置问题把大量玩家(十万数量级)引到同一组服务器,导致该服务器几乎无法创建新角色,同时老玩家登录也无法获取自己的角色(因为和创建角色在同一服务内)。如果玩家有足够耐心,等待 10 分钟,还是可以正常进入游戏。这个状态在分流新用户后,得到了缓解。但服务器依然用了小时级的时间才自我恢复。经事后排查,同样是一处 bug 导致的性能问题,但自我恢复时间过长也值得关注。

August 29, 2014

近日工作记录

sproto 基本算完成了, 等 lua 5.3 正式发布后, 还需要把 64bit 整数支持一下。我给 sproto 加了 lua 封装,以方便更好的支持 rpc 。

子熏同学完成了 sproto 的 jit 版本。但似乎性能提升不是很明显。

我希望可以在 skynet 的下个大版本,把 sproto 作为推荐 C/S 通讯协议加进去。


目前正在开发的 skynet 新特性是可以把单个服务的外来消息全部 log 在一个文件中。目前支持了 skynet 的普通消息以及 socket 消息。如果有必要,还可以把组播消息加上。

目前这个特性主要用来调试。其实可以为之开发配套的工具,比如另外做一个调试工具,能够把所以记录的消息重放给一个特定的服务脚本,便可重现一个服务的工作历史。目前 log 文件中记录的消息时间和消息内容足以重现。只要消息中不包含内存地址,这种录像重播的测试方法应该是有效的。

不过暂时还没碰到需要这种调试(比如一个服务出现异常,可以利用录像回溯之前发生的事情,以及当时的现场),等需要时再根据需要制作这样的工具。


等 lua 5.3 正式发布后,打算把 pbc 跟进一下。skynet 里的 int64 支持也可以用 lua 5.3 官方特性取代。我相信到那个时候就可以发布 skynet 的 1.0 版了。


ejoy2d 这个项目,公司有许多同事有兴趣做进一步贡献。所以我把主仓库迁移到 ejoy 名下。

由于正在用 ejoy2d 开发的两个新项目比较紧,最近 ejoy2d 里增加了不少临时项目用的接口。暂时还没有精力去规整。许多新特性(比如粒子绑定,对资源异步加载的支持)都没能及时加上文档。

目前实测在 iphone4 上 ejoy2d 可能会有性能问题。为此,增加了一个 renderbuffer 的特性,可以把一批渲染的定点输出到固定的顶点 buffer 中,这对用复杂图素拼装起来的静态背景会有一些效果。不过关键还在于 iphone4 的 GPU 性能太差,稍微复杂一点的 fragment shader 就会很勉强,为次可能需要给 ejoy2d 加入更灵活的 shader 定制特性。

经过几天的努力,终于把我们新项目在 iphone4 上的 fps 从 12 提升到了 18 ,勉强可以接受了吧。离目标 30fps 还有一些距离,如果进一步的细调还是可以达到的,但会增加很多制作上的难度。不知道到明年,还是否需要考虑 iphone4 这个档次的硬件。


btw, 乘 steam 打折,周末玩了一天文明 V 的第二扩展,还是很不错的。非常期待年底的 beyond earth 。

August 12, 2014

STM 的简单实现

STM 全称 Software transactional memory

在前年的项目里,我制作了一个类似的东西。随着 skynet 的日趋完善,我希望找到一个更为简单易用的方法实现类似的需求。

对于不常更新的数据,我在 skynet 里增加了 sharedata 模块,用于配置数据的共享。每次更新数据,就将全部数据打包成一个只读的树结构,允许多个 lua vm 共享读。改写的时候,重新生成一份,并将老数据设置脏标记,指示读取者去获取新版本。

这个方案有两个缺点,不适合实时的数据更新。其一,更新成本过大;其二,新版本的通告有较长时间的延迟。

我希望再设计一套方案解决这个实时性问题,可以用于频繁的数据交换。(注:在 mmorpg 中,很可能被用于同一地图上的多个对象间的数据交换)

一开始的想法是做一个支持事务的树结构。对于写方,每次对树的修改都同时修改本地的 lua table 以及被修改 patch 累计到一个尽量紧凑的序列化串中。一个事务结束时,调用 commit 将快速 merge patch 。并将整个序列化串共享出去。相当于快速做一个快照。

读取者,每次读取时则对最新版的快照增加一次引用,并要需反序列化它的一部分,变成本地的 lua table 。

我花了一整天实现这个想法,在写了几百行代码后,意识到设计过于复杂了。因为,对于最终在 lua 中操作的数据,实现一个复杂的数据结构,并提供复杂的 C 接口去操作它性能上不会太划算。更好的方法是把数据分成小片断(树的一个分支),按需通过序列化和反序列化做数据交换。

既然序列化过程是必须的,我们就不需要关注数据结构的问题。STM 需要管理的只是序列化后的消息的版本而已。这一部分(尤其是每个版本的生命期管理)虽然也不太容易做对,但结构简单的多。

July 30, 2014

skynet 中如何实现邮件达到通知服务

skynet 中可以独立的业务都是以独立服务形式存在的。昨天和同事讨论如何实现一个邮件通知服务。

目前大概是这样的:有一个独立的邮件中心服务,它可以处理三条协议:

  1. 向一个 mailbox 投递一封邮件。
  2. 查询一个 mailbox 里有多少封邮件。
  3. 收取 mailbox 里指定的一封邮件。

用户读了多少邮件没有放在邮件中心,而是记在玩家数据里的。

用户的界面上需要显示是否有几封未读邮件,如果有新邮件达到,这个数字会自动变更。你可以想像成 iOS 上的那种带数字的小红点。

当然,在 skynet 的设计惯例中,每个用户在服务器上有一个 agent 代理,所以我们不单独考虑和客户端数据交互的问题,而只用考虑 agent 如何和邮件中心的交互。

现在的做法是,在用户上线的时候,就去邮件中心查一次,比较邮件数量后知道是否有新邮件,然后推送给玩家。

在玩家特定的操作后,比如进出副本等,都会重新查询一次。如果玩家在一个场景停留太久,客户端也会定期发起查询请求。

如果邮件必须在新邮件达到时,立刻通知给玩家怎么办呢?那么系统中另外有个用户中心的服务。邮件服务可以把消息推送到那里;用户中心发现玩家不在线,就扔掉消息;如果在线就做消息推送。


我觉得这个方案有那么一点点不好,所以提出了我的想法。

July 23, 2014

给 skynet 增加 http 服务器模块

一直没给 skynet 加 http 协议解析模块是因为这个领域我不熟悉,而懂这块(web 开发)的人很多,随便找个人做都应该比我做的好。世界上的 web 服务器实在是太多了,足见做一个的门槛也不高,我也没什么需求,所以就这样等着有需要的人来补上这一块。

但这两天实在等不了了。我们即将上线的一款游戏,运营方要求我们提供一组 web api 供运营使用。固然我们可以单独写一个进程挂在 nginx 的后面,并和 skynet 通讯,但游戏开发组的同学觉得不必把简单的事情做的这么绕。监听一个端口提供 http 协议的服务又不是什么特别麻烦的事情,结果就打算直接在 skynet 里提供。

July 15, 2014

skynet 消息服务器支持

周末终于把上周提到的 短连接服务 实现了。由于本质上是一个消息请求回应模式的服务器,并没有局限于长连接还是短连接,所以不打算用短连接服务来命名。

用户的登陆状态不再依赖于是否有连接保持,所以登陆服务也顺理成章的分离出来了。

用户先去统一的登陆服务器登陆,获得令牌,然后去游戏服务器连接握手。如果用户和游戏服务器的连接断开,只需要重新用令牌握手即可,不必重新回登陆服务器登陆。

当然用户也可以重新登陆,清除登陆状态,完成一个传统意义上的下线再上线的过程。

July 11, 2014

计划给 skynet 增加短连接的支持

不基于一个稳定 TCP 连接的做法,在 web game 中很常见。这种做法多基于 http 协议、以适合在浏览器中应用。

运行在移动网络上的游戏,网络条件比传统网络游戏差的多。玩家更可能在游戏进行中突然连接断开而导致非自愿的登出游戏。前段时间,我实现了一个库来帮助缓解这个问题

如果业务逻辑基于短连接来实现,那么也就不必这么麻烦。但是缺点也是很明显的:

每对请求回应都是独立的,所以请求的次序是不保证的。

服务器向客户端推送变得很麻烦,往往需要客户端定期提起请求。

安全更难保证,往往需要用一个 session 串来鉴别身份,如果信道不加密,很容易被窃取。


即使有这些缺点,这种模式也被广泛使用。我打算在下个版本的 skynet 中提供一些支持。

所谓支持、想解决的核心问题其实是上述的第三点:身份验证问题;同时希望把复杂的登陆认证,以及在线状态管理模块可以更干净的实现出来。

我不打算基于 HTTP 协议来做,因为有专有客户端时,不必再使用浏览器协议。出于性能考虑,建立了一个 TCP 连接后,也可以在上面发送多个请求。仅在连接状态不健康时,才建议新建一个 TCP 连接。

June 21, 2014

重新设计并实现了 skynet 的 harbor 模块

skynet 是可以启动多个节点,不同节点内的服务地址是相互唯一的。服务地址是一个 32bit 整数,同一进程内的地址的高 8bit 相同。这 8bit 区分了一个服务处于那个节点。

每个节点中有一个特殊的服务叫做 harbor (港口) ,当一个消息的目的地址的高 8 位和本节点不同时,消息被投递到 harbor 服务中,它再通过 tcp 连接传输到目的节点的 harbor 服务中。


不同的 skynet 节点的 harbor 间是如何建立起网络的呢?这依赖一个叫做 master 的服务。这个 master 服务可以单独为一个进程,也可以附属在某一个 skynet 节点内部(默认配置)。

master 会监听一个端口(在 config 里配置为 standalone 项),每个 skynet 节点都会根据 config 中的 master 项去连接 master 。master 再安排不同的 harbor 服务间相互建立连接。

最终一个有 5 个节点的 skynet 网络大致是这样的:

network.png

上面蓝色的是 master 服务,下面 5 个 harbor 服务间是互连的。master 又和所有的 harbor 相连。

这就是早期的 skynet 分布式集群方案。有一篇 2 年前的 blog 记录了当时的想法,可以一窥历史。

由于历史变迁,从早期的手脚架不全,到如今的 skynet 的基础设置日臻完善。这部分代码也改写过很多次,每每想做大的改动,都不敢过于激进。

最近,我们的一个新项目要上线,由于运营方只能提供虚拟机,且网络状态不是很好,暴露了 skynet 在启动组网阶段的一些时序漏洞。所以这个周末我咬牙把这块东西全部重新设计实现了。

June 10, 2014

skynet 主题 T 恤

目前联系了广州一家制衣厂做一些 skynet 的主题 T 恤。由于定的量很少,所以价格不算便宜(但材料选用的那家厂最好的)。

有同学帮忙开了家 taobao 店用于收款(不是我开的),想要的人自己下单。凑够 100 件就开工。如果做不成就退款。

想要的同学点这里

June 09, 2014

skynet 的集群方案

上周 release 了 skynet 的 0.3 版,其最重要的新特性就是给出了一套新的集群方案。

在过去,skynet 的集群限制在 255 个节点,为每个服务的地址留出了 8bit 做节点号。消息传递根据节点号,通过节点间互联的 tcp 连接,被推送到那个 skynet 节点的 harbor 服务上,再进一步投递。

这个方案可以隐藏两个 skynet 服务的位置,无论是在同一进程内还是分属不同机器上,都可以用唯一地址投递消息。但其实现比较简单,没有去考虑节点间的连接不稳定的情况。通常仅用于单台物理机承载能力不够,希望用多台硬件扩展处理能力的情况。这些机器也最好部署在同一台交换机下。


之前这个方案弹性不够。如果一台机器挂掉,使用相同的节点 id 重新接入 skynet 的后果的不可预知的。因为之前在线的服务很难知道一个节点下的旧地址全部失效,新启动的进程的内部状态已经不可能和之前相同。

所以,我用更上层的 skynet api 重新实现了一套更具弹性的集群方案。

May 21, 2014

skynet logo

skynet.png

skynet_b.png

折腾了几天,终于把 skynet 的 logo 设计好了。

May 15, 2014

skynet 征集 logo

有同学建议,该给 skynet 做个 logo 了。所以在 blog 上征集一下 :)

希望 logo 标识清晰简洁,没有繁杂的设计。可以有一个吉祥物,萌一点最好。

另外,skynet 社区渐渐建设起来了,目前开设了 QQ 群和中文邮件列表。我正在努力完善 wiki

如果你正在使用 skynet 开发项目,并想列在这个列表 中的话,可以在下面留言或直接在 github 上提 pr 。

当然也欢迎发自内心的夸赞几句,我可以收录在 wiki 的用户反馈中。暂时不收录批评 :)

May 12, 2014

skynet v0.2.0 发布

按原计划, 我今天给 skynet 的 github 仓库 打上了 v0.2.0 的 tag 。

这个版本增加的主要特性是组播 。其它都是对已有代码的整理。

虽然 skynet 只有不到三年的历史,但已经有不少历史包袱了。最早的 skynet 是用 erlang 实现的,在 erlang 中编写了一个 C driver 然后在里面嵌入 lua 虚拟机。设计 skynet 的 C 接口时,也没有经过实际项目的使用,有许多设计不当的地方。

有一些后来觉得不应该放在核心层的东西被实现在了核心内,一些本应该在底层提供的设施被放在了很高层实现。

还有一些小特性几乎没有被使用过,但却增加了整体实现的复杂度。

调整这些历史造成的设计需要一点点来,v0.2.0 向前迈了一步。

May 07, 2014

skynet 消息队列调度算法的一点说明

最近接连有几位同学询问 skynet 的消息队列算法中为什么引入了一个独立的 flags bool 数组的问题。时间久远,我自己差点都忘记设计初衷了。今天在代码里加了点注释,防止以后忘记。

其实当时我就写过一篇 blog 记录过,这篇 blog 下面的评论中也有许多讨论。今天把里面一些细节再展开说一次:

我用了一个循环队列来保存 skynet 的二级消息队列,代码是这样的:

#define GP(p) ((p) % MAX_GLOBAL_MQ)

static void 
skynet_globalmq_push(struct message_queue * queue) {
    struct global_queue *q= Q;

    uint32_t tail = GP(__sync_fetch_and_add(&q->tail,1));
    q->queue[tail] = queue;
    __sync_synchronize();
    q->flag[tail] = true;
}

struct message_queue * 
skynet_globalmq_pop() {
    struct global_queue *q = Q;
    uint32_t head =  q->head;
    uint32_t head_ptr = GP(head);
    if (head_ptr == GP(q->tail)) {
        return NULL;
    }

    if(!q->flag[head_ptr]) {
        return NULL;
    }

    __sync_synchronize();

    struct message_queue * mq = q->queue[head_ptr];
    if (!__sync_bool_compare_and_swap(&q->head, head, head+1)) {
        return NULL;
    }
    q->flag[head_ptr] = false;

    return mq;
}

April 30, 2014

skynet 的新组播方案

最近在做 skynet 的 0.2 版。主要增加的新特性是重新设计的组播模块。

组播模块在 skynet 的开发过程中,以不同形式存在过。最终在 0.1 版发布前删除了。原因是我不希望把这个模块放在核心层中。

随着 skynet 的基础设施逐步完善,在上层提供一个组播方案变得容易的多。所以我计划在 0.2 版中重新提供这个模块。注:在 github 的仓库中,0.2 版的开发在 dev 分支中,只到 0.2 版发布才会合并到 master 分支。这部分开发中的特性的实现和 api 随时都可能改变。

April 23, 2014

Skynet 发布第一个正式版

距离 skynet 开源项目的公布 已经有 20 月+ 了,如果从闭源阶段算起,已经超过了 30 个月。在我们公司内部有五个项目使用 skynet 开发,据有限的了解,在我们公司之外,至少有两个正式项目使用了相当长的时间。是时候发布一个正式版了。

今天 skynet 的第一个正式版本 v0.1.0 发布了。

在发布之前,我花了几天时间帮助公司内部的项目合并代码。最后全部统一使用这个版本。而在此之前,每个项目都是由一个负责人 fork 出一份,根据项目需要自己修改。merge 工作总是做的痛苦不堪。

通过这次发布,希望未来可以统一维护基础框架部分。

April 17, 2014

skynet 的 snax 框架及热更新方案

skynet 目前的 api 提供的偏底层,由于一些历史原因,某些 api 的设计也比较奇怪。(比如 skynet.ret 是不对返回数据打包的)

我想针对一些最常见的应用环境重新给出一套更简单的 api ,如果按固定模式来编写 skynet 的内部服务会简单的多。

这就是这两天实现的 snax 模块。今天我已经将其提交到 github 的 snax 分支上,如果没有明显的问题,将合并入主干。

snax 仅解决一个简单的需求:编写一个 skynet 内部服务,处理发送给它的消息。snax 并不会取代 skynet 原有的 api ,只是方便实现这类简单需求而已。

April 15, 2014

对 skynet 的 gate 服务的重构

由于历史原因,skynet 中的 gate 服务最早是用 C 写的独立服务。后来 skynet 将 socket 的管理模块加入核心后又经历过一次重构,用后来增加的 socket api 重新编写了一遍。

目前,skynet 的各个基础设施逐步完善,并确定了以 lua 开发为主的基调,所以是时候用 lua 重写这个服务了。

如果是少量的连接且不关心性能的话,直接用 skynet 的 lua socket 库即可。这里有一个例子

gate 定位于高效管理大量的外部 tcp 长连接。它不是 skynet 的核心组件,但对于网络游戏业务,必不可少。

March 27, 2014

在不同的 lua vm 间共享 Proto

在 skynet 这种应用中,同一个系统进程里很轻易的就会创建数千个 lua 虚拟机。lua 虚拟机本身的开销很小,在不加载任何库(包括基础库)时,仅几百字节。但是,实际应用时,还需要加载各种库。

在 lua 虚拟机中加载 C 语言编写的库,同一进程中只会存在一份 C 函数原型。但 lua 编写的库则需要在每个虚拟机中创建一份拷贝。当有几千个虚拟机运行着同一份脚本时,这个浪费是巨大的。

我们知道,lua 里的 function 是 first-class 类型的。lua 把函数称为 closure ,它其实是函数原型 proto 和绑定在上面的 upvalue 的复合体。对于 Lua 实现的函数,即使没有绑定 upvalue ,我们在语言层面看到的 function 依然是一个 closure ,只不过其 upvalue 数量为 0 罢了。

btw, 用 C 编写的 function 不同:不绑定 upvalue 的 C function 被称为 light C function ,可视为只有原型的函数。

如果函数的实现是一致的,那么函数原型就也是一致的。无论你的进程中开启了多少个 lua 虚拟机,它们只要跑着一样的代码,那么用到的函数原型也应该是一样的。只不过用 C 编写的函数原型可以在进程的代码段只存在一份,而 Lua 编写的函数原型由于种种原因必须逐个复制到独立的虚拟机数据空间中。

March 23, 2014

Skynet 新的 socket.channel 模式

大部分外部网络服务都是请求回应模式的,skynet 和外部数据库对接的时候,直接用 socket api 编写 driver 往往很繁琐。需要解决读取异步回应的问题,还需要正确处理连接断开后重连的问题。

这个周末,我试着给 skynet 的 socket 模块加了一个叫做 channel 的模式,用来简化这类问题的处理。

可以用 socket.channel { host = hostname, port = port_number } 创建出一个对象。这个对象用来和外部服务器通讯。

March 04, 2014

谈谈陌陌争霸在数据库方面踩过的坑( Redis 篇)

注:陌陌争霸的数据库部分我没有参与具体设计,只是参与了一些讨论和提出一些意见。在出现问题的时候,也都是由肥龙、晓靖、Aply 同学判断研究解决的。所以我对 Redis 的判断大多也从他们的讨论中听来,加上自己的一些猜测,并没有去仔细阅读 Redis 文档和阅读 Redis 代码。虽然我们最终都解决了问题,但本文中说描述的技术细节还是很有可能与事实相悖,请阅读的同学自行甄别。

在陌陌争霸之前,我们并没有大规模使用过 Redis 。只是直觉上感觉 Redis 很适合我们的架构:我们这个游戏不依赖数据库帮我们处理任何数据,总的数据量虽然较大,但增长速度有限。由于单台服务机处理能力有限,而游戏又不能分服,玩家在任何时间地点登陆,都只会看到一个世界。所以我们需要有一个数据中心独立于游戏系统。而这个数据中心只负责数据中转和数据落地就可以了。Redis 看起来就是最佳选择,游戏系统对它只有按玩家 ID 索引出玩家的数据这一个需求。

我们将数据中心分为 32 个库,按玩家 ID 分开。不同的玩家之间数据是完全独立的。在设计时,我坚决反对了从一个单点访问数据中心的做法,坚持每个游戏服务器节点都要多每个数据仓库直接连接。因为在这里制造一个单点毫无必要。

根据我们事前对游戏数据量的估算,前期我们只需要把 32 个数据仓库部署到 4 台物理机上即可,每台机器上启动 8 个 Redis 进程。一开始我们使用 64G 内存的机器,后来增加到了 96G 内存。实测每个 Redis 服务会占到 4~5 G 内存,看起来是绰绰有余的。

由于我们仅仅是从文档上了解的 Redis 数据落地机制,不清楚会踏上什么坑,为了保险起见,还配备了 4 台物理机做为从机,对主机进行数据同步备份。

Redis 支持两种 BGSAVE 的策略,一种是快照方式,在发起落地指令时,fork 出一个进程把整个内存 dump 到硬盘上;另一种唤作 AOF 方式,把所有对数据库的写操作记录下来。我们的游戏不适合用 AOF 方式,因为我们的写入操作实在的太频繁了,且数据量巨大。

December 20, 2013

skynet lua 服务的内存管理优化

前两天一直困扰我的问题是并发启动 lua state 比串行启动它们要慢的多。而启动 lua state 的操作相互是完全无关的,没有任何应用层的锁。原本我以为多核同时做这些事情,即使不比单核快,也不至于慢一倍吧?

昨天有同学在 google talk 上和我讨论这个问题,说要不要考虑下是内存资源方面的问题。比如,在大量线程同时申请并读写大量内存时,有可能引起操作系统在映射物理内存到虚拟地址空间这个操作上出现性能问题。

虽然最后确认不是这个原因,但这启发我可能内存分配器在多核上工作并不顺畅。

我们使用 skynet 的项目利用 jemalloc 替换了默认 glibc 的分配器。tcmalloc 也应该是一个好的选择。但无论是哪个,都有多线程锁的问题。而 lua 可以自定义内存管理器,我们在 lua 服务启动时,若预分配 1M 内存,那么在这 1M 内存内的内存管理就完全没有线程安全的顾虑了,理论上这种定制的内存管理器会比一切通用管理器表现更好。

昨天我花了 3 个小时实现了一个简单的 lua 内存管理器,提交到 github 上。默认是关闭的,有兴趣使用的同学可以在 service_lua.c 里把

#define PREALLOCMEM (1024 * 1024)

的注释去掉。这样就可以启用为 skynet 专配的 lua 内存分配器了。

December 18, 2013

skynet 服务启动优化

我们开发 6 个月的手游即将上线,渠道要求我们首日可以承受 20 万同时在线,100 万活跃用户的规模。这是一个不小的挑战,我们最近在对服务器做压力测试。

我们的服务器基于 skynet 构架,之前并没有实际跑过这么大用户量的应用,在压力测试时许多之前理论预测的问题都出现了,也发现了一些此前没有推测到的现象。

首先,第一个性能瓶颈出现在数以万计的机器人同时登陆系统的时候。这是我们预测到的,之前有为此写过一篇 blog

为了解决这个拥塞问题,我的建议是用这样一个系列的方案:

  1. 用户认证不要接入 agent 服务。即,不要因为每个新连接接入都启动一个新的 agent 为它做认证过程。而应该统一在 watchdog 分发认证请求。当然这样就不可以用 skynet 默认提供的 watchdog 了。skynet 的源代码库中之所以实现了一份简单的 watchdog ,更多的是一个简单的示范。我们自己开发的两个项目都自己定制了它。

  2. 认证的具体业务逻辑(例如需要接入数据库等),实现在一个独立的服务中,做成无状态服务,可以任意启动多份。由 watchdog 用简单的均匀负载的方式来使用。如有需要,再实现一套排队流程(参考 1, 参考 2 ),也由 watchdog 调度。

  3. 我们目前这个项目的设计是唯一大服,所有用户在一个服务器中,要求承担百万级用户同时在线。所以我们在每台物理机上都配备了一个 watchdog ,通过内部消息在中心服务器统一协调。如果不这样设计,watchdog 会实现的更简单。watchdog 只负责维护用户在线状态,没有具体的计算压力,所以很难成为性能热点。

  4. 当用户认证成功后,watchdog 启动一份 agent ,通知 gate (连接网关) 把用户连接重定向到 agent 上。后续用户的业务逻辑,都有一对一的 agent 为它服务。

December 09, 2013

Skynet 的服务监控及远程调用

基于 Actor 模式的框架,比较难解决的问题是当一个 actor 异常退出后如何善后的问题。

Erlang 的做法简单粗暴,它提供了 spawn_link 方法 , 当一个 process (Erlang 的 Actor) 退出后,可以把和它关联的 process 也同时退出。

在 skynet 中,一开始我并不想在底层解决这个问题。我希望所有的 service 都是稳定的。如果一个 service 可能中途退出,那么在上层协调好这个关系。

而且 skynet 借助 lua 的 coroutine 机制,事实上在同一个 lua service 里跑着多个 actor 。一个 lua coroutine 才是一个 actor 。粗暴的将几个 service 在底层绑定生命期不太合适。

但是,如果有 service 有中途退出的可能,那么利用 skynet.call 调用上面的远程方法就变得不可靠。简单的用 timeout 来解决我认为只是回避了问题,而且会带来更多的复杂性。这是我在设计 skynet 时就想避免的。所以我在处理 service 生命期监控的问题上,做的比较谨慎。

October 30, 2013

skynet 中 Lua 服务的消息处理

最近为 skynet 修复了一个 bug ,Issue #51 。经查,是由于 redis driver 中的 batch 模式加锁不当造成的。

有同学建议把 batch 模式取消,由于历史原因暂时还保留。在很多其它 redis driver 的实现中也不提供类似机制。也就是依次提交多个数据库操作请求,不用等回应,最后再集中处理数据库返回的信息。

我的个人建议是在目前的 redis driver 基础上再实现一个独立服务,里面做一个连接池,让系统不同服务对数据库的读写工作在不同连接上,这样可能更好些。如果简单的实现一个数据库代理服务而不采用连接池的话,可能会面对一些意想不到的情况。

这是由 skynet 的 lua 模块工作方式决定的,下面解释一下。

September 11, 2013

skynet 的启动流程中的异步 IO 问题

有同学向我反应,自从 skynet 的 IO 库重写后,Mac OSX 上便无法启动了。

我检查了一下,直接原因是 kqueue 部分写的不对。kqueue 和 epoll 的 api 设计还是有些区别的,epoll 的 api 可以合并读写消息,但 kqueue 读是读,写是写。当时随手写好后一直没有在真实环境下测试,所以一直是有问题的,只是这次另外一个问题将它暴露了。

我新写的 IO 库是异步操作的。异步处理针对任何 IO 请求,其中也包括了建立连接以及监听连接。skynet 启动的时候需要启动一个 master 服务,然后再各个分布节点上启动 harbor 服务通过 master 互联。这些连接是通过 IO 模块的 socket connect 建立的。

我希望在 skynet 启动完成前把这些连接(至少是主节点的)都正确建立起来。

August 24, 2013

Skynet 的一次大更新

Skynet (关于 skynet 的更多 blog 见右侧导航条上的 skynet tag) 的设计受我在 2006 年做过的一个卡牌游戏服务器影响很重,后来又受到 2008~2011 年期间 Erlang 的影响。多年的经验也让我背上许多思想包袱,以前觉得理所当然的东西,后来没来得及细想就加入了 skynet 里面。

最近项目稳定下来,并且开始了第一个手游项目,虽然带点试验性质,毕竟也是第 2 个我们自己正式使用 skynet 的项目了。做第 2 个服务器项目的晓靖同学对 skynet 提出了不少想法和疑问,让我重新考虑了以前的设计。最近花了一个月时间重写了大量的代码,为下一步重构底层设计做好准备。

在一个稍有历史的活着的项目上做改造是不容易的,时刻需要考虑向前兼容的问题,又不想因为历史包袱而放弃改良的机会,我只能尽力而为了。


最初我认为 skynet 是为分布式运算设计的,没有用 erlang 的主要原因是因为我们有大量的业务逻辑需要(习惯)用命令式语言编写。所以最早的 skynet 版本是基于 erlang 的,并把 lua 嵌入了 erlang 。或许只是我们的实现不太好,总之结果性能表现很糟糕。放弃直接用 erlang 编写业务逻辑,而是透过 C driver 到 lua 中去解析消息请求回应加大了中间层的负担。慢慢的我发现,erlang 带给我们的好处远不如坏处多。

除了性能,我们的代码充满了不同语言不同风格:有用 erlang 实现的,有用 lua 的,还有用 C 的。为了跨语言,又不得不定义了统一的消息格式(使用 google protobuffer)。这些加起来变得厚重很难维护。

最终我决定用 C 重写底层的代码,并重新考虑 skynet 底层模块的设计定位。

August 06, 2013

如何安全的退出 skynet

Skynet 在最开始设计的时候是没有仔细考虑系统退出的问题的,后来为了检测内存泄露的问题,加入了一个 ABORT 指令可以杀掉全部活着的服务。

安全的退出整个系统,尤其是一个分布式系统,总是一个复杂的问题。我们的内部版为这些做了大量的工作。但我觉得做的不太干净,所以一直没有把代码合并到开源版。

今天晓靖又重提这个退出系统的方案,就又把这个问题拿出来讨论了一下。我的个人观点是,如何安全的退出和业务逻辑相关性很强,在框架中加入大量的通知机制让每个服务自己协调解决即增加了框架的复杂度,又不降低系统的设计难度。我们目前内部版本中增加的服务退出消息以及服务优先级等等这些机构过于复杂,且没有很好的解决问题。

曾经我想过按操作系统的做法,杀掉一个服务先发送一个消息给这个服务,让服务有一小段时间可以做最好的事务处理。等超时后,就强行把服务杀掉。这样显得不够安全,但若等服务自行干净退出又有可能发生死锁。我觉得,对于整个系统都是自己设计构建的话,其实设计人员是能够理解如何安全的关闭系统的。只有小部分服务需要做善后工作,比如数据库读写层需要把尚未写完的数据写入数据库后才能退出;大部分服务是无状态的,它们可以直接清理;还有一部分服务的关闭过程比较复杂,比如网关,就需要先关闭监听端口,再逐个关闭客户链接,最后才能退出自己。

让每个服务自己收到退出消息后想办法处理好退出流程,不如有一个专门掌管系统关闭的服务来统一协调系统关闭退出的流程。目前的 launcher 服务掌管了大部分服务的启动流程,可以稍加改进来管理退出过程。也可以把这一部分业务独立实现。无论怎样,都不要在框架底层来做太多事情。每个有复杂退出流程的服务,应在启动时把自己上报,退出管理器了解所有系统中活跃的服务,按系统的整体设计来决定退出的时候按怎样的次序做好哪些事情。

框架可以做一点点事情,能让这件事情做起来简洁一点。那就是可以指定一个服务可以监听到其它服务的关闭事件,而不需要服务在退出的时候自行汇报。

July 30, 2013

给 skynet 添加 mongo driver

前段时间 实现了 mongo 的 lua driver ,做了一些基础工作后,由于工作比较忙就放下了。

这几天有同学告诉我他们在用这个了,并找到了一处 bug 。我还真是受宠若惊啊。一咬牙决定把这个东东整合到 skynet 中去。

本来觉得挺简单,做起来后发现必须把 lua-mongo 里的 socket 部分剥离开,才能替换成 skynet 的 socket 库

这个分离工作花了我一天的时间,结果虽然会损失一点性能,但是 mongo 的底层协议解析模块就可以独立出来。

和 skynet 的整合工作在做完这个步骤后,要轻松的多了。只需要把 socket 模块替换成 skynet 提供的即可。

注意:我们的项目暂时还没有使用 MongoDB ,所以我只实现了最基本的 mongo driver 的特性。需要有兴趣的同学帮我完善,或者,等我们的项目开始用 mongo 的话,总有一天我会自己把这些工作做完的。

有兴趣的同学可以直接 pull request 到 lua-mongo ,我会整合新功能,并合并到 skynet 中。

目前最希望完成的是短线自动重连, replica set ,以及 write concern 这三项特性。它们应该都可以在 lua 层完成。

July 25, 2013

coroutine 的回收利用

这几天在 lua 和 luajit 的邮件列表上有人讨论 coroutine 的再利用问题。

前几天有个用 skynet 的同学给我写了封邮件,说他的 skynet 服务在产生了 6 万次 timeout 后,内存上升到了 50M 直到 gc 才下降。

这些让我重新考虑 skynet 的消息处理模块。skynet 对每条消息的相应都产生了一个新的 coroutine ,这样才能在消息处理流程中,可以方便的切换出去让调度器调度。诸如 RPC/ socket 读写这些 api 才能在用起来看成是同步调用,却在实现上不阻塞线程。

读源码可知,lua 的 coroutine 非常轻量(luajit 的略重)。但依旧有一些代价。频繁的动态生成 coroutine 对象也会对 gc 造成一定的负担。所以我今天花了一点时间优化了这个问题。

简单说,就是用自己写的 co_create 函数替换掉 coroutine.create 来构建 coroutine 。在原来的主函数上包裹一层。主函数运行完后,抛出一个 EXIT 消息表示主函数运行完毕。并把自己放到池中。如果池中有可利用的旧 coroutine ,则可以传入新的主函数重新利用之。

为了简化设计,如果 coroutine 中抛出异常,就废弃掉这个 coroutine 不再重复利用。为了防止 coroutine 池引用了死对象,需要在主函数运行完后,把主函数引用清空,等待替换。

具体实现参见这个 patch

ps. coroutine poll 故意没实现成弱表,而是在相应 debug GC 消息时再主动清空。

July 22, 2013

增强了 skynet 的 socket 库

今天想在 skynet 下访问外部的 http 接口, 所以试了一下前几天新写的非阻塞 socket 库

由于测试的时候用的 URL 被墙了,所以发现了问题:connect 目前是阻塞模式的。连接一个有问题的服务器可能被阻塞很久。所以花了点时间把 connect 接口改成非阻塞的了。

我以前没处理过这种情况,所以也就按书上的写法写写,异步 connect 做起来挺麻烦的。如果有用 skynet 的同学这方面经验丰富,烦请用之前帮忙 review 一下新打的 patch 。

另外,为了处理 http 的需求,给 socket 库增加了新接口 readall ,可以读 socket 上的所有数据(直到对方 close 再返回)。

socket.read 和 socket.readline 也改进了,如果对方关闭了连接,那么最后一次调用除了返回 nil 外,也把最后发送过来的数据返回。这样,readline 就可以处理最后一行没有分割符的情况了。

July 02, 2013

Hive 增加了 socket 库

按计划给 Hive 增加了非阻塞的 socket 库。这样,它就可以用 lua 完成 skynet 中的 gate 以及其它 tcp 连接相关的功能了。

我比较纠结的是 listen 这个 api 的设计。传统的 bind/listen/accept 模型不太适合这样的 actor 模式。一个尝试过的方案是 skynet 中的 gate 。也就是在 listen 的端口上每收到一个新连接,就转发到新的 cell 中去。

为了灵活起见,我把转发控制交给了回调函数。这也是用 lua 的灵活之处。

用户可以选择启动新的 cell ,然后把新连接 forward 到新 cell 去,也可以 fork 一个 coroutine ,forward 回自己处理。整套 socket api 是非阻塞的,如果 IO 上没有数据,coroutine 都会被挂起。

这次 Hive 这个项目算是业余的尝试,skynet 在我们的项目中已经积累了太多相关代码,不太容易迁移到新的框架下来。而且用 lua 实现大部分以前用 C 实现的代码,性能损失未知。不过这几天写下来觉得,C 没有很好的 coroutine 支持,在做异步消息处理时非常麻烦,以至于用 C 写的模块都不能很好的提供服务的同时保持代码简洁。用 Lua 重新实现它们要简单的多。

June 28, 2013

skynet 下的用户登陆问题

今天收到一个朋友的邮件,他们使用 skynet 框架的游戏上线后遇到一些问题。

引用如下:

我有一个线上的 server 用了skynet框架,用了gate watchdog agent这样的结构,当人数到达1000左右的时候发现很多新 socket 连接都阻塞在了 gate 或 watchdog 这里,导致用户登录不了,后面发现是客户端那边在登录的时候做了超时判断,如果超时了就断开连接,重新连 server 执行登录过程,因为 watchdog 是在收到客户端发来的第一个消息的时候创建agent,这就导致很多 cpu 消耗在创建销毁 agent 上,而 agent 服务创建的代价比较高,这就导致watchdog效率下降。后来取消掉了客户端的那个登录超时判断,情况有所好转,但在人多的时候延迟还是存在,接着又优化了server 这边的一些逻辑代码,现在还在观察中。


中午吃饭的时候,我和我们的开发人员讨论了这个问题。为什么我们在 1000 人级别时没有出现类似状况呢?我总结的原因如下:

June 26, 2013

Hive , Lua 的 actor 模型

上个周末我一直在想,经过一年多在 skynet 上的开发,我已经有许多相关经验了。如果没有早期 erlang 版本的历史包袱以及刚开始设计 skynet 时的经验不足,去掉那些不必要的特性后的 skynet 应该是怎样的。

一个精简过代码的 skynet 不需要支持 Lua 之外的语言和通讯协议。如果某个服务的性能很关键,那么可以用 C 编写一个 Lua 库,只让 Lua 做消息分发。如果需要发送自定义协议的消息,可以把这个消息打包为一个 C 结构,然后把 C 结构指针编码在发送的消息中。

skynet 的内部控制指令全部可以移到一个系统服务中,用 Lua 编写。

跨机支持不是必要的。如果需要在多个进程/机器上运行多份协同工作,可以通过编写一个跨机通讯的服务来完成。虽然会增加一个间接层使跨进程通讯代价更大,但是可以简化许多代码。

广播也不是基础设施,直接用循环发送复制的消息即可。为了必要过大的消息在广播过程中反复拷贝,可以把需要广播的消息先打包为 C 对象,然后仅广播这个 C 对象的指针即可。

June 21, 2013

重写了 skynet 中的 socket 库

前几天在做 skynet 的 mongodb driver ,感觉以前为 skynet 做的 socket 库不太好用。

以前 skynet 对外的网络连接是通过 connection 服务实现的,这个服务会自动推送绑定的 socket 的上行数据到指定的位置。接下来,需要自己用 coroutine 去分析这个数据流。这样做比设计一个询问应答接口来获取 socket 上的数据流要高效一些,但用起来很不方便。

我希望能有一个看起来和传统的 socket api 类似的接口,直接 read/write 即可,但又不想失去性能。而且 read 不能阻塞住系统线程。

May 26, 2013

skynet 的网关模块的一点修改

skynet 有一个叫做 gate 的模块,用来解决外部连接数据读取的问题。它最初是用我随手为 ringbuffer 示例 而写的一段代码改造成的。

最初我认为,用 epoll 去处理读事件足够了。至于写数据,完全可以用阻塞写的方式进行。因为 skynet 可以将事务分到多线程中,所以特定几个 socket 发生阻塞,也并不会把系统阻塞住。也可以简单的理解为,单线程读,多线程写。

随着我们的游戏的开发,这样做的弊端逐渐显露出来。大量玩家聚集的场景里,广播数据会突然同时塞满多条连接。skynet 的工作线程数据固定,这样就有可能因为同时写数据而阻塞住所有的工作线程。

October 12, 2012

并发问题 bug 小记

今天解决了一个遗留多时的 bug , 想明白以前出现过的许多诡异的问题的本质原因。心情非常好。

简单来说,今天的一个新需求导致了我重读过去的代码,发现了一个设计缺陷。这个设计缺陷会在特定条件下给系统的一个模块带来极大的压力。不经意间给它做了一次高强度的压力测试。通过这个压力测试,我完善了之前并发代码中写的不太合理的部分。

一两句话说不清楚,所以写篇 blog 记录一下。

最开始是在上周,阿楠同学发现:在用机器人对我们的服务器做压力测试时的一个异常状况:机器人都在线的时候,CPU 占用率不算特别高。但是一旦所以机器人都被关闭,系统空跑时,CPU 占用率反而飚升上去。但是,经过我们一整天的调试分析,却没有发现有任何线程死锁的情况。换句话说,不太像 bug 导致的。

但是,一旦出现这种情况,新的玩家反而登陆不进去。所以这个问题是不可以放任不管的。

后来,我们发现,只要把 skynet 的工作线程数降低到 CPU 的总数左右,问题就似乎被解决了。不会有 CPU 飚升的情况,后续用户也可以登陆系统。

虽然问题得到了解决,但我一直没想明白原因何在,心里一直有点不爽。

September 03, 2012

Skynet 设计综述

经过一个月, 我基本完成了 skynet 的 C 版本的编写。中间又反复重构了几个模块,精简下来的代码并不多:只有六千余行 C 代码,以及一千多 Lua 代码。虽然部分代码写的比较匆促,但我觉得还是基本符合我的质量要求的。Bug 虽不可避免,但这样小篇幅的项目,应该足够清晰方便修正了吧。

花在 Github 上的这个开源项目上的实际开发实现远小于一个月。我的大部分时间花了和过去大半年的 Erlang 框架的兼容,以及移植那些不兼容代码和重写曾经用 Erlang 写的服务模块上面了。这些和我们的实际游戏相关,所以就没有开源了。况且,把多出这个几倍的相关代码堆砌出来,未必能增加这个开源项目的正面意义。感兴趣的同学会迷失在那些并不重要,且有许多接口受限于历史的糟糕设计中。

在整合完我们自己项目的老代码后,确定移植无误,我又动手修改了 skynet 的部分底层设计。在保证安全迁移的基础上,做出了最大限度的改进,避免背上过多历史包袱。这些修改并不容易,但我觉得很有价值。是我最近一段时间仔细思考的结果。今天这一篇 blog ,我将最终定稿的版本设计思路记录下来,备日后查阅。

August 17, 2012

记录一个并发引起的 bug

今天发现 Skynet 消息处理的一个 bug ,是由多线程并发引起的。又一次觉得完全把多线程程序写对是件很不容易的事。我这方面经验还是不太够,特记录一下,备日后回顾。


Skynet 的消息分发是这样做的:

所有的服务对象叫做 ctx ,是一个 C 结构。每个 ctx 拥有一个唯一的 handle 是一个整数。

每个 ctx 有一个私有的消息队列 mq ,当一个本地消息产生时,消息内记录的是接收者的 handle ,skynet 利用 handle 查到 ctx ,并把消息压入 ctx 的 mq 。

ctx 可以被 skynet 清除。为了可以安全的清除,这里对 ctx 做了线程安全的引用计数。每次从 handle 获取对应的 ctx 时,都会对其计数加一,保证不会在操作 ctx 时,没有人释放 ctx 对象。

skynet 维护了一个全局队列,globalmq ,里面保存了若干 ctx 的 mq 。

这里为了效率起见(因为有大量的 ctx 大多数时间是没有消息要处理的),mq 为空时,尽量不放在 globalmq 里,防止 cpu 空转。

Skynet 开启了若干工作线程,不断的从 globalmq 里取出二级 mq 。我们需要保证,一个 ctx 的消息处理不被并发。所以,当一个工作线程从 globalmq 取出一个 mq ,在处理完成前,不会将它压回 globalmq 。

处理过程就是从 mq 中弹出一个消息,调用 ctx 的回调函数,然后再将 mq 压回 globalmq 。这里不把 mq 中所有消息处理完,是为了公平,不让一个 ctx 占用所有的 cpu 时间。当发现 mq 为空时,则放弃压回操作,节约 cpu 时间。

所以,产生消息的时刻,就需要执行一个逻辑:如果对应的 mq 不在 globalmq 中,把它置入 globalmq 。

需要考虑的另一个问题是 ctx 的初始化过程:

ctx 的初始化流程是可以发送消息出去的(同时也可以接收到消息),但在初始化流程完成前,接收到的消息都必须缓存在 mq 中,不能处理。我用了个小技巧解决这个问题。就是在初始化流程开始前,假装 mq 在 globalmq 中(这是由 mq 中一个标记位决定的)。这样,向它发送消息,并不会把它的 mq 压入 globalmq ,自然也不会被工作线程取到。等初始化流程结束,在强制把 mq 压入 globalmq (无论是否为空)。即使初始化失败也要进行这个操作。

Skynet 的一些改进和进展

最近我的工作都围绕 skynet 的开发展开。

因为这个项目是继承的 Erlang 老版本的设计来重新用 C 编写的。 再一些接口定义上也存在一些历史遗留问题. 我需要尽量兼容老版本, 这样才能把上层代码较容易的迁移过来。

最近的开发已经涉及具体业务流程了, 搬迁了不少老代码过来。 我不想污染放在外面的开源版本。 所以在开发机上同时维护了两个分支, 同时对应到 github 的公开仓库, 以及我们项目的开发仓库。

btw, 我想把自己的开发机上一个分支版本对应到办公室仓库的 master 分支, 遇到了许多麻烦。 应该是我对 git 的工作流不熟悉导致的。

August 06, 2012

Skynet 集群及 RPC

这几天老在开会,断断续续的拖慢了开发进度。直到今天才把 Skynet 的集群部分,以及 RPC 协议设计实现完。

先谈谈集群的设计。

最终,我们希望整个 skynet 系统可以部署到多台物理机上。这样,单进程的 skynet 节点是不够满足需求的。我希望 skynet 单节点是围绕单进程运作的,这样服务间才可以以接近零成本的交换数据。这样,进程和进程间(通常部署到不同的物理机上)通讯就做成一个比较外围的设置就好了。

为了定位方便,我希望整个系统里,所有服务节点都有唯一 id 。那么最简单的方案就是限制有限的机器数量、同时设置中心服务器来协调。我用 32bit 的 id 来标识 skynet 上的服务节点。其中高 8 位是机器标识,低 24 位是同一台机器上的服务节点 id 。我们用简单的判断算法就可以知道一个 id 是远程 id 还是本地 id (只需要比较高 8 位就可以了)。

我设计了一台 master 中心服务器用来同步机器信息。把每个 skynet 进程上用于和其他机器通讯的部件称为 Harbor 。每个 skynet 进程有一个 harbor id 为 1 到 255 (保留 0 给系统内部用)。在每个 skynet 进程启动时,向 master 机器汇报自己的 harbor id 。一旦冲突,则禁止连入。

master 服务其实就是一个简单的内存 key-value 数据库。数字 key 对应的 value 正是 harbor 的通讯地址。另外,支持了拥有全局名字的服务,也依靠 master 机器同步。比如,你可以从某台 skynet 节点注册一个叫 DATABASE 的服务节点,它只要将 DATABASE 和节点 id 的对应关系通知 master 机器,就可以依靠 master 机器同步给所有注册入网络的 skynet 节点。

master 做的事情很简单,其实就是回应名字的查询,以及在更新名字后,同步给网络中所有的机器。

skynet 节点,通过 master ,认识网络中所有其它 skynet 节点。它们相互一一建立单向通讯通道。也就是说,如果一共有 100 个 skynet 节点,在它们启动完毕后,会建立起 1 万条通讯通道。

为了缩短开发时间,我利用了 zeromq 来做 harbor 间通讯,以及 master 的开发。蜗牛同学觉得更高效的做法是自己用 C 来写,并和原有的 gate 的 epoll 循环合并起来。我觉得有一定道理,但是还是先给出一个快速的实现更好。

August 01, 2012

Skynet 开源

最近两天是我们项目第二个里程碑的第一个检查点。我们的服务器在压力测试下有一些性能问题。很多方面都有一个数量级的优化余地,我们打算先实现完功能,然后安排时间重构那些值得提升性能的独立模块。

我最近两周没有项目进度线上的开发任务。所以个人得以脱身出来看看性能问题。前几天已经重新写了许多觉得可能有问题的模块。在前几天的 blog 里都有记录。

虽然没有明显的证据,但是感觉上,我们的服务器底层框架 skynet 有比较大的开销。这个东西用 Erlang 开发的,性能剖析我自己没有什么经验。总觉得 Erlang 本身代码基有点庞大,不太能清晰的理解各个性能点。

其实底层框架需要解决的基本问题是,把消息有序的,从一个点传递到另一个点。每个点是一个概念上的服务进程。这个进程可以有名字,也可以由系统分配出唯一名字。本质上,它提供了一个消息队列,所以最早我个人是希望用 zeromq 来开发的。

现在回想起来,无论是利用 erlang 还是 zeromq ,感觉都过于重量了。作为这个核心功能的实现,其实在 2000 行 C 代码内就可以很好的实现。事实上,我最近花了两个整天还不错的重新完成了这个任务,不过千余行 C 代码。当然离现在已有的框架功能,细节上还远远不够,但能够清晰的看到性能都消耗到哪些位置了。其实以后不用这个 C 版本的底层框架,作为一个对比测试工具,这半周时间也是花得很值得的。

我将这两天的工作开源到了 github 上,希望对更多人有帮助。从私心上讲,如果有同学想利用这个做开发,也可以帮助我更快发现 bug 。有兴趣的同学可以在这里跟踪我的开发进度

关于接口,我在上面提到的 blog 中已经列过了。这次重新实现,发现一些细节上不合理的地方,但是不太好修改,姑且认为是历史造成的吧。

在目前的版本里,我还没有实现跨机器通讯,我也不打算讲跨机通讯做到核心层中。而希望用附加服务的方式在将来实现出来。

这个系统是单进程多线程模型。