阅读云大的博客以及网上关于 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 为基准分析的。一次性打字描述好多函数调用有可能会描述错误(而且打字并没有那么直观:)),有错误的话,欢迎评论指出,我来修改。