本文主要参考&转载:skynet源码赏析
云风的 BLOG: skynet Archives
云风的 BLOG: Skynet 设计综述
本文旨在记录我对skynet重新学习和理解的过程,也便于以后回顾(本文纯手打,输出的过程也是记忆的过程)。
希望游戏服务器能充分利用多核优势,将不同的业务放在独立的环境中执行处理,协同工作。这个执行环境通过lua的虚拟机实现,能有效隔离不同的执行环境。而多线程模式使得状态共享,数据交换更加高效。
作为核心功能,skynet仅解决一个问题:
把一个符合规范的c模块,从动态库(so文件)中启动起来,绑定一个永不重复的数字id作为其handle。模块被称为服务,服务间可以自由的发送消息,每个模块可以向skynet框架注册一个callback函数,用来接收发给它的消息。
每个服务都是被一个个消息包驱动的,当没有包到来的时候,它们就会处于挂起状态。对CPU的资源零消耗。如果需要自主逻辑,则利用skynet系统提供的timeout消息,定期触发。
具体地,skynet为我们提供了这样的环境:
skynet的机制可以用一张表来概括:
注意:服务模块要将数据,通过socket发送给客户端时,并不是将数据写入消息队列,而是通过管道从work线程发送给socket线程,交由socket转发。此外,设置定时器也不走消息队列,而是直接在定时器模块,加入一个time_node。因为time和socket线程内运行的模块并不是这里的context,因此消息队列它们无法消费。
上面的论述只涉及到c服务模块,并未讨论lua服务的内容,我们所有的 lua服务均是依附于一个叫snlua的c模块来运行的,lua服务每次收到一个消息就会产生一个协程(事实上,skynet每个服务均有一个协程池,lua服务收到消息时,会优先去池子里取出一个协程,为了理解方便,就视为收到一个消息就创建一个协程)
上面的服务基本遵循一个原则,就是上层允许调用下层,而下层不能直接调用上层的api,这样做层次清晰,不会出现你中有我,我中有你的高度耦合的情况存在。c层和lua层的耦合模块则是包含在lualib-src中,这种模块划分更利于我们快速寻找对应模块。
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。
创建一个新的服务,首先要找到服务对应的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全局消息队列中,被剔除掉)。