前言
skynet是我们游戏服务端的底层框架,当初在技术选型的时候仔细阅读过它的源码,发现它是一个C语言的工程典范。大多数游戏服务端,要么使用C++,要么使用java,使用C是非常少见的。但是skynet通过C和Lua的结合,实现了一个高效的游戏框架,C层没有多余的一堆三方库,只有紧凑的核心结构,提供最核心的消息处理框架;Lua层用来写游戏逻辑,降低了开发门槛。
目前skynet在阿里游戏大量使用,据我所闻风之大陆,时下很火的三国志使用的都是skynet,而我们游戏当然也用这个框架,已经稳定运营了一年有余。
说起来skynet并不能算是一个游戏服务端框架,它只是提供了一些游戏服务端必须的基础设施,可以用这套设施去设计符合要求的上层逻辑。按照云风的说法,skynet实现了类似Erlang 的 Actor 模型,它本质上是一个高并发的消息处理框架,消息从底层派发给上层的“服务”去处理,这里的服务可以用C编写,当然大部分时候都是用Lua编写,每个Lua服务是一个独立的Lua虚拟机,这就保证了服务之间的环境隔离,Lua服务使用协程处理消息,当需要向其他服务通讯时,协程可以挂起等其他服务返回再继续,这让我们一方面能像写同步代码一样“顺序执行”,另一方面当协程挂起时,该服务可以处理其他消息,这就保证了消息的高并发。
由于skynet内核的精简,很多人抱着开箱即用的想法,后面发现门槛其实并不低,它仍然要求你对游戏服务器的业务很熟悉,知道自己想要实现什么,然后自己动手。但是正是由于它的精简,使得他的可定制性很高。
skynet的核心功能
如果要用一句话描述skynet核心功能是什么:它仍然是一个基于事件的高并发消息处理框架。事件主要来源于网络,定时器和信号通知等,当事件触发时,skynet将这些事件统一编码成消息结构,派发给感兴趣的服务处理;而服务在处理消息时,也可以主动向其他服务发送消息。因此他是事件来驱动的,如果没有前面说的那些事件,skynet就没法做任何事情。
skynet的核心数据结构是 skynet_context ,我对Erlang不熟悉,所以没法说出它对应于Erlang的什么结构;但它实际上也像操作系统中的进程的概念,在这里我们把它称之为服务,一个服务包含了下面几个东西:
- 服务句柄:和进程ID类似,用于唯一标识服务。
- 服务模块:模块以动态库的形式提供。在创建skynet_context的时候,必须指定模块的名字,skynet把模块加载进来,创建模块实例,实例向服务注册一个回调函数,用于处理服务的消息。
- 消息队列:每个服务都有一个消息队列,当队列中有消息时,会主动挂到全局链表。skynet启动了一定数量的工作线程,不断从全局链表取出消息队列,派发消息给服务的回调函数去处理。
下面的结构图展示了skynet最核心的结构:
服务句柄
每个服务都关联一个句柄,句柄的实现在 skynet_handle.h|c 中,句柄是一个32位无符号整型,最高8位表示集群ID(已不推荐使用),剩下的24位为服务ID。
handle_storage 用于存储ID和skynet_context的映射:
// 句柄存储结构
struct handle_storage {
struct rwlock lock; // 读写锁
uint32_t harbor; // 集群ID
uint32_t handle_index; // 当前句柄索引
int slot_size; // 槽位数组大小
struct skynet_context ** slot; // skynet_context数组
... ...
};
服务模块
先来看一下创建服务的API:
// 创建一个服务:name为服务模块的名字,parm为参数,由模块自己解释含义
struct skynet_context * skynet_context_new(const char * name, const char * parm);
这里的name参数就是模块名,skynet根据这个名字加载模块,并调用约定好的导出函数。这个过程大概是这样的:
- 得到模块后,调用skynet_module_instance_create函数创建模块实例。
- 然后调用skynet_module_instance_init初始化实例,通常实例在初始化时调用skynet_callback向skynet设置回调函数,以后消息处理由该回调函数处理。
消息队列
创建服务时也会新建一个消息队列,消息队列在 skynet_mq.c|h 中实现,消息队列用下面的结构表示:
// 消息队列
struct message_queue {
struct spinlock lock;
uint32_t handle; // 关联的服务句柄
int cap; // 队列容量
int head; // 队列头的位置
int tail; // 队列尾的位置
struct skynet_message *queue; // 消息结构数组
struct message_queue *next; // 指向下一个消息队列
... ...
};
next指向下一个消息队列,也就是说message_queue会形成一个链表,然后由global_queue持有,global_queue就这样的:
struct global_queue {
struct message_queue *head;
struct message_queue *tail;
struct spinlock lock;
};
global_queue持有的链表是需要处理消息的消息队列,这个过程是这样的:
- 调用skynet_mq_push向消息队列压入一个消息。
- 然后,调用skynet_globalmq_push把消息队列链到global_queue尾部。
- 从全局链表弹出一个消息队列,处理队列中的消息,如果队列的消息处理完则不压回全局链表,如果未处理完则重新压入全局链表,等待下一次处理。
描述得比较简单,具体的细节还是要查看skynet_context_message_dispatch这个函数。
skynet启动及消息处理
上面把服务的三个重要组成部分介绍完,现在可以来看看skynet_context的内容了:
struct skynet_context {
void * instance; // 服务模块的实例指针
struct skynet_module * mod; // 服务模块指针
void * cb_ud; // 回调函数的用户数据
skynet_cb cb; // 服务处理消息的回调函数
struct message_queue *queue; // 消息队列
uint32_t handle; // 服务句柄
... ...
};
其实包含的最核心的部分就是上面介绍的三个,那么skynet是怎么样启动起来,并不断地处理消息呢?答案就是skynet_start这个函数:
- 第一步初始化各个功能模块,比如句柄,消息队列,模块,定时器,socket等等。
- 然后创建一个logger服务。创建一个bootstrap服务。
- 接着创建一定数量的工作线程,这个数量可由配置指定,工作线程的责任就是派发消息。
- 创建定时器线程,用于记录时间以及实现timeout事件;
- 创建sokcet线程,用于处理sokcet消息,socket和timeout事件最终都会转化成消息,交给工作线程派发给服务处理。
- 创建monitor线程,这个线程的作用是监控服务有没有出现死循环。
前面说过,skynet是由事件驱动运行的,这里的事件主要就是两个,一个是socket,另一个是timeout。分别由两个线程驱动运行。
工作线程的核心逻辑就是调用skynet_context_message_dispatch去派发消息,派发完成后,它会进入睡眠状态,等待另外两个线程来唤醒。这就是非常典型的生产消费者模型,绝大多数服务器程序的核心功能就是这个,skynet也不例外:
from:https://zhuanlan.zhihu.com/p/84634254