skynet中的协程

这篇是转来的,http://www.cnblogs.com/iirecord/p/skynet_coroutine.html
也是这一直很想写的,作者已经把skynet处理消息过程中的协程解释得很清析了,如下文

——————

阅读云大的博客以及网上关于 skynet 的文章,总是会谈服务与消息。不怎么看得懂代码,光读这些文字真的很空洞,不明白说啥。网络的力量是伟大的,相信总能找到一些解决自己疑惑的文章。然后找到了这篇讲解 skynet 消息队列的文章(最新的 skynet 消息队列代码已经有更新,变得更简洁易读)。了解了 skynet 消息是如何派发的,就想知道消息被派发出去到一个服务后,如何调用服务的 callback 函数,从而处理此消息。碰巧博主写了这篇讲解 skynet 如何注册回调函数的文章,于是 skynet 的概念“服务与消息”便在代码中得到了定位,便可以此为入口点探究 skynet 实现。

消息派发

这里云大已经很详细的介绍了,我就仅仅在这里略提一下。skynet 把消息分为不同的类别,不同类别的消息有不同的编码方式,若编写一个服务,你需要为此服务关注的消息类型注册 dispatch 函数用来接收此类别的消息。skynet 注册类别消息的 dispatch 函数有两种方式。

调用 skynet.register_protocol 注册。函数的参数是一个 table ,以”lua”类消息为例,里面有若干字段含义如下:

{
name = "lua", -- 消息组的字符串名称
id = skynet.PTYPE_LUA, -- 消息组的数字 id
pack = skynet.pack, -- 打包消息
unpack = skynet.unpack, -- 解包消息
dispatch = function(session, source, cmd, ...) ... end -- 消息回调/分发函数
}

指定了 table 中的 dispatch 字段,以后”lua”类消息到达时便会调用此函数。

调用 skynet.dispatch 函数注册。为此,云大给出了一个惯用写法,以”lua”类消息为例,如下:

local CMD = {}
skynet.dispatch("lua", function(session, source, cmd, ...)
local f = assert(CMD[cmd])
f(...)
end)

两种方式可以根据喜好选择,毕竟一个服务可能需要处理多种类型的消息,需要注册多个 dispatch 函数。

在 skynet 中用 Lua 编写一个服务必须调用 skynet.start 启动函数启动此服务。

function skynet.start(start_func)
c.callback(dispatch_message)
skynet.timeout(0, function()
init_service(start_func)
end)
end

skynet.start 其中在一个作用是调用 c.callback 函数把 skynet 框架的消息派发与你自定义的 dispatch 函数联系起来,这个联系的纽带就是 dispatch_message(skynet.lua) 函数。当服务的消息队列有消息到达时,框架从消息队列中取出消息经过一些转换调用到 dispatch_message 函数,然后 dispatch_message 函数根据协议类型调用相应的 dispatch 函数,最终到具体某条消息的处理函数。

消息执行

skynet 是基于服务的,服务间通过消息进行通信。实现方面 skynet 为每个服务创建一个 lua_State ,不同的服务 lua_State 是不同的,因此服务是相互独立互不影响的。对于消息,”skynet 的 lua 层会为每个请求创建一个独立的 coroutine”。经过上面一节,了解到消息会到达我们自定义的 dispatch 函数,此时进入了业务相关的代码逻辑中,我们只关注业务的逻辑而不关注底层消息如何到达这儿的。于是猜测应该是在 dispatch_message 函数中 skynet 会创建 coroutine 来具体处理某个消息。然后,我们猜想消息执行流程大概应该是这样的:

一条消息到达,服务的主线程创建 coroutine 处理此消息,处理完后执行权回到主线程,继续下一条消息处理。
一条消息到达,服务的主线程创建 coroutine 处理此消息,假设此服务是 A ,此时创建的 coroutine 是 coA。A 向另一个服务 B 发送一条消息并等待 B 的返回结果,A 才继续执行。这时最好的方式是对 coA 做出标记让出执行,主线程继续处理其他消息,并根据标记判断接收的消息是不是派发到 coA 的,若是则再唤醒 coA 继续执行。
对于单个服务来说,弄清楚一条消息执行流程是这篇笔记的主要内容。

