Skynet起源于云风在简悦研发的第一个项目,研发的过程记录在博客里面,这个研发笔记第一次提到skynet 是框架的开发代号。
Skynet 设计综述提到Skynet核心解决什么问题,不解决什么问题以及核心功能设计细节
需求:希望游戏服务器(但 skynet 不仅限于用于游戏服务器)能够充分利用多核优势,将不同的业务放在独立的执行环境中处理,协同工作。
做为核心功能,Skynet 仅解决一个问题:
把一个符合规范的 C 模块,从动态库(so 文件)中启动起来,绑定一个永不重复(即使模块退出)的数字 id 做为其 handle 。模块被称为服务(Service),服务间可以自由发送消息。每个模块可以向 Skynet 框架注册一个 callback 函数,用来接收发给它的消息。每个服务都是被一个个消息包驱动,当没有包到来的时候,它们就会处于挂起状态,对 CPU 资源零消耗。如果需要自主逻辑,则可以利用 Skynet 系统提供的 timeout 消息,定期触发。
Skynet 的消息传递都是单向的,以数据包为单位传递的。并没有定义出类似 TCP 连接的概念。也没有约定 RPC 调用的协议。不规定数据包的编码方式,没有提供一致的复杂数据结构的列集 API 。
Skynet 原则上主张所有的服务都在同一个 OS 进程中协作完成。所以在核心层内,不考虑跨机通讯的机制,也不为单独一个服务的崩溃,重启等提供相应的支持。和普通的单线程程序一样,你要为你代码中的 bug 和意外负责。如果你的程序出了问题而崩溃,你不应该把错误藏起来,假装它们没有发生。至少,这些不是核心层做的事情。
当然,这并不是说,最终用 Skynet 搭建的系统不具有健壮性,只是这些是在更高层去解决。比如,使用 Lua 的沙盒就可以隔绝大多数上层逻辑中的 bug 了。
为了提供高效的服务间通讯,Skynet 采用了几点设计,获得了比多进程方案更高的性能。
数据包通常是在一个服务内打包生成的,Skynet 并不关心数据包是怎样被打包的。它仅仅是把数据包的指针,以及你声称的数据包长度(并不一定是真实长度)传递出去。由于服务都是在同一个进程内,接收方取得这个指针后,就可以直接处理其引用的数据了。
Skynet 推荐的一种更可靠,性能略低的方案:它约定,每个服务发送出去的包都是复制到用 malloc 分配出来的连续内存。接收方在处理完这个数据块(在处理的 callback 函数调用完毕)后,会默认调用 free 函数释放掉所占的内存。即,发送方申请内存,接收方释放。
虽然 skynet 核心只解决单向的消息包发送问题,但是,我们的应用很大部分都需要使用请求回应的模式。即,一个服务向另一个服务提出一个请求包,对方处理完这个请求后,把结果返回。由于每个服务仅有一个 callback 函数,这时,我们就需要有另一个东西来区分这个包。这就是 session 的作用。
服务间的交互,只有很少的服务只需要处理别人发送过来的请求,而不需要向外提出请求。所以我们至少需要区分请求包和回应包。这两种包显然是有不同的处理方式,但它们需要从同一个 callback 函数入口进入。这就需要用一个额外的参数区分。
一个服务只使用一种消息编码协议,但不强求每个服务使用相同的编码协议。那么你去调用一个服务时,去适应对方的通讯协议即可。有了独立的 session 在消息包之外,你在处理不同协议的对外请求的回应包时,就可以用不同的编码方式来解码回应包了。
type就是用来区分不同的消息编码协议,但type表示的是当前消息包的协议组别,而不是传统意义上的消息类别编号。
#define PTYPE_TEXT 0
#define PTYPE_RESPONSE 1
#define PTYPE_MULTICAST 2
#define PTYPE_CLIENT 3
#define PTYPE_SYSTEM 4
#define PTYPE_HARBOR 5
#define PTYPE_TAG_DONTCOPY 0x10000
#define PTYPE_TAG_ALLOCSESSION 0x20000
虽然设计上围绕单进程多线程模块进行的,但 skynet 其实并不仅限于单进程。它实际是可以部署到不同机器上联合工作的。
单个 skynet 进程内的服务数量被 handle 数量限制。handle 也就是每个服务的地址,在接口上看用的是一个 32 位整数。但实际上单个服务中 handle 的最终限制在 24bit 内,也就是 16M 个。高 8 位是保留给集群间通讯用的。(地址32位,高8位用于进程外通讯,低24位用于进程内通讯)
最终允许 255 个 skynet 节点部署在不同的机器上协作。每个 skynet 节点有不同的 id 。这里被称为 harbor id 。每个消息包产生的时候,skynet 框架会把自己的 harbor id 编码到源地址的高 8 位。这样,系统内所有的服务模块,都有不同的地址了。从数字地址,可以轻易识别出,这个消息是远程消息,还是本地消息。
集群间的通讯,是由一个独立的 harbor 服务来完成的。所有的消息包在发送时,skynet 识别出这是一个远程消息包时,都会把它转发到 harbor 服务内。harbor 服务会建立 tcp 连接到所有它认识的其它 skynet 节点内的 harbor 服务上。
skynet 目前支持一个全局名字服务,可以把一个消息包发送到特定名字的服务上。这个服务不必存在于当前 skynet 节点中。这样,我们就需要一个机构能够同步这些全局名字。
为此实现了一个叫做 master 的服务。它的作用就是广播同步所有的全局名字,以及加入进来的 skynet 节点的地址。本质上,这些地址也是一种名字。同样可以用 key-value 的形式储存。即,每个 skynet 节点号对应一个字符串的地址。
组播包的分配和释放策略和其它包不同。它需要有引用计数。和别的消息包,发送方分配,接收方释放是不同的。skynet 会识别消息的 type 是否为PTYPE_MULTICAST
,然后有不同的生命期管理策略,并把组播包交给组播服务处理。这一点,和集群间通讯的做法非常类似。
组播服务并不解决分熟在不同集群节点上的服务归组的问题。即,每个分组内的成员都必须在同一系统进程内。用户可以让不同的服务 handle 归属一个组号。向 skynet 索取这个组号对应的 handle 。向这个组的 handle 发送消息,就等同于向组内所有 handle 发送消息。
而跨集群分组又如何做到呢?这里是在上层用 lua 来做了进一步的封装。首先,提供了一个简单的,用 C 编写的服务,叫做 tunnel 。它可以把发送给它的消息,无条件的转发到另一个 handle 上。这个转发 handle 可以是在不同 skynet 节点上的。
Skynet 的核心功能就是发送消息和处理消息。它体现在 skynet_send 和 skynet_callback 两个 api 上。但是,skynet 本身还需要另一些 api 才能工作。比如启动一个新服务、退出一个服务、组管理、timer 管理、等等。
skynet 提供了一个叫做 skynet_command 的 C API ,作为基础服务的统一入口。它接收一个字符串参数,返回一个字符串结果。你可以看成是一种文本协议。但 skynet_command 保证在调用过程中,不会切出当前的服务线程,导致状态改变的不可预知性。其每个功能的实现,其实也是内嵌在 skynet 的源代码中,相同上层服务,还是比较高效的。
Skynet 维护了两级消息队列。
每个服务实体有一个私有的消息队列,队列中是一个个发送给它的消息。
并且维护了一个全局消息队列,里面放的是诸个不为空的次级消息队列。
在 Skynet 启动时,建立了若干工作线程(数量可配置),它们不断的从主消息列队中取出一个次级消息队列来,再从次级队列中取去一条消息,调用对应的服务的 callback 函数进行出来。为了调用公平,一次仅处理一条消息,而不是耗净所有消息,这样可以保证没有服务会被饿死。用户定义的 callback 函数不必保证线程安全,因为在 callback 函数被调用的过程中,其它工作线程没有可能获得这个 callback 函数所熟服务的次级消息队列,也就不可能被并发了。一旦一个服务的消息队列暂时为空,它的消息队列就不再被放回全局消息队列了。这样使大部分不工作的服务不会空转 CPU 。
用户定义的 callback 函数不必保证线程安全,因为在 callback 函数被调用的过程中,其它工作线程没有可能获得这个 callback 函数所熟服务的次级消息队列,也就不可能被并发了。一旦一个服务的消息队列暂时为空,它的消息队列就不再被放回全局消息队列了。这样使大部分不工作的服务不会空转 CPU 。
一个完整的游戏服务器避免不必和外界通讯。
外界通讯有两种:
一是游戏客互端使用 TCP 连接接入 skynet 节点。换个角度看,如果你用 skynet 实现一个 web 服务器的话,游戏客户端就可以等价于一个浏览器请求。
另一个是第三方的服务,比如数据库服务,它接受一个或多个 TCP 连接。你需要从 skynet 内部建立一个 TCP 连接出去使用。
gate 服务的特征是监听一个 TCP 端口,接受连入的 TCP 连接,并把连接上获得的数据转发到 skynet 内部。Gate 可以用来消除外部数据包和 skynet 内部消息包的不一致性。外部 TCP 流的分包问题,是 Gate 实现上的约定。
Gate 会接受外部连接,并把连接相关信息转发给另一个服务去处理。它自己不做数据处理是因为我们需要保持 gate 实现的简洁高效。C 语言足以胜任这项工作。而包处理工作则和业务逻辑精密相关,我们可以用 Lua 完成。
外部信息分两类,一类是连接本身的接入和断开消息,另一类是连接上的数据包。对于连接数据包,添加一个包头无疑有性能上的开销。于是Gate 支持了三种接收模式:
控制信息都是交给 watchdog 去处理的,数据信息发送给 agent 或 broker 。识别这些包是从外部发送进来的方法是检查消息包的类型是否为PTYPE_CLIENT
。
Gate 支持的三种接收模式
watchdog 模式:转发控制信息和数据信息的所有数据到同一个处理服务
agent 模式:把每个不同连接上的数据信息转发给不同的独立服务上
broker 模式:不区分不同连接而转发数据信息给同一数据处理服务
注意,Gate 只负责读取外部数据,但不负责回写。也就是说,向这些连接发送数据不是它的职责范畴。作为示范,skynet 开源项目实现了一个简单的回写代理服务,叫做service_client
。
Connection 服务负责的是从 skynet 内部建立 socket 到外部服务。
Connection 分两个部分,一部分用于监听不同的系统 fd 的可读状态,这是用 epoll 实现的。它收到这个连接上的数据后,会把所有数据不做任何分包,转发到另一个服务里去处理。这是因为 connection 多用于使用外部第三方数据库,我们很难统一其分包的格式。
通过消息包的 type ,我们可以轻易识别出那些包是外部 tcp 连接上的数据块。使用提供好的 lua 模块,可以轻松的对这些数据分包(读一个指定字节数的数据块,或是读一个以回车结束的文本行)。Lua 的 coroutine 支持,可以轻松的在数据包并不完整时挂起,却不打断执行流程。
另一个使用 Connection 模块的例子是 console 服务。skynet 的开源部分实现了一个简单的 Console 模块,可以读取进程的标准输入,按回车分割,并以用户输入的文本行去启动一个 lua 编写的服务。代码很短,很容易理解其工作方式。
Lua 是 skynet 的标准配置,它不是必须的,但实际上被用在很多部分了。
在 Lua 的底层,skynet 封装了 skynet 最基本的 C API 。但是开发人员不必工作在这些底层 API 上,以 C 语言的思维来编写服务。
当 Lua 编写的服务接收到一个外部请求时,对应的底层 callback 函数被调用,既而转发到 Lua 虚拟机中。skynet 的 lua 层会为每个请求创建一个独立的 coroutine 。Lua 的 coroutine 可以帮助我们把一个个在 C 层面分离的 callback 调用串成逻辑上连续的线索。
一旦在处理这个请求的 coroutine 中发生远程调用,即发出一个消息包,coroutine 会挂起。在 C 层面,这次 callback 函数正常返回了。但在 Lua 中,则是记录下这个发出的消息包的 session ,记录 session 和挂起的 corutine 在一张对应表中。之后,一但收到回应包里有相同的 session ,对应的 coroutine 则被唤醒 resume 。
每个服务可以使用不同的协议组,则是在底层由 type 参数区分的。在 lua 层,可以为每个不同的 type 编写不同的 dispatch 函数。默认仅提供了 RESPONSE 消息的处理方法,每个独立的 lua 服务,都需要去实现自己可以支持的协议类型的处理函数。
大多数 lua 服务也可以使用一种简易的消息编码协议,我称为 lua 协议。因为它仅仅是简单的把 Lua 支持的类型系列化起来,另一个 Lua 服务可以顺利的解开它们。这样,看起来请求(用 Lua 编写的)远程服务和调用本地函数一样简单。
希望游戏服务器能够充分利用多核优势,将不同的业务放在独立的执行环境中处理,协同工作。
Skynet 是一个基于C跟lua的开源服务端并发框架,这个框架是单进程多线程Actor模型。
Actor的基础就是消息传递,skynet中每个服务就是一个LUA虚拟机,就是一个Actor。
Actor由状态(State)、行为(Behavior)和邮箱(MailBox)三部分组成:
1.状态(State)
Actor中的状态指的是Actor对象的变量信息,状态由Actor自己管理,避免了并发环境下的锁和内存原子性等问题
2.行为(Behavior)
行为指定的是Actor中计算逻辑,通过Actor接收到消息来改变Actor的状态
3.邮箱(MailBox)
邮箱是Actor和Actor之间的通信桥梁,邮箱内部通过FIFO消息队列来存储发送方Actor消息,接受方Actor从邮箱队列中获取消息
参考资料
https://blog.codingnow.com/2012/09/the_design_of_skynet.html
https://blog.csdn.net/qq769651718/article/details/79432793