先插一句题外话,skynet中核心架构使用c语言,除此之外还大量使用了lua语言。这里不介绍lua。skynet在c中嵌入一层lua,主要是为了利用lua的协程,同步的方式实现异步的性能。关于协程,之前有过介绍。skynet中,io操作是由c完成的,lua是做业务层面的组装工作。
之前有过介绍,skynet中,有网络消息,这里介绍网络框架和网络消息的处理。
网络模型
skynet 采用 reactor 网络编程模型。
之前介绍过reactor ,就是单条连接通过 io 函数去检测网络事件再操作 io,转化为 reactor 统一为多条连接检测网络事件,再让有事件的连接同步通过 io 函数去操作 io。
对于网络模型的封装,主要通过四个事件来了解。
连接建立
这里分为两种情况。
- skynet作为服务端,由客户端与其建立连接。此时建立成功的标识是 listenfd,触发可读事件。
- skynet作为客户端,与第三方服务(如mysql)建立连接。此时建立成功的标识是 connect 返回fd,触发可写事件。
这与一般的reactor模型没有较大差异,不过多介绍。只是这里提醒一下,第二种情况下是触发的可写事件,如果想不通,可以思考一下三次握手的过程中第三次握手的情况。
连接断开
这是skynet与一般reactor模型差异最大的地方,skynet 网络层支持半关闭状态。这里举个半关闭状态的应用场景的例子。客户端发送完数据后关闭写端shutdown(w),不再向服务端写数据,此时需要等待服务端再返回最后一次数据,客户端接收到消息再close()。
读写端都关闭
这与正常的关闭事件没有差异。直接看代码
// 事件:1. EPOLLHUP 读写端都关闭
e[i].eof = (flag & EPOLLHUP) != 0;
// 处理:直接关闭并向 actor 返回事件 SOCKET_CLOSE
int halfclose = halfclose_read(s);
force_close(ss, s, &l, result); //实例就是调用close()
if (!halfclose) { // 如果前面因为关闭读端已经发送 SOCKET_CLOSE,在这里避免重复SOCKET_CLOSE
return SOCKET_CLOSE;
}
检测读端关闭
read()返回0,表示对端写关闭,本地读关闭。
int n = (int)read(s->fd, buffer, sz);
// 事件:2. 读端关闭 注意:EPOLLRDHUP 也可以检测,但是这个 read = 0 更为及时,因为事件处理先处理读事件,再处理异常事件
if (n == 0) {
if (s->closing) { // 如果该连接的 socket 已经关闭
// Rare case : if s->closing is true, reading event is disable, and SOCKET_CLOSE is raised.
if (nomore_sending_data(s)) {
force_close(ss,s,l,result);
}
return -1;
}
int t = ATOM_LOAD(&s->type);
if (t == SOCKET_TYPE_HALFCLOSE_READ) { // 如果已经处理过读端关闭
// Rare case : Already shutdown read.
return -1;
}
if (t == SOCKET_TYPE_HALFCLOSE_WRITE) { // 如果之前已经处理过写端关闭,则直接close
// Remote shutdown read (write error) before.
force_close(ss,s,l,result);
} else { // 如果之前没有处理过,则只处理读端关闭
close_read(ss, s, result);
}
return SOCKET_CLOSE;
}
检测写端关闭
通过write的返回值小于0 以及 errno == EPIPE来判断,检测对端读端关闭,本地写端关闭。
for (;;) {
ssize_t sz = write(s->fd, tmp->ptr, tmp->sz);
if (sz < 0) {
switch(errno) {
case EINTR:
continue;
case AGAIN_WOULDBLOCK:
return -1;
}
// sz < 0 && errno = EPIPE, fd is connected to a pipe or socket whose reading end is closed.
// 在这里的处理是只要sz < 0,且不是被中断打断以及写缓冲满的情况下,直接关闭本地写端
return close_write(ss, s, l, result);
}
... // 省略部分代码
}
消息到达
即触发可读事件。
使用单线程处理读事件。读策略:
int n = read(fd, buf, sz);
与一般reactor稍有不同的是,sz 初始值为 64,根据从网络接收数据情况,动态调整 sz 的大小。
// socket_server.c [forward_message_tcp]
int sz = s->p.size;
char * buffer = MALLOC(sz);
int n = (int)read(s->fd, buffer, sz);
... // 部分略
if (n == sz) {
s->p.size *= 2;
return SOCKET_MORE;
} else if (sz > MIN_READ_BUFFER && n*2 < sz) {
s->p.size /= 2;
}
return SOCKET_DATA;
消息发送完毕
即触发可写事件。
注意这里是可以有多个线程对同一fd进行写操作,即同一个 fd 可以在不同的 actor 中发送数据。skynet 底层通过加锁确保数据正确发送到socket 的写缓冲区。先看代码
// 注意此时在 work 线程中
int socket_server_send(struct socket_server *ss, struct socket_sendbuffer *buf) {
int id = buf->id;
struct socket * s = &ss->slot[HASH_ID(id)];
if (socket_invalid(s, id) || s->closing) {
free_buffer(ss, buf);
return -1;
}
struct socket_lock l;
socket_lock_init(s, &l);
// 确保能在fd上写数据,连接可用状态,检测 socket 线程是否在对该fd操作;
if (can_direct_write(s,id) && socket_trylock(&l)) {
// may be we can send directly, double check
// 双检测
if (can_direct_write(s,id)) {
// send directly
struct send_object so;
send_object_init_from_sendbuffer(ss, &so, buf);
ssize_t n;
if (s->protocol == PROTOCOL_TCP) {
// 尝试在 work 线程直接写,如果n > 0,则写成功
n = write(s->fd, so.buffer, so.sz);
} else {
union sockaddr_all sa;
socklen_t sasz = udp_socket_address(s, s->p.udp_address, &sa);
if (sasz == 0) {
skynet_error(NULL, "socket-server : set udp (%d) address first.", id);
socket_unlock(&l);
so.free_func((void *)buf->buffer);
return -1;
}
n = sendto(s->fd, so.buffer, so.sz, 0, &sa.s, sasz);
}
if (n<0) {
// ignore error, let socket thread try again
// 如果失败,不要在 work 线程中处理异常,所有网络异常在 socket 线程中处理
n = 0;
}
stat_write(ss,s,n);
if (n == so.sz) {
// write done
socket_unlock(&l);
so.free_func((void *)buf->buffer);
return 0;
}
// write failed, put buffer into s->dw_* , and let socket thread send it. see send_buffer()
s->dw_buffer = clone_buffer(buf, &s->dw_size);
s->dw_offset = n;
socket_unlock(&l);
inc_sending_ref(s, id);
struct request_package request;
request.u.send.id = id;
request.u.send.sz = 0;
request.u.send.buffer = NULL;
// let socket thread enable write event
// 如果写失败,可能写缓冲区满,或被中断打断,直接交由 socket 线程去重试;
// 注意,这里通过 pipe 来与 socket 线程通信
send_request(ss, &request, 'W', sizeof(request.u.send));
return 0;
}
socket_unlock(&l);
}
inc_sending_ref(s, id);
struct request_package request;
request.u.send.id = id;
request.u.send.buffer = clone_buffer(buf, &request.u.send.sz);
send_request(ss, &request, 'D', sizeof(request.u.send));
return 0;
}
因为 write 不同于 read,write(fd, buf, sz) 必须向 fd 的写缓冲区写满 sz 的值才会返回大于0的值,而read(fd, buf, sz) 可以实际只读取小于 sz 的值。这就是写可以有多个线程操作同一fd,而读不行的原因。
socket.lua 封装
加载 socket.lua 的服务,就具备了处理网络消息的能力, skynet_callback 设置服务的回调函数,在回调函数中可以设置不同类型的子回调。例如,在 socket.lua 设置了 “socket” 类型的处理方法, pack 指定发送数据的压缩方法, unpack 指定接收数据的解压缩的方法( unpack 的参数为 msg, sz ), dispatch 指定处理消息的方法,它的参数来自于 unpack 的返回值。
skynet.register_protocol {
name = "socket",
id = skynet.PTYPE_SOCKET, -- PTYPE_SOCKET = 6
unpack = driver.unpack,
dispatch = function (_, _, t, ...)
socket_message[t](...)
end
}
socket_message 是个数组,处理不同类型的网络消息。这里给出基本结构,里面具体的实现略去。
-- SKYNET_SOCKET_TYPE_DATA = 1
socket_message[1] = function(id, size, data) ... end
-- SKYNET_SOCKET_TYPE_CONNECT = 2
socket_message[2] = function(id, _ , addr) ... end
-- SKYNET_SOCKET_TYPE_CLOSE = 3
socket_message[3] = function(id) ... end
-- SKYNET_SOCKET_TYPE_ACCEPT = 4
socket_message[4] = function(id, newid, addr) ... end
-- SKYNET_SOCKET_TYPE_ERROR = 5
socket_message[5] = function(id, _, err) ... end
-- SKYNET_SOCKET_TYPE_UDP = 6
socket_message[6] = function(id, size, data, address) ... end
-- SKYNET_SOCKET_TYPE_WARNING = 7
socket_message[7] = function(id, size) ... end
现在我们考虑如何处理拆包和粘包问题。首先需要有一个读缓冲区缓存网络发送过来的数据;在 lua-socket.c 中封装了 buffer 对象操作接口,用来缓存网络数据包,并且封装了 socket 的 io 操作接口。
struct buffer_node {
char * msg;
int sz;
struct buffer_node *next;
};
struct socket_buffer {
int size;
int offset;
struct buffer_node *head;
struct buffer_node *tail;
};
static int lnewbuffer(lua_State *L) {
struct socket_buffer * sb = lua_newuserdatauv(L, sizeof(*sb), 0);
sb->size = 0;
sb->offset = 0;
sb->head = NULL;
sb->tail = NULL;
return 1;
}
buf还有其他操作,这里不再贴出来了。
socket是单线程工作的,这一点与redis相似。skynet与redis的区别在于redis的处理逻辑也是这个线程,而skynet的socket线程接收到了数据就转给相应的actor了,交由后续的work线程处理,实际上网络数据是在多个actor中处理的。那么,socket线程与work线程是怎样协同工作的呢?
在 skynet 中,协程与网络消息的关系是由 slot 所存储的 socket 池的索引 id 来联系的,协程与actor 消息以及定时消息的关系是由 session(全局唯一 id )来联系的。具体的线程工作方法:
work 线程通过 pipe 发送消息到 socket 线程去调用相应函数,并让出当前协程。
static int ctrl_cmd(struct socket_server *ss, struct socket_message *result) {
int fd = ss->recvctrl_fd;
// the length of message is one byte, so 256+8 buffer size is enough.
uint8_t buffer[256];
uint8_t header[2];
block_readpipe(fd, header, sizeof(header));
int type = header[0];
int len = header[1];
block_readpipe(fd, buffer, len);
// ctrl command only exist in local fd, so don't worry about endian.
switch (type) {
case 'R':
return resume_socket(ss,(struct request_resumepause *)buffer, result);
case 'S':
return pause_socket(ss,(struct request_resumepause *)buffer, result);
case 'B':
return bind_socket(ss,(struct request_bind *)buffer, result);
case 'L':
return listen_socket(ss,(struct request_listen *)buffer, result);
case 'K':
return close_socket(ss,(struct request_close *)buffer, result);
case 'O':
return open_socket(ss, (struct request_open *)buffer, result);
case 'X':
result->opaque = 0;
result->id = 0;
result->ud = 0;
result->data = NULL;
return SOCKET_EXIT;
case 'W':
return trigger_write(ss, (struct request_send *)buffer, result);
case 'D':
case 'P': {
int priority = (type == 'D') ? PRIORITY_HIGH : PRIORITY_LOW;
struct request_send * request = (struct request_send *) buffer;
int ret = send_socket(ss, request, result, priority, NULL);
dec_sending_ref(ss, request->id);
return ret;
}
case 'A': {
struct request_send_udp * rsu = (struct request_send_udp *)buffer;
return send_socket(ss, &rsu->send, result, PRIORITY_HIGH, rsu->address);
}
case 'C':
return set_udp_address(ss, (struct request_setudp *)buffer, result);
case 'T':
setopt_socket(ss, (struct request_setopt *)buffer);
return -1;
case 'U':
add_udp_socket(ss, (struct request_udp *)buffer);
return -1;
default:
skynet_error(NULL, "socket-server: Unknown ctrl %c.",type);
return -1;
};
return -1;
}
服务设置
最后再提示一下服务设置的相应问题。
服务的确定需要考虑以下几点:
- 功能独立性,可独立测试。actor设置的原则就是所有actor都能被单独测试。
- 需要大致估计它的运算程度。如果密集计算,需要考虑拆分成多个服务。如果一个服务涉及的功能太多,不能用简单案例来测试的时候,那么服务设置有问题,此时要按功能拆分;如果某个服务功能检测,也可以用简单案例来测试,但是计算比较密集,此时要将该服务拆分成核心数*N个服务。
设置核心数,是希望它们调度的时候能得到公平处理;为什么是N,根据 lua gc 压力,拆分成核心数的倍数。 - 需要考虑lua gc 压力,通常服务中存放的对象数据越多,gc 压力越大。lua的gc是阻塞操作,所有gc操作完成后才会继续执行lua操作。
当服务拆分成多个,数据需要共享应该如何实现呢?
- 通过消息来交换数据,这也符合“通过消息来共享数据”这一哲学。
- 如果这些数据大部分场景下只是只读的,为了数据一致性,通常将数据放在一个服务中,让不同的服务从这个服务中获取数据。
- 使用 sharetable 模块。原理是 skynet 修改了 lua,为了让服务不重复加载数据,会共享一些函数原型和常量表,其次也默认会缓存 lua 文件,这样其他服务能快速加载这些 lua 文件(一个 lua 文件通过 loadfile 加载后,磁盘对该文件修改不会影响下一次加载)。skynet 增加了设置文件缓存方式的模块 skynet.codecache。
-- 从一个源文件读取一个共享表,这个文件需要返回一个 table ,这个 table 可以被多个不同的服务读取。... 是传给这个文件的参数。
sharetable.loadfile(filename, ...)
-- 更新一个或多个 key
sharetable.update(filenames)
-- 以 filename 为 key 查找一个被共享的 table
sharetable.query(filename)
local cache = require "skynet.codecache"
--[[
当 mode 为 "ON" 的时候,当前服务 cache 一切加载 lua 代码文件的行为。
当 mode 为 "OFF" 的时候,当前服务关闭任何重复利用 lua 代码文件的行为,即使在别的服务中曾经加载过同名文件。当 mode 为 "EXIST" 的时候,当前服务在加载曾经在其它服务或自己的服务加载过同名文件时,复用之前的拷贝。但对新加载的文件则不进行 cache 。
ps:通常可以让 skynet 本身被 cache。
]]
cache.mode(mode)
关于skynet,大致介绍这么多,具体细节的理解还是需要自行查看源码。