此外由于每条消息都运行在一个 coroutine 中,云大根据反馈对 coroutine 进行了回收再利用以此提升效率。

skynet 接口有非阻塞 API (如 skynet.ret)也有阻塞 API (如 skynet.call)。阻塞 API 也仅仅是阻塞调用此 API 的 coroutine ,服务本身并没有阻塞。这两个 API 刚好与上面猜测的消息执行流程相呼应,接下来以这两个 API 为例子来说明。顺便提一点,调用阻塞 API 时要防止一些问题。

绕来绕去的 coroutine

上面提到 dispatch_message 会创建 coroutine 把消息派发到我们的自定义 dispatch 函数中。实际上完成任务是在函数 raw_dispatch_message 函数中。下面是简化版的函数实现:

local function raw_dispatch_message(prototype, msg, sz, session, source, ...)
-- skynet.PTYPE_RESPONSE = 1, read skynet.h
if prototype == 1 then -- “response” 类型消息,skynet 已自动处理
local co = session_id_coroutine[session]
session_id_coroutine[session] = nil
suspend(co, coroutine.resume(co, true, msg, sz))
else -- 其他类型消息派发到相应的 dispatch 函数
local p = assert(proto[prototype], prototype)
local f = p.dispatch -- 我们自定义的 dispatch 函数
if f then
local co = co_create(f) -- 创建 coroutine
session_coroutine_id[co] = session
session_coroutine_address[co] = source
suspend(co, coroutine.resume(co, session,source, p.unpack(msg,sz, ...)))
end
end
end

下面以 skynet 自带的例子 agent.lua 和 simpledb.lua 为例来进行说明,以 agent 服务 和 simpledb 服务分别指代这两个服务。agent 服务通过”client”类型协议处理客户端发送过来的请求,然后 agent 服务和 simpledb 服务通信获得结果,最后把结果发送到客户端。simpledb 服务最简单,接收消息计算结果并返回结果。

先以 simpledb 服务为例进行说明。

local skynet = require "skynet"
local db = {}

local command = {}

function command.GET(key)
return db[key]
end

function command.SET(key, value)
local last = db[key]
db[key] = value
return last
end

skynet.start(function()
skynet.dispatch("lua", function(session, address, cmd, ...)
local f = command[string.upper(cmd)]
if f then
skynet.ret(skynet.pack(f(...)))
else
error(string.format("Unknown command %s", tostring(cmd)))
end
end)
skynet.register "SIMPLEDB" -- 注册名称,其他服务可以直接向此名称发送协议
end)

simpledb(line 17) 调用 skynet.dispatch 注册”lua”类型消息的 dispatch 函数,假设这个匿名函数叫 db_dispatch 。

假设 simpledb 接收到 agent 发送过来的”SET”消息。框架从 simpledb 消息队列中取出消息,经过一些调用代码执行到 raw_dispatch_message 函数。在 raw_dispatch_message(line 3) 进行 if 条件判断,这条”SET”消息的消息类型是”lua”,因此 prototype 是 10 ,代码这时执行到 else 分支,目的是为了创建 coroutine 调用 db_dispatch 函数。代码走到 raw_dispatch_message(line 11) 调用 co_create 函数,在能回收 coroutine 的情况下创建一个 coroutine ,让我们看看 co_create 实现。

local coroutine_pool = {} -- 存放 coroutine 对象的数组
local coroutine_yield = coroutine.yield -- 让出函数

