skynet rpc序列化实现

A服务发消息给B服务,不管 B 服务是和 A 服务在同一个节点上,还是在不同节点上,都是需要序列化消息的,因为每个服务都是一个 Actor 对象,通信必须得通过发送信封的方式来交互,不能直接引用调用。

本来想自己写博客记录下具体,但网上搜到一篇对序列化将的还不错的文章,就懒得再自己写了。

skynet.pack序列化学习

在看了这个 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.callcluster.send

static int
lpackrequest(lua_State *L) {
    return packrequest(L, 0);
}

static int
lpackpush(lua_State *L) {
    return packrequest(L, 1);
}

packrequest 主要做两件事:

  1. 构造头部信息 + 包体消息
  2. 释放旧的 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 层自定义数据结构,来缓存半包数据,也就不用关心这种对方宕机后,缓存的数据处理。

 

posted @ 2024-07-02 07:08  墨色山水  阅读(160)  评论(0)    收藏  举报