Skynet入门

前导

本人在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 核心解决什么问题

做为核心功能Skynet 仅解决一个问题:

  • 把一个符合规范的 C 模块,从动态库(so 文件)中启动起来,绑定一个永不重复(即使模块退出)的数字 id 做为其 handle 。模块被称为服务(Service),服务间可以自由发送消息。每个模块可以向 Skynet 框架注册一个 callback 函数,用来接收发给它的消息。每个服务都是被一个个消息包驱动,当没有包到来的时候,它们就会处于挂起状态,对 CPU 资源零消耗。如果需要自主逻辑,则可以利用 Skynet 系统提供的 timeout 消息,定期触发。

  • Skynet 提供了名字服务,还可以给特定的服务起一个易读的名字,而不是用 id 来指代它。id 和运行时态相关,无法保证每次启动服务,都有一致的 id ,但名字可以。

Skynet 核心不解决什么问题

  • Skynet 的消息传递都是单向的,以数据包为单位传递的。并没有定义出类似 TCP 连接的概念。也没有约定 RPC 调用的协议。不规定数据包的编码方式,没有提供一致的复杂数据结构的列集 API 。

  • Skynet 原则上主张所有的服务都在同一个 OS 进程中协作完成。所以在核心层内,不考虑跨机通讯的机制,也不为单独一个服务的崩溃,重启等提供相应的支持。和普通的单线程程序一样,你要为你代码中的 bug 和意外负责。如果你的程序出了问题而崩溃,你不应该把错误藏起来,假装它们没有发生。至少,这些不是核心层做的事情。和操作系统不一样,操作系统会认为用户进程都是不可靠的,不会让一个用户进程的错误影响到另一个进程。但在 Skynet 提供的框架内,所有的服务都有统一的目的,为服务器的最终客户服务,如果有某个环节出了错都可能是致命的,没有必要被问题隔离开。

  • 这并不是说,最终用 skynet 搭建的系统不具有健壮性,只是这些是在更高层去解决。比如,使用 Lua 的沙盒就可以隔绝大多数上层逻辑中的 bug 了。

Skynet 做什么

  • 简单说,Skynet 只负责把一个数据包从一个服务内发送出去,让同一进程内的另一个服务收到,调用对应的 callback 函数处理。它保证,模块的初始化过程,每个独立的 callback 调用,都是相互线程安全的。编写服务的人不需要特别的为多线程环境考虑任何问题。专心处理发送给它的一个个数据包。

服务间高效的通讯

为了提供高效的服务间通讯,Skynet 采用了几点设计,获得了比多进程方案更高的性能。

  • 数据包通常是在一个服务内打包生成的,Skynet 并不关心数据包是怎样被打包的,它甚至不要求这个数据包内的数据是连续的(虽然这样很危险,在后面会谈及的跨机通讯中会出错,除非你保证你的数据包绝对不被传递出当前所在的进程)。它仅仅是把数据包的指针,以及你声称的数据包长度(并不一定是真实长度)传递出去。由于服务都是在同一个进程内,接收方取得这个指针后,就可以直接处理其引用的数据了。

  • 这个机制可以在必要时,保证绝对的零拷贝,几乎等价于在同一线程内做一次函数调用的开销。

  • 但,这只是 Skynet 提供的性能上的可能性。它推荐的是一种更可靠,性能略低的方案:它约定,每个服务发送出去的包都是复制到用 malloc 分配出来的连续内存。接收方在处理完这个数据块(在处理的 callback 函数调用完毕)后,会默认调用 free 函数释放掉所占的内存。即,发送方申请内存,接收方释放。

通信消息格式

我们来看看 skynet_sendcallback 函数的定义:

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 和 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 的消息调度

Skynet 维护了两级消息队列。每个服务实体有一个私有的消息队列,队列中是一个个发送给它的消息。消息由四部分构成:

struct skynet_message {
    uint32_t source;
    int session;
    void * data;
    size_t sz;
};

向一个服务发送一个消息,就是把这样一个消息体压入这个服务的私有消息队列中。这个结构的值复制进消息队列的,但消息内容本身不做复制。

Skynet 维护了一个全局消息队列,里面放的是诸个不为空的次级消息队列

在 Skynet 启动时,建立了若干工作线程(数量可配置),它们不断的从主消息列队中取出一个次级消息队列来,再从次级队列中取去一条消息,调用对应的服务的 callback 函数进行出来。为了调用公平,一次仅处理一条消息,而不是耗尽所有消息(虽然那样的局部效率更高,因为减少了查询服务实体的次数,以及主消息队列进出的次数),这样可以保证没有服务会被饿死。

用户定义的 callback 函数不必保证线程安全,因为在 callback 函数被调用的过程中,其它工作线程没有可能获得这个 callback 函数所属服务的次级消息队列,也就不可能被并发了。一旦一个服务的消息队列暂时为空,它的消息队列就不再被放回全局消息队列了。这样使大部分不工作的服务不会空转 CPU 。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值