local function co_create(f)
local co = table.remove(coroutine_pool) -- 先从数组中取出 coroutine ,从数组中删除是禁止此 coroutine 被其他消息使用
if co == nil then
co = coroutine.create(function(...)
f(...) -- 执行我们传入的函数
while true do
-- 执行完后回收 coroutine
f = nil
coroutine_pool[#coroutine_pool+1] = co
-- 让出执行,通知 main_thread 做些清理工作
-- coroutine 被唤醒后,代码会从下面的调用中返回并赋值 f 为我们需要执行的函数,然后继续执行
f = coroutine_yield "EXIT"
f(coroutine_yield()) -- 这里再次调用让出函数,是为了接收参数传递给 f
end
end)
else
coroutine.resume(co, f) -- 唤醒一个 coroutine ,并传入参数 f ,f 是我们想要执行的函数
end
return co
end

从使用的理念上,调用函数创建一个 coroutine 对象后,再调用 resume 函数,coroutine 便会执行,调用者无需关注这个 coroutine 是新创建的还是回收利用之前已经创建的。代码继续执行,走到 raw_dispatch_message(line 14) ,正如预想的那样,代码先调用 coroutine.resume 启动 coroutine ,于是 dispatch 函数变得以执行。由于 coroutine 是回收利用的,实际在 raw_dispatch_message(line 14) 调用 coroutine.resume 时,coroutine 是分两种情况执行的,让我们回到 co_create 函数实现。

当调用 co_create ,coroutine_pool 没有 coroutine 时(此时有可能是服务刚启动数组中还没有 coroutine ,也有可能创建的 coroutine 已经被用完了)此时会走到 co_create(line 7) ,创建一个新的 coroutine 。然后调用 coroutine.resume 时,co_create(line 8) 的代码会被执行,函数执行完后就要回收这个新创建的 co ,然后调用 coroutine_yield “EXIT” 让出执行,此时 raw_dispatch_message(line 14) 调用的 coroutine.resume 函数返回,代码回到主线程,调用 suspend 函数处理”EXIT”命令,suspend 函数执行完后,raw_dispatch_message 函数也执行完毕,本次消息也就执行完毕。
当调用 co_create ,coroutine_pool 中有剩余的 coroutine 时,此时便会利用这个 coroutine 。代码执行到 co_create(line 20) ,这里调用 coroutine.resume 唤醒这个之前已经让出执行的 coroutine ,然后在 co_create(line 15) 对 coroutine_yield 的调用会返回,并赋值 f ,这样做的目的是为了传入我们要执行的函数 f 。然后执行到 line 16 再次调用 coroutine_yield ,这次目的是为了接收函数参数。最后在 raw_dispatch_message(line 14) 调用 coroutine.resume 时,coroutine 第二次被唤醒,在 co_create(line 16) coroutine_yield 会返回并返回 resume 传入的参数,这样我们想要执行的函数便得到执行。执行后这是一个 while 死循环,代码走到 co_create(11) 开始回收这个 coroutine ,然后调用 coroutine_yield “EXIT” 让出执行(接下来的执行同上),消息执行完毕。
分析了 co_create 函数,让我们回到正题。此时是 simpledb 服务,代码执行 raw_dispatch_message(line 14) ,coroutine 被执行,db_dispatch 函数被调用,此时代码走到 simpledb(line 18) 然后 command.SET 函数被调用,紧接着调用 skynet.ret 返回结果。skynet.ret 实现如下:

function skynet.ret(msg, sz)
msg = msg or ""
return coroutine_yield("RETURN", msg, sz)
end

在 skynet.ret 函数中会调用 coroutine_yield ,此时 coroutine 会让出执行,执行权回到主线程 main_thread 。不要晕,千万不要晕:)现在代码再次回到 raw_dispatch_message(line 14) ,此时 coroutine.resume 函数返回并返回了 4 个参数:true, “RETURN”, msg, sz ,其中 msg, sz 是要发送回去的消息。接着便调用 suspend 函数处理”RETURN”命令。下面看一下简化版的 suspend 代码。

