skynet rpc序列化实现
A服务发消息给B服务,不管 B 服务是和 A 服务在同一个节点上,还是在不同节点上,都是需要序列化消息的,因为每个服务都是一个 Actor 对象,通信必须得通过发送信封的方式来交互,不能直接引用调用。
本来想自己写博客记录下具体,但网上搜到一篇对序列化将的还不错的文章,就懒得再自己写了。
在看了这个 skynet.pack 博客后,发现有个地方,我有自己的理解,就拿出来说说:
至于为什么还要重新复制一次,没有直接返回 head 指针给 lua 层调用者,然后根据 head 指向的链表来反序列化呢。我想主要是为了集群等其他模块的需要。比如说集群,你不可能通过 socket 发送一个链表给对方吧,所以只返回一个内存块地址和大小,可以为其他模块减少不必要的麻烦。
至于为什么还要重新复制一次,我个人的观点是,为了方便反序列化,你想想,如果用链表 buffer 来反序列化,在遇到 跨节点怎么反序列号,是不是不太好做,比如一个整数,跨两个节点,第一个节点占2个bit,第二个节点占剩余2个bit,那么想想就不好做把,如果是一块完整的连续的 buffer,我们就不用考虑这跨节点问题了。当然远程 rpc 调用需要发送完整的 buffer 这个我没啥疑问。
如果 B 服务在同一个节点上,那么 A 服务在把消息序列号后,只需要把这个序列化后的buffer 地址传递给 B 服务就可以,B服务在拿到这个buffer地址后,进行 反序列化,就可以消费消息了。但如果 B 服务是在远程另一个节点上呢,rpc 远程发送,需要知道哪些信息,我们至少需要知道对方 B 服务所在节点的 ip:port,有了 ip:port 才能 connect 建立连接,再然后,我们还需要知道 B 服务对应的句柄或者服务名吧,这样我们才能找到 B 服务。
我们看到 skynet 给的例子,cluster1.lua 代码,rpc 接口有两种一种是阻塞的 cluster.call,另一种是非阻塞的 cluster.send。cluster.call 需要阻塞等待对方返回消息,才能继续往下执行,函数重入需要注意上下文变换。如果是 cluster.send,则不会阻塞,发送完消息,就直接往下执行,函数可重入。
function cluster.call(node, address, ...)
-- skynet.pack(...) will free by cluster.core.packrequest
local s = sender[node]
if not s then
local task = skynet.packstring(address, ...)
return skynet.call(get_sender(node), "lua", "req", repack(skynet.unpack(task)))
end
return skynet.call(s, "lua", "req", address, skynet.pack(...))
end
function cluster.send(node, address, ...)
-- push is the same with req, but no response
local s = sender[node]
if not s then
table.insert(task_queue[node], skynet.packstring(address, ...))
else
skynet.send(sender[node], "lua", "push", address, skynet.pack(...))
end
end
这里的 s 对应的就是 clustersender 服务,一个连接对应一个 clustersender 服务,我们要发送消息的内容 ...在经过 skynet.packstring 序列化(底层也会调用到 luaseri_pack,和 skynet.pack 一样)后,传递给 clustersender 服务,再由 clustersender 服务通过网络模块 socket 发送出去。
local function send_request(addr, msg, sz)
-- msg is a local pointer, cluster.packrequest will free it
local current_session = session
local request, new_session, padding = cluster.packrequest(addr, session, msg, sz)
session = new_session
...
return channel:request(request, current_session, padding)
end
重点在 cluster.packrequest 上,它会构建一个头部信息,包括了发起消息的来源服务这次会话 session,以及指明消息需要传递到对方节点的哪个服务addr,最后是就是把头部信息和消息内容msg,及消息大小sz进行拼接,构成一个新的 buffer,经网络 socket 发送给目标节点。
cluster.packrequest 的实现对应 lua-cluster.c 文件。我们看到,对应有两种实现方式,分别代表了 cluster.call,cluster.send:
static int
lpackrequest(lua_State *L) {
return packrequest(L, 0);
}
static int
lpackpush(lua_State *L) {
return packrequest(L, 1);
}
packrequest 主要做两件事:
- 构造头部信息 + 包体消息
- 释放旧的 msg buffer,把最终带消息头的 msg 放入到lua 堆栈中。

在构造消息头部的时候,还有一些细节,比如发现包体大小超过32k 时,会进行分包。对方地址 addr 用整数,或者字符串,这两种情况 head 又是不一样的。
包体小于32k
给定对方地址 addr 如果是数字的情况,最后发送给对方的 buffer 数据:
给定对方地址 addr 如果是字符串的情况,最后发送给对方的 buffer 数据:

