skynet coroutine 运行笔记

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

消息派发

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

  • 调用 skynet.register_protocol 注册。函数的参数是一个 table ,里面有若干字段含义如下:
    {
         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 函数。当服务的消息队列有消息到达时,框架从消息队列中取出消息经过一些转换调用到 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 函数中。下面是简化版的函数实现:

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

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

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

 1 local skynet = require "skynet"
 2 local db = {}
 3 
 4 local command = {}
 5 
 6 function command.GET(key)
 7     return db[key]
 8 end
 9 
10 function command.SET(key, value)
11     local last = db[key]
12     db[key] = value
13     return last
14 end
15 
16 skynet.start(function()
17     skynet.dispatch("lua", function(session, address, cmd, ...)
18         local f = command[string.upper(cmd)]
19         if f then
20             skynet.ret(skynet.pack(f(...)))
21         else
22             error(string.format("Unknown command %s", tostring(cmd)))
23         end
24     end)
25     skynet.register "SIMPLEDB" -- 注册名称,其他服务可以直接向此名称发送协议
26 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 实现。

 1 local coroutine_pool = {} -- 存放 coroutine 对象的数组
 2 local coroutine_yield = coroutine.yield -- 让出函数
 3 
 4 local function co_create(f)
 5     local co = table.remove(coroutine_pool) -- 先从数组中取出 coroutine ,从数组中删除是禁止此 coroutine 被其他消息使用
 6     if co == nil then
 7         co = coroutine.create(function(...)
 8             f(...) -- 执行我们传入的函数
 9             while true do
10             -- 执行完后回收 coroutine 
11             f = nil
12             coroutine_pool[#coroutine_pool+1] = co
13             -- 让出执行,通知 main_thread 做些清理工作
14             -- coroutine 被唤醒后,代码会从下面的调用中返回并赋值 f 为我们需要执行的函数,然后继续执行
15             f = coroutine_yield "EXIT" 
16             f(coroutine_yield()) -- 这里再次调用让出函数,是为了接收参数传递给 f 
17             end
18         end)
19     else
20         coroutine.resume(co, f) -- 唤醒一个 coroutine ,并传入参数 f ,f 是我们想要执行的函数
21     end
22     return co
23 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 实现如下:

1 function skynet.ret(msg, sz)
2     msg = msg or ""
3     return coroutine_yield("RETURN", msg, sz)
4 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 代码。

 1 function suspend(co, result, command, param, size)
 2     if command == "CALL" then
 3         session_id_coroutine[param] = co -- 记录下此 coroutine ,接收到"response"消息时获取
 4     elseif command == "RETURN" then
 5         local co_session = session_coroutine_id[co]
 6         local co_address = session_coroutine_address[co]
 7         ret = c.send(co_address, skynet.PTYPE_RESPONSE, co_session, param, size)
 8         return suspend(co, coroutine.resume(co, ret))
 9     elseif command == "EXIT" then
10         -- coroutine exit
11         local address = session_coroutine_address[co]
12         release_watching(address)
13         session_coroutine_id[co] = nil
14         session_coroutine_address[co] = nil
15         session_response[co] = nil
16     end
17 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 简化版代码如下:

 1 local skynet = require "skynet"
 2 local netpack = require "netpack"
 3 local socket = require "socket"
 4 local sproto = require "sproto"
 5 local sprotoloader = require "sprotoloader"
 6 
 7 local host
 8 local send_request
 9 
10 local CMD = {}
11 local REQUEST = {}
12 local client_fd
13 
14 function REQUEST:set()
15     print("set", self.what, self.value)
16     local r = skynet.call("SIMPLEDB", "lua", "set", self.what, self.value)
17 end
18 
19 local function request(name, args, response)
20     local f = assert(REQUEST[name])
21     local r = f(args)
22     if response then
23         return response(r)
24     end
25 end
26 
27 local function send_package(pack)
28     local package = string.pack(">s2", pack)
29     socket.write(client_fd, package)
30 end
31 
32 skynet.register_protocol {
33     name = "client",
34     id = skynet.PTYPE_CLIENT,
35     unpack = function (msg, sz)
36         return host:dispatch(msg, sz)
37     end,
38     dispatch = function (_, _, type, ...) -- "client" 类型消息 dispatch 函数
39         if type == "REQUEST" then
40             local ok, result  = pcall(request, ...)
41             if ok then
42                 if result then
43                     send_package(result)
44                 end
45             else
46                 skynet.error(result)
47             end
48         else
49             assert(type == "RESPONSE")
50             error "This example doesn‘t support request client"
51         end
52     end
53 }
54 
55 skynet.start(function()
56     skynet.dispatch("lua", function(_,_, command, ...)
57         local f = CMD[command]
58         skynet.ret(skynet.pack(f(...)))
59     end)
60 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 代码:

 1 local function yield_call(service, session)
 2     local succ, msg, sz = coroutine_yield("CALL", session)
 3     return msg,sz
 4 end
 5 
 6 function skynet.call(addr, typename, ...)
 7     local p = proto[typename]
 8     local session = c.send(addr, p.id , nil , p.pack(...))
 9     if session == nil then
10         error("call to invalid address " .. skynet.address(addr))
11     end
12     return p.unpack(yield_call(addr, session))
13 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 ,服务本身不会被阻塞,可以继续处理其他消息。

代码取自skynet-v1.0.0-alpha,因为代码以后有可能变动,这里是以 1.0-alpha 为基准分析的。一次性打字描述好多函数调用有可能会描述错误(而且打字并没有那么直观:)),有错误的话,欢迎评论指出,我来修改。

郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。