skynet-设计综述

本文主要参考&转载:skynet源码赏析

云风的 BLOG: skynet Archives

云风的 BLOG: Skynet 设计综述

本文旨在记录我对skynet重新学习和理解的过程,也便于以后回顾(本文纯手打,输出的过程也是记忆的过程)。

1. skynet初衷

        希望游戏服务器能充分利用多核优势,将不同的业务放在独立的环境中执行处理,协同工作。这个执行环境通过lua的虚拟机实现,能有效隔离不同的执行环境。而多线程模式使得状态共享,数据交换更加高效。

2. 概述

作为核心功能,skynet仅解决一个问题:

        把一个符合规范的c模块,从动态库(so文件)中启动起来,绑定一个永不重复的数字id作为其handle。模块被称为服务,服务间可以自由的发送消息,每个模块可以向skynet框架注册一个callback函数,用来接收发给它的消息。

        每个服务都是被一个个消息包驱动的,当没有包到来的时候,它们就会处于挂起状态。对CPU的资源零消耗。如果需要自主逻辑,则利用skynet系统提供的timeout消息,定期触发。

具体地,skynet为我们提供了这样的环境:

  • 将符合规范的c文件模块编译成so库后,在某个时机调用so库的api的句柄,将其加载到modules列表中,一般这样的模块被要求定义4种接口:create、init、signal、release。
  • 当我们要创建一个新的、运行某个业务逻辑的上下文时,从modules列表中找到对应so库的句柄,并且调用create接口,创建一个该业务模块的数据实例,并且创建一个上下文环境(context),引用该类业务的接口和数据实例,该context会被放在一个统一存放context的列表中,这种context被称为服务。
  • 一个服务,默认不会执行任何逻辑,只有别人向它发出请求时,才会执行对应的逻辑,并在需要时返回结果给请求者。服务间的请求、响应和推送通过消息队列转发到另一个服务中。skynet的消息队列,分为两级,一个全局消息队列,他包含一个头尾指针,分别指向两个隶属于指定服务的次级消息队列。skynet中的每一个服务,都有一个唯一的、专属的次级消息队列。
  • skynet一共有4种线程,monitor线程用于检测节点内的消息是否堵住,timer线程运行定时器,socket线程用于进行网络数据的收发,worker线程负责对消息队列进行调度(worker线程的数量可以通过配置表进行指定)。
  • 消息调度的规则是:每条worker线程,每次从全局消息队列中pop出一个次级消息队列,再从次级消息队列中pop出一条消息,并找到该次级消息队列的所属服务,将消息传递给该服务的callback函数,执行指定业务,当逻辑执行完毕时,再将次级消息队列push回全局消息队列中。因为每个服务只有一个次级消息队列,每当一条worker线程,从全局消息队列中pop出一个次级消息队列时,其他线程是拿不到同一个服务,并调用callback函数的,因此不用担心一个服务同时在多条线程内消费不同的消息,一个服务执行,不存在并发,线程是安全的。
  • socket线程、time线程以及worker线程都可能往指定服务的次级消息队列push消息,push函数有一个自旋锁,避免同时多条线程同时向一个次级消息队列push消息的情况出现。

skynet的机制可以用一张表来概括:

skynet-设计综述_第1张图片

注意:服务模块要将数据,通过socket发送给客户端时,并不是将数据写入消息队列,而是通过管道从work线程发送给socket线程,交由socket转发。此外,设置定时器也不走消息队列,而是直接在定时器模块,加入一个time_node。因为time和socket线程内运行的模块并不是这里的context,因此消息队列它们无法消费。

上面的论述只涉及到c服务模块,并未讨论lua服务的内容,我们所有的 lua服务均是依附于一个叫snlua的c模块来运行的,lua服务每次收到一个消息就会产生一个协程(事实上,skynet每个服务均有一个协程池,lua服务收到消息时,会优先去池子里取出一个协程,为了理解方便,就视为收到一个消息就创建一个协程)

3. 目录结构

  • 3rd:存放第三方代码,提供lua语言支持、jemalloc内存管理、md5加密等
  • skynet-src:包括skynet最核心机制的模块,包括逻辑入口、加载c服务代码的skynet_module模块、运行和管理服务实例的skynet_context模块、skynet消息队列、定时器和socket模块等。
  • service-src:依附于skynet核心模块的c服务,如用于日志输出的logger服务,用于运行lua脚本的snlua服务。
  • lualib-src:提供c层级的api调用,如调用socket模块的api,调用skynet消息发送,注册回调函数的api,甚至是对c服务的调用等,并导出lua接口,供lua层使用。可以视为lua调c的媒介。
  • service:lua服务,依附于snlua这个c服务,这个目录包含skynet lua层级的一些基本服务,比如启动lua层级服务的bootstrap服务,gate服务,供lua层创建新服务的launcher服务deng。
  • lualib:包含对一些c模块或lua模块调用的辅助函数,总之,这些lualib方便应用层调用skynet提供的基本服务和其他库。