function suspend(co, result, command, param, size)
if command == "CALL" then
session_id_coroutine[param] = co -- 记录下此 coroutine ,接收到"response"消息时获取
elseif command == "RETURN" then
local co_session = session_coroutine_id[co]
local co_address = session_coroutine_address[co]
ret = c.send(co_address, skynet.PTYPE_RESPONSE, co_session, param, size)
return suspend(co, coroutine.resume(co, ret))
elseif command == "EXIT" then
-- coroutine exit
local address = session_coroutine_address[co]
release_watching(address)
session_coroutine_id[co] = nil
session_coroutine_address[co] = nil
session_response[co] = nil
end
end

再次强调一下,此时代码走到 suspend ,这是在主线程执行的,然后处理”RETURN”命令,发送消息到 agent 服务。这里发现原来调用 skynet.ret 返回消息时实际的消息发送是在主线程执行的。紧接着代码走到 suspend(line 8) ,再次调用 coroutine.resume ,此时执行权回到 coroutine ,回到 skynet.ret 函数中,在 skynet.ret(line 3) coroutine_yield 返回后,skynet.ret 函数也已经返回,执行权还是在 coroutine ,代码此时走到 simpledb(line 20) skynet.ret 的返回,db_dispatch 函数也已经执行完并返回,此时 simpledb 已经对”SET”消息处理完毕,这时就相当于 co_create 中的 f 函数执行完毕,下面就是 coroutine 的回收,参考 co_create 说明。OK ,到了这里 simpledb 处理”SET”消息,我们已经分析完毕,看起来很绕,其实也蛮清晰的。我们来总结一下 simpledb 处理”SET”消息在主线程和 coroutine 经历了哪些切换(忽略 co_create 利用回收的 cocoutine 时做的切换):

(raw_dispatch_message 函数) 主线程 -> (db_dispatch 函数) coroutine: skynet.ret 调用 coroutine_yield”RETURN” 让出执行 -> (suspend 函数,在 raw_dispatch_message(line 14) 被调用) 主线程: 处理”RETURN”,并再次 resume -> coroutine: skynet.ret 返回,db_dispatch 函数返回,调用coroutine_yield”EXIT” 让出执行 -> (suspend 函数,在suspend(line 8) 被调用) 主线程: 处理”EXIT”,suspend 函数返回,raw_dispatch_message 函数返回 -> 消息执行完毕。

以 agent 服务为例说明 skynet.call 调用。

上面解释了 simpledb 处理”SET”消息的流程,这条消息实际上是 agent 服务发送过去的,agent 也是接收到”client”类型的”set”。agent 简化版代码如下:

local skynet = require "skynet"
local netpack = require "netpack"
local socket = require "socket"
local sproto = require "sproto"
local sprotoloader = require "sprotoloader"

local host
local send_request

local CMD = {}
local REQUEST = {}
local client_fd

function REQUEST:set()
print("set", self.what, self.value)
local r = skynet.call("SIMPLEDB", "lua", "set", self.what, self.value)
end

local function request(name, args, response)
local f = assert(REQUEST[name])
local r = f(args)
if response then
return response(r)
end
end

local function send_package(pack)
local package = string.pack(">s2", pack)
socket.write(client_fd, package)
end

skynet.register_protocol {
name = "client",
id = skynet.PTYPE_CLIENT,
unpack = function (msg, sz)
return host:dispatch(msg, sz)
end,
dispatch = function (_, _, type, ...) -- "client" 类型消息 dispatch 函数
if type == "REQUEST" then
local ok, result = pcall(request, ...)
if ok then
if result then
send_package(result)
end
else
skynet.error(result)
end
else
assert(type == "RESPONSE")
error "This example doesn't support request client"
end
end
}

skynet.start(function()
skynet.dispatch("lua", function(_,_, command, ...)
local f = CMD[command]
skynet.ret(skynet.pack(f(...)))
end)
end)

