skynet是一个轻量级游戏服务器框架,但是不仅仅用于游戏,其他领域也可以使用这个框架。它的轻量级体现在:
- 仅实现 actor 模型,以及相关的脚手架。sknynet目录下lualib和lualib-src都是脚手架。
- 实现了服务器框架的基础组件。实现了 reactor 并发网络库,并提供了大量连接的接入方案;基于自身网络库,实现了常用的数据库驱动(异步连接方案),并融合了 lua 数据结构。
编译安装
先安装相应依赖。
apt-get install git build-essential readline-dev autoconf
或者
apt-get install git build-essential libreadline-dev autoconf
下载源码并编译安装
git clone https://github.com/cloudwu/skynet.git
cd skynet
make linux
多核并发编程
skynet属于多核并发编程。
多核并发编程主要有多进程、多线程(一般是一个进程里面开的多个线程)、协程、actor模型。前两者是通过共享内存来达到通信的目的,模块之间耦合度较高;后两者是利用通信来达到共享数据的目的。由于多进程和多线程模型存在资源竞争的问题,所以需要加锁,一般而言,这种锁首选自旋锁,避免进程或线程的切换。比如memcached,由于其数据结构是hash,多线程加锁时粒度比较小,通常也采用粒度较小的自旋锁,或是原子变量实现的自旋锁。
通过通信来共享数据,其实是一种解耦合的过程。并发实体之间可以分别开发并进行单独优化,而它们唯一的耦合在于消息。这能让我们快速地进行开发;同时也符合我们开发的思路,将一个大的问题拆分成若干个小问题。
actor模型
先给出一张总体的组成图。
下面来介绍skynet中的actor模型。
定义
actor 是skynet中最基本的计算单元,是基于消息进行计算和沟通的模型,用于并行计算。
actor由三部分组成:隔离的环境、消息队列、回调函数。隔离的环境主要通过 lua 虚拟机来实现;消息队列用来存放有序(先后到达)的消息;回调函数用来运行 actor,即从 actor 的消息队列中取出消息,并作为该回调函数的参数来运行 actor。
部分接口
这里给出sknynet中关于actor的部分接口,具体的实现通过下载的源码自行查看。
创建actor
在skynet.lua中
function skynet.newservice(name, ...)
return skynet.call(".launcher", "lua" , "LAUNCH", "snlua", name, ...)
end
接下来需要看到launcher.lua中的command.LAUNCH函数,这里不再一步步跟,最后会到skynet_server.c中skynet_context_new函数,该函数会创建一个隔离的环境(lua 虚拟机),一个消息队列,并且需要设置回调函数。
struct skynet_context *skynet_context_new(const char * name, const char *param) {
struct skynet_module * mod = skynet_module_query(name);
if (mod == NULL)
return NULL;
void *inst = skynet_module_instance_create(mod);
if (inst == NULL)
return NULL;
struct skynet_context * ctx = skynet_malloc(sizeof(*ctx));
CHECKCALLING_INIT(ctx)
ctx->mod = mod;
ctx->instance = inst;
ATOM_INIT(&ctx->ref , 2);
ctx->cb = NULL;
ctx->cb_ud = NULL;
ctx->session_id = 0;
ATOM_INIT(&ctx->logfile, (uintptr_t)NULL);
ctx->init = false;
ctx->endless = false;
ctx->cpu_cost = 0;
ctx->cpu_start = 0;
ctx->message_count = 0;
ctx->profile = G_NODE.profile;
// Should set to 0 first to avoid skynet_handle_retireall get an uninitialized handle
ctx->handle = 0;
ctx->handle = skynet_handle_register(ctx);
struct message_queue * queue = ctx->queue = skynet_mq_create(ctx->handle);
// init function maybe use ctx->handle, so it must init at last
context_inc();
CHECKCALLING_BEGIN(ctx)
int r = skynet_module_instance_init(mod, inst, ctx, param);
CHECKCALLING_END(ctx)
if (r == 0) {
struct skynet_context * ret = skynet_context_release(ctx);
if (ret) {
ctx->init = true;
}
skynet_globalmq_push(queue);
if (ret) {
skynet_error(ret, "LAUNCH %s %s", name, param ? param : "");
}
return ret;
} else {
skynet_error(ctx, "FAILED launch %s", name);
uint32_t handle = ctx->handle;
skynet_context_release(ctx);
skynet_handle_retire(handle);
struct drop_t d = { handle };
skynet_mq_release(queue, drop_message, &d);
return NULL;
}
}
这里再给出创建时用到的一些重要函数的声明。
// 用于创建隔离的环境
void * skynet_module_instance_create(struct skynet_module *m);
// 用于设置回调函数
int skynet_module_instance_init(struct skynet_module *m, void * inst, struct skynet_context *ctx, const char * parm);
// 用于释放 actor 对象
void skynet_module_instance_release(struct skynet_module *m, void *inst);
// 用于处理 信号 消息
void skynet_module_instance_signal(struct skynet_module *m, void *inst, int signal);
运行actor
从skynet.c中开始
struct message_queue * skynet_context_message_dispatch(struct skynet_monitor *sm, struct message_queue *q, int weight) ;
函数中有从消息队列中取出消息操作skynet_mq_pop(q,&msg)
,然后执行消息dispatch_message(ctx, &msg);
最后在这个函数中会执行回调函数ctx->cb,这里就不再一一跟下去了。
actor消息
actor是基于消息进行计算的模型,在 skynet 框架中,actor消息包含三种: actor之间发送的消息、网络消息以及定时消息。
actor之间消息
主要通过两种方式发送消息
-- addr 对端服务的地址
-- typename 消息类型 actor内部间通常为 lua 类型消息
-- ... 为可变参
skynet.send(addr, typename, ...)
-- addr 对端服务的地址
-- typename 消息类型 actor内部间通常为 lua 类型消息
-- ... 为可变参
-- 注意:
-- 对端需要显示调用 skynet.ret(...)或skynet.retpack(...) 回应 skynet.call 的请求,两者区别在于是否自动打包
-- 或者通过调用 skynet.response() 延迟回应 skynet.call 的请求
skynet.call(addr, typename, ...)
网络消息
skynet 当中采用一个 socket 线程来处理网络信息。skynet 基于 reactor 网络模型,在linux中使用epoll。网络当中获取数据,怎么知道传递到哪个服务的消息队列当中去?这就需要做到fd与相应的actor绑定。
skynet通过 epoll_ctl 设置 struct epoll_event 中设置data.ptr = (struct socket *)ud;
来完成fd 与 actor绑定,通过 socket.start(fd, func)
来完成 actor 与 fd 的绑定。
定时消息
skynet 采用多层级时间轮来解决多线程环境下定时任务的管理,时间复杂度为 O ( 1 ) O(1) O(1),当定时任务被触发,将会向目标 actor 发送定时消息,从而驱动 Actor 的运行。
actor调度
工作线程从全局队列中 pop 出单个 actor 消息队列,从 actor 消息队列中按照规则 pop 出一定数量的消息进行执行。执行这个数量的消息后,若 actor 消息队列中仍有消息,则将该actor放入全局队列队尾;若 actor 消息队列中没有消息则不放入全局队列中。这也确保了全局队列只存活跃的 actor 消息队列。
工作线程执行规则
工作线程数量是按照 cpu 核心数来设置的,一般设置的线程数等于cpu核心数。工作线程按照下面工作线程权重图来设置每个工作线程的权重。
// 工作线程权重图
static int weight[] = {
-1, -1, -1, -1, 0, 0, 0, 0,
1, 1, 1, 1, 1, 1, 1, 1,
2, 2, 2, 2, 2, 2, 2, 2,
3, 3, 3, 3, 3, 3, 3, 3, };
权重<0,worker线程一次消费一条消息(从actor消息队列中pop一个消息);权重=0,worker线程一次消费actor消息队列里所有的消息;权重>0,假设actor消息队列的长度为
l
e
n
len
len,worker线程一次消费actor消息队列里
l
e
n
/
2
w
e
i
g
h
t
len/2^{weight}
len/2weight条消息。
由于前4个线程一次只执行一条消息,这就确保没有消息会处于饥饿状态。
int i,n=1;
for (i=0; i<n; i++) {
// 注意: skynet_mq_pop pop出消息则返回0,没有pop消息返回1
if (skynet_mq_pop(q, &msg)) {
skynet_context_release(ctx);
return skynet_globalmq_pop();
} else if (i==0 && weight >= 0) {
n = skynet_mq_length(q);
n >>= weight;
}
...
// 调用 actor 回调函数消费消息
dispatch_message(ctx, &msg);
}
通过这种方式,完成消息队列梯度消费,从而不至于让某些队列过长。这种消息调度的方式不是最优的调度方式(相较于 go 语言)。云风也在尝试修改更优的方式来调度,但是目前从多年线上实践情况来看,skynet 运行良好。
actor锁问题
关于actot的加锁,有以下几点:
- 在skynet工作过程中,会有多个工作线程从全局消息队列中取次级消息队列的情况,应该采用什么锁?
从两个方面考虑,一是skynet 目标是为了充分利用多核,尽量减少 cpu 切换;二是队列操作粒度很小,全局队列的 pop 和 push 操作只是少量的运算。所以,考虑使用自旋锁,互斥锁会引起核心切换。 - 当 skynet 全局消息队列节点很少的时候,怎么让多余的工作线程得到休眠?如果此时全局消息队列节点很多后,怎么让休眠的工作线程得到唤醒?
使用条件变量+互斥锁。对于调度,可以使用条件变量,也可以使用信号量。但是,因为信号量是固定数量的,而skynet中actor数量是不固定的,有时多有时少,所以无法使用需要准确数量的信号量。
总结一下:
- actor 内部若涉及多线程应考虑加自旋锁或原子操作,避免在工作线程执行过程中被切换;
- actor 内部若涉及多线程应考虑临界区域操作不能过于耗时,避免长期占用工作线程让同消息队列中其他消息得不到及时执行;
- actor 单个消息业务应避免阻塞线程(注意不是协程)的操作。如果这个操作必不可少,另起一个外部进程,skynet 进程用 socket 与之通信,这种阻塞或者耗时操作的任务交由外部服务来处理。当然,也可以在skynet添加一个线程,参考timer线程的实现。
最后再补充一下,锁中还有一种较为重要的锁是读写锁。读写锁适用于读多写少的情况。此外,对于读多写少的情况,有些框架还可以不用加读写锁,如mvcc和read-on-copy。