skynet-设计综述_第2张图片

        上面的服务基本遵循一个原则,就是上层允许调用下层,而下层不能直接调用上层的api,这样做层次清晰,不会出现你中有我,我中有你的高度耦合的情况存在。c层和lua层的耦合模块则是包含在lualib-src中,这种模块划分更利于我们快速寻找对应模块。

4. 基本数据结构

  • modules管理模块

c服务在编译成so库以后,会在某个时机被加载到modules列表中,当要创建该服务的实例时,从modules列表中取出该服务的函数句柄,调用create函数创建服务实例,并且init之后,将实例值赋给一个新的context对象,注册到skynet_context

///skynet-src/skynet_module.h
typedef void * (*skynet_dl_create)(void);
typedef int (*skynet_dl_init)(void * inst, struct skynet_context *, const char * parm);
typedef void (*skynet_dl_release)(void * inst);
typedef void (*skynet_dl_signal)(void * inst, int signal);

struct skynet_module {
	const char * name;            //c服务名称,一般是c服务的文件名
	void * module;                //访问该so库的dl(Dynamic Linking)句柄,该句柄通过dlopen函数获得
	skynet_dl_create create;      //绑定so库中的xxx_create函数,通过dlsy函数实现绑定,调用该create即是调用xxx_create	
    skynet_dl_init init;          //绑定so库中的xxx_init函数,调用该init即是调用xxx_init
	skynet_dl_release release;    //绑定so库中的xxx_release函数,调用该release即是调用xxx_release
	skynet_dl_signal signal;      //绑定so库中的xxx_signal函数,调用该signal即是调用xxx_signal
};

//skynet-src/skynet_module.c
#define MAX_MODULE_TYPE 32

struct modules {
	int count;                //modules的数量                         
	struct spinlock lock;     //自旋锁,避免多个线程同时向skynet_module写入数据,保证线程安全
	const char * path;        //由skynet配置表中的cpath指定,一般包含./cservice/?.so
	struct skynet_module m[MAX_MODULE_TYPE];
};

static struct modules * M = NULL; //存放服务模块的数组,最多32类

 从上面的数据可以看出,一个符合规范的c服务,应当包括create、init、release、signal四个接口,在该c服务被编译成so库以后,在程序中动态加载到skynet_module列表,这里通过dlopen函数来获取so库的访问句柄,并通过dlsym将so库中对应的函数绑定到函数指针中。

这里举一个例子方便理解dlopen函数和dlsym函数:

#include 
#include 

int main() {
    void* handle;
    handle = dlopen("./libexample.so", RTLD_LAZY); // 将so文件加载到内存,返回dl句柄

    if (handle == NULL) {
        fprintf(stderr, "Unable to open library: %s\n", dlerror());
        return 1;
    }

    // 使用dlsym函数。指定句柄和函数名,获取so库中的函数指针
    void (*hello)() = dlsym(handle, "hello");
    if (hello == NULL) {
        fprintf(stderr, "Unable to load symbol: %s\n", dlerror());
        dlclose(handle);
        return 1;
    }

    // 调用so库中的函数
    hello();

    // 关闭dl句柄
    dlclose(handle);

    return 0;
}

当要创建module的实例时,从modules的skynet_module数组中取出模块,调用create函数创建实例,然后将实例指针传入init函数完成初始化,赋值给context。

  • skynet_context管理模块

创建一个新的服务,首先要找到服务对应的module,在创建完module实例并完成初始化以后,还需要创建一个skynet_context上下文,并将module实例和这个context关联起来,最后放置于skynet_context 列表中,一个个独立的沙盒环境就这样被创建出来了,下面来看看主要的数据结构:

// skynet-src/skynet_server.c
struct skynet_context {
	void * instance;                 // 由指定module的create函数,创建的数据实例指针,同一类服务可能有多个实例,因此每个服务都应该有自己的数据
	struct skynet_module * mod;      // 引用服务module的指针,方便后面对create、init、release和signal函数进行调用
	void * cb_ud;                    // 调用callback函数时,回传给callback的userdata,一般是instance指针                        
	skynet_cb cb;                    // 服务的消息回调函数,一般在skynet_module的init函数里指定
	struct message_queue *queue;     // 服务专属的次级消息队列指针
	ATOM_POINTER logfile;    // 日志句柄
	uint64_t cpu_cost;	     // in microsec
	uint64_t cpu_start;	     // in microsec
	char result[32];         // 操作skynet_context的返回值,会写到这里
	uint32_t handle;         // 标识唯一context的服务id
	int session_id;          // 发出请求后,收到对方返回的消息时,通过session_id来匹配一个返回,对应哪个请求
	ATOM_INT ref;            // 引用计数变量,当为0时,表示内存可以释放
	int message_count;
	bool init;               // 是否完成初始化
	bool endless;            // 是否堵住
	bool profile;

	CHECKCALLING_DECL
};

// skynet-src/skynet_handle.c
// 这个结构用于记录服务对应的别名,当应用层为某个服务命名时,会写到这里来
struct handle_name {
	char * name;        // 服务别名
	uint32_t handle;    // 服务id
};

struct handle_storage {
	struct rwlock lock;                // 读写锁