包头大于32k
在包体大于32k后,就需要切分多个包发送给对方了,虽然 2个字节头部,包体长度可以达 2^16-1 = 64k 大小,但只用了32k。可能是为了减少发包体积,减少网络压力,才这么设计把。
那么发出去的包是长怎么样的呢,我们用一张图来看下:

对方地址是 数字情况下,skynet 会先发一个总头部数据给对方,头部填充长度2个字节后,紧跟着的1个字节,代表了消息报的类型,0x41 表示消息包类型是 mul 的,后面会接着多段发送过去,并且是 cluster.send 非阻塞,不需要回复的消息包。1 表示消息包同样是 mul 的,但需要有回复,所以代表了 cluster.call 阻塞调用方式。
接下来就是将包体拆分成多个大小为 32k 的数据报,最后一个数据包可能比32k小,放到一个 lua 的数组中。如果当前拆分的数据包不是最后一个包,第3位值为2,如果是最后一个包,值为3,对方就会在收到最后一个包后,重新构造一个大小为 sz 的 buffer 缓冲区,将前面收到的半包和最后一个半包全部拼接起来,最后调用 luaseri_unpack 反序列化数据包,将结果传递给对应的 addr 服务消费。
如果对方地址 addr 是字符串的情况,同样和数字的情况差不多,都是先构造一个总头部数据包,后面是一个 table 数组,数组值就是分包后的结果,如下:

其实 lua-cluster.c 里的函数注释也很清楚的介绍了,包体结构。
在远程 rpc 时,建议 addr 使用字符串来表示,因为 cluster wiki 也有提到过,如果对方宕机,重启了,addr 数字可能发生变化,所以还是推荐使用固定写死的字符串来表示对方服务。
接收方解析数据包
接收方在接收到消息后,最先是在 gate 服务进行解析包头2个字节,查看是否是完整的数据包。
--gate.lua
gateserver.start(handler)
--gateserver.lua
function gateserver.start(handler)
...
unpack = function ( msg, sz )
return netpack.filter( queue, msg, sz)
end,
end
//lua-netpack.c
static int
lfilter() {
...
}
static inline int
read_size(uint8_t * buffer) {
int r = (int)buffer[0] << 8 | (int)buffer[1];
return r;
}
最终调用 lua-netpack.c 模块来接收到一个完整的数据包后,指针跳过数据包 头2个字节,再将这个指针传递给下一层 clusteragent 服务,由 clusteragent 服务调用 cluster.unpackrequest 来 unpack 解析剩余头部数据,获取到最终的 addr,session,msg 值。
cluster.unpackrequest 对应的 c 实现:
//lua-cluster.c
static int
lunpackrequest(lua_State *L) {
int sz;
const char *msg;
if (lua_type(L, 1) == LUA_TLIGHTUSERDATA) {
msg = (const char *)lua_touserdata(L, 1);
sz = luaL_checkinteger(L, 2);
} else {
size_t ssz;
msg = luaL_checklstring(L,1,&ssz);
sz = (int)ssz;
}
if (sz == 0)
return luaL_error(L, "Invalid req package. size == 0");
switch (msg[0]) {
case 0:
return unpackreq_number(L, (const uint8_t *)msg, sz);
case 1:
return unpackmreq_number(L, (const uint8_t *)msg, sz, 0); // request
case '\x41':
return unpackmreq_number(L, (const uint8_t *)msg, sz, 1); // push
case 2:
case 3:
return unpackmreq_part(L, (const uint8_t *)msg, sz);
case 4:
return unpacktrace(L, msg, sz);
case '\x80':
return unpackreq_string(L, (const uint8_t *)msg, sz);
case '\x81':
return unpackmreq_string(L, (const uint8_t *)msg, sz, 0 ); // request
case '\xc1':
return unpackmreq_string(L, (const uint8_t *)msg, sz, 1 ); // push
default:
return luaL_error(L, "Invalid req package type %d", msg[0]);
}
}
如下图所示:

