DoubleLi

qq: 517712484 wx: ldbgliet

  博客园 :: 首页 :: 博问 :: 闪存 :: 新随笔 :: 联系 :: 订阅 订阅 :: 管理 ::

前言

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

posted on 2021-01-12 11:50  DoubleLi  阅读(1841)  评论(0编辑  收藏  举报