看见代码发现 agent 服务处理两种类型的消息:”lua”和”client”。这里我们关注的是”client”消息,”client”消息的 dispatch 函数是调用 skynet.register_protocol 设置的,赋值给 dispatch 一个匿名函数,假设这个匿名函数叫 ag_client_dispatch 。当接收到客户端发送来的”set”消息(这里先不管那些不懂的函数,我们此时只关注执行流程),便会调用 REQUEST:set 函数,然后调用 skynet.call 向 simpledb 发送”set”消息,阅读 skynet 文档说 skynet.call 是阻塞的(阻塞调用 skynet.call 的 coroutine),我们来看一下是如何阻塞的。先看一下简化的 skynet.call 代码:

local function yield_call(service, session)
local succ, msg, sz = coroutine_yield("CALL", session)
return msg,sz
end

function skynet.call(addr, typename, ...)
local p = proto[typename]
local session = c.send(addr, p.id , nil , p.pack(...))
if session == nil then
error("call to invalid address " .. skynet.address(addr))
end
return p.unpack(yield_call(addr, session))
end

阅读发现 skynet.call 和 skynet.ret 有一些相似,不同的是 skynet.call 调用 coroutine_yield 传入的是”CALL”,然后执行权回到主线程 suspend 函数,阅读 suspend 函数(千万别晕)代码发现此时仅仅是记录了 coroutine ,然后就返回了。 神马?神马?神马?suspend 函数没有做其他的事情就返回了,我们的 agent 服务对”set”消息的处理追踪定格在了 skynet.call(line 2) 行,当前这个 coroutine 未被回收,而是被标记了,然后本次 agent 对”set”消息的处理也就完毕了。

当 simpledb 接收到”set”消息并处理完,然后调用 skynet.ret 返回结果时,阅读 suspend(line 7) 此时给 agent 服务发送了一个类型为 1 的”lua”类型的消息。之后 agent 服务接收到此消息时,agent 服务主线程执行到函数 raw_dispatch_message ,由于 prototype 为 1 ,此时走到了 raw_dispatch_message(line 6) ,找到了上次标记的 coroutine ,并调用 resume 唤醒这个 coroutine 并传入了接收到的 msg 和 sz(这实际是 simpledb 服务发送来的),接着代码执行权来到 coroutine ,来到 skynet.call(line 2) ,coroutine_yield 函数返回并返回了接收到的消息。然后 skynet.call 函数执行完毕,执行权依旧是在 coroutine 中,然后回到 agent(line 16) ,接着继续执行,ag_client_dispatch 执行完毕,然后进行 coroutine 的回收,调用 coroutine_yield”EXIT” ,coroutine 也就执行完毕,执行权回到主线程,raw_dispatch_message(line 6) 行继续调用suspend 并传入”EXIT”命令,suspend 执行完后,raw_dispatch_message 也就执行完毕了,agent 对”set”消息的处理也终于结束了。总结一下:调用 skynet.call 导致 coroutine 被中间执行中断,等结果到达时(框架从 agent 服务消息队列取得相应的消息)才会从中断处继续执行。流程是这样的:

1) 第一次。

(raw_dispatch_message 函数) 主线程 -> (ag_client_dispatch 函数) coroutine: skynet.call 调用 coroutine_yield”CALL” 让出执行 -> (suspend 函数,在 raw_dispatch_message(line 14) 被调用) 主线程: 处理”CALL”,suspend 函数返回,raw_dispatch_message 函数返回 -> 消息执行完毕。

2)第二次。

(raw_dispatch_message 函数) 主线程 -> coroutine: skynet.call(line 2) ,skynet.call 函数返回,ag_client_dispatch 函数返回,调用coroutine_yield”EXIT” 让出执行 -> (suspend 函数,在 raw_dispatch_message(line 6) 被调用) 主线程: 处理”EXIT”,suspend 函数返回,raw_dispatch_message 函数返回 -> 消息执行完毕。

总结:大体上 coroutine 的执行流程就是这样的。我们始终保持一个理念:skynet 为每个服务创建一个 lua_State ,skynet 为每个消息的执行创建一个 coroutine ,阻塞 API 阻塞的是当前 coroutine ,服务本身不会被阻塞,可以继续处理其他消息。

发表评论