本人在Skynet开源初期就持续关注,阅读了Skynet的大部份代码,受益匪浅。当时因工作需要,要为一个项目选型 一套服务器的架构。项目并不是游戏行业,而是联网报警、视频管理、定位追踪相关。查阅了很多资料,对比了很多开源的框架,比如:golang、nodejs、java的一些项目,最后锁定为golang或Skynet。后来因为lua更灵活,更方便的调用现有的c库,同时跨平台性强( 公模块需要同时用在android、ios、windows),还考虑到pcall的出错不崩并报告原因等优势,最终选择了skynet框架。由于服务器需要运行于windows下,skynet是不能直接运行于windows下的,dpull的skyent windows版网络通信部分用使用select模拟epoll然后用mingw编译。网络性能受很大的影响,作者dpull也不建议用于生产环境。为了解决这个问题,本人也想过很多办法,最终采用的是在gate服务里用libuv。为了解决运行libuv事件循环相对于skynet是阻塞的这个问题,想了这个办法:
local _NeedRunUvLoop=true
function _M.RunUvLoop()
local uv=require("luv")
if _NeedRunUvLoop then
_NeedRunUvLoop=false
skynet.fork(function()--luv event loop
skynet.error(skynet.pcall(function()
local timer=uv.new_timer()
uv.timer_start(timer,1,1,function()end)
local uv_run=uv.run
local skynet_sleep=skynet.sleep
while true do
uv_run('once')
skynet_sleep(0)
end
end))
end)
end
end
uv_run('once')
并不是立即反回,而是至少执行一次事件,为了很让事件尽快到来,用uv.timer_start
开启了一个周期性的定时器。这样uv_run('once')
被阻塞的时间最长是定时器的间隔。
本文后面大部分描述引用Skynet作者云风的《Skynet 设计综述》,因为原作者本人是对skynet最为了解的,讲的也是最权威的。原文讲写得比长,下文仅截取了我认为比较重要的部分。
我们希望服务器能够充分利用多核优势,将不同的业务放在独立的执行环境中处理,协同工作。这个执行环境,最早的时候,期望是利用 OS 的进程。后来发现,如果我们必定采用嵌入式语言,比如 Lua 的话,独立 OS 进程的意义不太大。Lua State 已经提供了良好的沙盒,隔离不同执行环境。而多线程模式,可以使得状态共享、数据交换更加高效。而多线程模型的诸多弊端,比如复杂的线程锁、线程调度问题等,都可以通过减小底层的规模,精简设计,最终把危害限制在很小的范围内。
做为核心功能Skynet 仅解决一个问题:
把一个符合规范的 C 模块,从动态库(so 文件)中启动起来,绑定一个永不重复(即使模块退出)的数字 id 做为其 handle 。模块被称为服务(Service),服务间可以自由发送消息。每个模块可以向 Skynet 框架注册一个 callback 函数,用来接收发给它的消息。每个服务都是被一个个消息包驱动,当没有包到来的时候,它们就会处于挂起状态,对 CPU 资源零消耗。如果需要自主逻辑,则可以利用 Skynet 系统提供的 timeout 消息,定期触发。
Skynet 提供了名字服务,还可以给特定的服务起一个易读的名字,而不是用 id 来指代它。id 和运行时态相关,无法保证每次启动服务,都有一致的 id ,但名字可以。
Skynet 的消息传递都是单向的,以数据包为单位传递的。并没有定义出类似 TCP 连接的概念。也没有约定 RPC 调用的协议。不规定数据包的编码方式,没有提供一致的复杂数据结构的列集 API 。
Skynet 原则上主张所有的服务都在同一个 OS 进程中协作完成。所以在核心层内,不考虑跨机通讯的机制,也不为单独一个服务的崩溃,重启等提供相应的支持。和普通的单线程程序一样,你要为你代码中的 bug 和意外负责。如果你的程序出了问题而崩溃,你不应该把错误藏起来,假装它们没有发生。至少,这些不是核心层做的事情。和操作系统不一样,操作系统会认为用户进程都是不可靠的,不会让一个用户进程的错误影响到另一个进程。但在 Skynet 提供的框架内,所有的服务都有统一的目的,为服务器的最终客户服务,如果有某个环节出了错都可能是致命的,没有必要被问题隔离开。
这并不是说,最终用 skynet 搭建的系统不具有健壮性,只是这些是在更高层去解决。比如,使用 Lua 的沙盒就可以隔绝大多数上层逻辑中的 bug 了。
为了提供高效的服务间通讯,Skynet 采用了几点设计,获得了比多进程方案更高的性能。
数据包通常是在一个服务内打包生成的,Skynet 并不关心数据包是怎样被打包的,它甚至不要求这个数据包内的数据是连续的(虽然这样很危险,在后面会谈及的跨机通讯中会出错,除非你保证你的数据包绝对不被传递出当前所在的进程)。它仅仅是把数据包的指针,以及你声称的数据包长度(并不一定是真实长度)传递出去。由于服务都是在同一个进程内,接收方取得这个指针后,就可以直接处理其引用的数据了。
这个机制可以在必要时,保证绝对的零拷贝,几乎等价于在同一线程内做一次函数调用的开销。
但,这只是 Skynet 提供的性能上的可能性。它推荐的是一种更可靠,性能略低的方案:它约定,每个服务发送出去的包都是复制到用 malloc 分配出来的连续内存。接收方在处理完这个数据块(在处理的 callback 函数调用完毕)后,会默认调用 free 函数释放掉所占的内存。即,发送方申请内存,接收方释放。
我们来看看 skynet_send
和 callback
函数的定义:
int skynet_send(
struct skynet_context * context,
uint32_t source,
uint32_t destination,
int type,
int session,
void * msg,
size_t sz
);
typedef int (*skynet_cb)(
struct skynet_context * context,
void *ud,
int type,
int session,
uint32_t source ,
const void * msg,
size_t sz
);
暂且不去关注 type 和 session 两个参数。这里,source 和 destination 都是 32 位整数,表示地址。原则上不需要填写 source 地址,因为默认就是它自己。0 是系统保留的 handle ,可以指代自己。这里允许填写 source 值,是因为在某些特殊场合,需要伪造一个由别人发出的包。姑且可以理解 source 为 reply address 。
发送一个数据包,就是发送 msg/sz 对。我们可以在 type 里打上 dontcopy 的 tag (PTYPE_TAG_DONTCOPY
) ,让框架不要复制 msg/sz 指代的数据包。否则 skynet 会用 malloc 分配一块内存,把数据复制进去。callback 函数在处理完这块数据后,会调用 free 释放内存。你可以通过让 callback 返回 1 ,阻止框架释放内存。这通常和在 send 时标记 dontcopy 标记配对使用。
接下来,谈谈 session 和 type 两个参数。
session 是什么?
虽然 skynet 核心只解决单向的消息包发送问题,正如 ip 协议只解决 ip 包在互连网中从一个 ip 地址传输到另一个 ip 地址的问题。但是,我们的应用很大部分都需要使用请求回应的模式。即,一个服务向另一个服务提出一个请求包,对方处理完这个请求后,把结果返回。由于每个服务仅有一个 callback 函数,好比在 ip 协议中去掉了端口的设定,所有发送到一个 ip 地址上的 ip 包就无法被分发到不同的进程了。这时,我们就需要有另一个东西来区分这个包。这就是 session 的作用。
使用 skynet_send
发送一个包的时候,你可以在 type 里设上 alloc session 的 tag (PTYPE_TAG_ALLOCSESSION
)。send api 就会忽略掉传入的 session 参数,而会分配出一个当前服务从来没有使用过的 session 号,发送出去。同时约定,接收方在处理完这个消息后,把这个 session 原样发送回来。这样,编写服务的人只需要在 callback 函数里记录下所有待返回的 session 表,就可以在收到每个消息后,正确的调用对应的处理函数。
type 的作用
其实,type 表示的是当前消息包的协议组别,而不是传统意义上的消息类别编号。协议组别类型并不会很多,所以,限制了 type 的范围是 0 到 255 ,由一个字节标识。在实现时,把 type 编码到了 size 参数的高 8 位。因为单个消息包限制长度在 16 M (24 bit)内,是个合理的限制。这样,为每个消息增加了 type 字段,并没有额外增加内存上的开销。
查看 skynet.h 我们可以看到已经定义出来的消息类型:
#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
0 是内部服务最为常用的文本消息类型。 1 表示这是一个回应包,应该依据对方的规范来解码。后面定义出来的几种类型暂时不解释,我们也可以自定义更多的类型。
Skynet 维护了两级消息队列。每个服务实体有一个私有的消息队列,队列中是一个个发送给它的消息。消息由四部分构成:
struct skynet_message {
uint32_t source;
int session;
void * data;
size_t sz;
};
向一个服务发送一个消息,就是把这样一个消息体压入这个服务的私有消息队列中。这个结构的值复制进消息队列的,但消息内容本身不做复制。
Skynet 维护了一个全局消息队列,里面放的是诸个不为空的次级消息队列。
在 Skynet 启动时,建立了若干工作线程(数量可配置),它们不断的从主消息列队中取出一个次级消息队列来,再从次级队列中取去一条消息,调用对应的服务的 callback 函数进行出来。为了调用公平,一次仅处理一条消息,而不是耗尽所有消息(虽然那样的局部效率更高,因为减少了查询服务实体的次数,以及主消息队列进出的次数),这样可以保证没有服务会被饿死。
用户定义的 callback 函数不必保证线程安全,因为在 callback 函数被调用的过程中,其它工作线程没有可能获得这个 callback 函数所属服务的次级消息队列,也就不可能被并发了。一旦一个服务的消息队列暂时为空,它的消息队列就不再被放回全局消息队列了。这样使大部分不工作的服务不会空转 CPU 。