	uint32_t harbor;                   // harbor id
	uint32_t handle_index;             // 创建下一个服务时,该服务的slot idx,一般会先判断该slot是否被占用
	int slot_size;
	struct skynet_context ** slot;     // skynet_context list
	
	int name_cap;                      // 别名列表大小,大小为2^n
	int name_count;                    // 别名数量
	struct handle_name *name;          // 别名列表
};

static struct handle_storage *H = NULL;

 创建一个新的skynet_context时,会往slot列表中放,当一个消息送达context时,其callback函数就会被调用,callback函数一般在module的init函数里指定,调用callback函数时,会传入userdata(一般是instance指针)、source(发送方的服务id)、type(消息类型)、msg和sz(数据及其大小),每个服务的callback处理各自的逻辑。这里其实可以将modules视为工厂,而skynet_context则是该工厂创建出来的实例,而这些实例则是通过handle_storage来进行管理。

  • 消息与消息队列

服务通过消息来驱动,服务的消息从消息队列取出。skynet包含两级消息队列,一个globa_mq,他包含一个head和tail指针,分别指向队列头部和尾部;还有次级消息队列,是单向链表的形式。消息的派发机制是,工作线程从gloab_mq里pop出一个次级消息队列,再从次级消息队列中pop出一个消息,并传给context的callback函数,在完成驱动以后,再将次级消息队列push回global_mq中,数据结构如下所示:

// skynet_mq.h
struct skynet_message {
	uint32_t source;    //消息发送方的服务地址

    // 如果这是一个回应消息,那么要通过session找回对应的一次请求,在lua层,我们每次调用call的时候
    // 都会往对方的消息队列中,铺设一个消息,并且生成一个session,然后将本地的协程挂起,挂起时,会
    // 以session为可以,协程的句柄为值,放入一个table中,当回应消息送达时,通过session找到对应的协程,并将其唤醒
	int session;
	void * data;        // 消息地址
	size_t sz;          // 消息大小
};

// skynet_mq.c
#define DEFAULT_QUEUE_SIZE 64
#define MAX_GLOBAL_MQ 0x10000

// 0 means mq is not in global mq.
// 1 means mq is in global mq , or the message is dispatching.

#define MQ_IN_GLOBAL 1
#define MQ_OVERLOAD 1024

struct message_queue {
	struct spinlock lock;     // 自旋锁,可能存在多个线程向同一个队列写入的情况,加上自旋锁避免
	uint32_t handle;          // 拥有此消息队列的服务的id
	int cap;                  // 消息大小
	int head;                 // 头部index
	int tail;                 // 尾部index
	int release;              // 是否能释放消息
	int in_global;            // 是否在全局消息队列中,0表示不是,1表示是
	int overload;             // 是否过载
	int overload_threshold;   
	struct skynet_message *queue;  // 消息队列
	struct message_queue *next;    // 下一个消息队列的指针
};

struct global_queue {
	struct message_queue *head;
	struct message_queue *tail;
	struct spinlock lock;
};

static struct global_queue *Q = NULL;

如何传递消息呢?我们要向一个服务发送消息,最终是通过调用skynet.send接口,将消息插入到该服务专属的次级消息队列,次级消息队列的内容并不是context结构的一部分(context只是引用了他的指针),因此,在一个服务执行callback的同时,其他服务可以向它的消息队列里面push消息,而mq的push操作是加了一个自旋锁的,避免多个线程同时操作一个消息队列。lua层的skynet.send接口,最终会调到c层的skynet_context_push。这个接口实质上是通过handle将context指针取出,然后再往消息队列里push消息。

// skynet_server.c
int
skynet_context_push(uint32_t handle, struct skynet_message *message) {
	struct skynet_context * ctx = skynet_handle_grab(handle);
	if (ctx == NULL) {
		return -1;
	}
	skynet_mq_push(ctx->queue, message);
	skynet_context_release(ctx);

	return 0;
}

// skynet_handle.c
struct skynet_context * 
skynet_handle_grab(uint32_t handle) {
	struct handle_storage *s = H;
	struct skynet_context * result = NULL;

	rwlock_rlock(&s->lock);

	uint32_t hash = handle & (s->slot_size-1);
	struct skynet_context * ctx = s->slot[hash];
	if (ctx && skynet_context_handle(ctx) == handle) {
		result = ctx;
		skynet_context_grab(result);
	}

	rwlock_runlock(&s->lock);

	return result;
}

在通过handle获取context指针时,加入了一个读锁,这样当在读取的过程中,如果有新服务创建,就存在要扩充skynet_context list的风险,因此不论如何,它都应当被阻塞住,直到所有的读锁都被释放掉。

次级消息队列实际上是一个数组,并且用两个int型数据,分别指向它的头部和尾部,当这两个值>=数组尺寸时,都会进行回绕(即从下标为0开始,比如值为数组的size时,会被重新赋值为0),在push操作后,head等于tail意味着队列已满(此时,队列会扩充两倍,并从头到尾重新赋值,此时head指向0,而tail为扩充前,数组的大小),在pop操作后,head等于tail意味着队列已经空了(后面他会从skynet全局消息队列中,被剔除掉)。

你可能感兴趣的:(skynet,lua,c语言)