skynet.start(function()
skynet.register_protocol {
name = "client",
id = skynet.PTYPE_CLIENT,
unpack = cluster.unpackrequest, -- 网络数据到来,解包接口
dispatch = dispatch_request, -- 在 cluster.unpackrequest 解包后,回调的函数
}
...
end)
local function dispatch_request(_,_,addr, session, msg, sz, padding, is_push)
ignoreret() -- session is fd, don't call skynet.ret
if session == nil then
-- trace
tracetag = addr
return
end
if padding then -- 分包情况下,缓存每个半包数据到 large_request 中,large_request[1] = 半包1,large_request[2] = 半包2...
local req = large_request[session] or { addr = addr , is_push = is_push, tracetag = tracetag }
tracetag = nil
large_request[session] = req
cluster.append(req, msg, sz)
return
else -- 分包情况下,获取到最后一个数据包;不分包情况下,就是一个完成的数据包
local req = large_request[session]
if req then
tracetag = req.tracetag
large_request[session] = nil
cluster.append(req, msg, sz)
msg,sz = cluster.concat(req)
addr = req.addr
is_push = req.is_push
end
...
end
...
end
如果是分包的情况下,就需要用一个 table 来缓存接收到的每个半包数据,直到接收到最后一个数据包时,才去拼接成一个完成的数据包,最终调用交给目标服 unpack(msg, sz) 消息体。
调用方接收 respone 消息
如果调用方是用 cluster.call 接口阻塞调用,那么会挂起当前服务,等待对方返回消息,才能接着往下执行。那么是在哪,接收响应消息的呢,我们知道最终发生消息是在 clustersender 服,那么接收应该也是在这个服,我看了下代码,果然是。
-- clustersender.lua
skynet.start(function()
channel = sc.channel {
host = init_host,
port = tonumber(init_port),
response = read_response,
nodelay = true,
}
skynet.dispatch("lua", function(session , source, cmd, ...)
local f = assert(command[cmd])
f(...)
end)
end)
local function read_response(sock)
local sz = socket.header(sock:read(2))
local msg = sock:read(sz)
return cluster.unpackresponse(msg) -- session, ok, data, padding
end
同样读取头部2个字节,成功读取后,接着读取sz 大小msg,接着调用 cluster.unpackresponse(msg) 解析头部,获取 session,msg。通过 session,我们可以找到发起方的协程co,然后 resume 唤醒它,把响应的数据包 skynet.unpack 反序列化,消费,这样就完成一个 cluster.call 过程。
异常处理
接收方宕机
如果是用 cluster.call 和对方通信时,假设消息发出去了,但对方还没来得及响应,就宕机了,结果会怎么样呢。
我发现如果是使用 socket_channel 来 read 网络数据包时,会被包装一下:
local function wrapper_socket_function(f)
return function(self, ...)
local result = f(self[1], ...)
if not result then
error(socket_error)
else
return result
end
end
end
channel_socket.read = wrapper_socket_function(socket.read)
channel_socket.readline = wrapper_socket_function(socket.readline)
可以看到,当对方节点宕机后,发起方底层肯定会调用到 lua 层,告知对方关闭了:
-- SKYNET_SOCKET_TYPE_CLOSE = 3
socket_message[3] = function(id)
local s = socket_pool[id]
if s then
s.connected = false
wakeup(s)
else
driver.close(id)
end
local cb = socket_onclose[id]
if cb then
cb(id)
socket_onclose[id] = nil
end
end
这里就会去 wakeup 唤醒正在挂起的协程。如果我们处于 read 挂起状态,那么此时会被唤醒。socket_channel 里的 read 指向了 wrapper_socket_function,在 wrapper_socket_function 里头,如果读不到数据,就抛出 error。顺序为:
read_response(clustersender.lua) -> sock:read(2) -> wrapper_socket_function 闭包读消息(socket_channel.lua) -> 抛出 erorr
发送方宕机
我之前想到一个情况,那就是如果消息包大小超过 32k,会被分成多个半包依次发送给对方,假设此时,我发送第一个半包后,发送方宕机了,接收方会怎么处理。
我仔细看了下 clusteragent.lua 实现,发现有这么一段代码:
skynet.start(function()
...
skynet.dispatch("lua", function(_,source, cmd, ...)
if cmd == "exit" then
socket.close_fd(fd)
skynet.exit()
...
end
end)
end)
这里注册了 lua 消息的回调函数,如果对方宕机了,接收方是会收到一个 exit 消息(由 clusterd 服务转发过来),此时,clusteragent 服务就会销毁自己。因为我们缓存半包用到的数据结构是 lua table,所以,服务销毁,本质是 lua_State 里所有用到的内存,包括 table,string 等这些,也会跟着自动释放。这样就很巧妙,没有在 c 层自定义数据结构,来缓存半包数据,也就不用关心这种对方宕机后,缓存的数据处理。

浙公网安备 33010602011771号