记一次 skynet 中使用 skynet.queue 给消息加锁时的问题

应该大多数 skynet 项目都是以 Lua 作为主开发语言。在使用 Lua 编写 skynet 服务时,基本的执行单元是 coroutine 。当有消息到达使用 Lua 编写的服务时,skynet 都会起一个 coroutine 来处理消息,当某个 coroutine 涉及到异步让出执行时,skynet 便继续执行下一条消息,等到该 coroutine 的唤醒消息到达时便会继续执行。

比如客户端先发送 a 消息,再发送 b 消息,而 a 中有异步操作,b 中无异步操作,则 b 会先比 a 处理完返回到客户端。若 a 中需要扣除金币,在异步前检查了金币满足,在异步返回后还需要再次检查金币数量,因为这个时候金币可能被修改而不满足条件了。在游戏开发中,有时我们想保持处理客户端发送消息的顺序性。比如某玩法,客户端依次发送消息 a 和 b ,而我们确实想 b 一定要在 a 之后返回,这个时候就需要用到 skynet.queue 。具体的代码在 skynet 中 lualib/skynet/queue.lua 文件中。skynet.queue 函数执行后会返回一个 closure 而这个 closure 的参数是函数以及这个函数的参数。若对这个 closure 传入参数 f1 f2 f3 f4 等函数及参数,那么这 4 个函数将会按照顺序依次执行,假设顺序是 f1 -> f2 -> f3 -> f4 ,若其中有函数比如 f2 中间会让出,则会一直等待该函数执行完毕,才会执行 f3 。

local current_thread
local ref = 0
local thread_queue = {}

skynet.queue 中有 3 个变量,current_threadref 用于标识当前 coroutine 执行的函数,而 ref 用于进一步处理当前 coroutine 中的嵌套锁。如下。其实就相当于若发现当前执行是同一个 coroutine 则直接执行即可并且 ref 技术加 1 ,最后在 ref 为 0 时检查队列中是否有需要唤醒的 coroutine 。若发现 current_thread 不为 nil 值,而且和 coroutine.running() 返回值不一样,则表明先前的函数有异步操作,并且异步操作还没有返回,此时则把 coroutine 放到 thread_queue 中,然后 skynet.wait 使 coroutine 让出执行,等待前一个操作完成。

local lock = skynet.queue()
lock(function()
    print("get the lock")
    lock(function()
        print("get the lock again")
    end)
end)

其实 skynet.queue 返回的 closure 其实就是对要执行的函数加一层包裹( wrapper )。在 wrapper 函数中(返回的 closure )判断传入的函数 f 是否可以直接执行,还是需要把 coroutine 放到 thread_queue 中等待执行。只要我们持有这个 coroutine 我们就可以恢复它,继续执行,真的是很便捷的功能。下面我想模拟一下 skynet.queue 的实现,来实际感受一下。完整的代码放到这儿,直接使用 lua 就可以执行。下面列出一些代码片段来分析。

local commands = {}
commands.lock = queue()

function commands.f1()
    local lock = commands.lock
    lock(function()
        print("[f1] unlock first in f1")
        lock(function()
            print("[f1] unlock second in f1, then wait call return")
            call()
        end)
    end)
    print("[f1] f1 return now")
end

function commands.f2()
    local lock = commands.lock
    lock(function()
        print("[f2] unlock in f2")
    end)
    print("[f2] f2 return now, returned after f1")
end

function commands.f3()
    print("[f3] f3 return now, the first returned cmd")
end

local function dispatch(commands)
    local session = 0
    return function(cmd)
        session = session + 1
        local f = assert(commands[cmd])
        local co = cocreate(f)
        local ok, op = coresume(co)
        assert(ok)
        if op == "CALL" then
            session_to_co[session] = co
        end
        return session
    end
end

local run = dispatch(commands)
local f1_session = run("f1")
run("f2")
run("f3")
call_ret(f1_session)

模拟的情景是处理客户端依次发送 f1 f2 f3 消息。服务器会给 f1 和 f2 消息处理函数加锁,而处理 f1 时涉及到异步操作(调用了 call() 函数调用),加锁可以确保 f2 在 f1 之后执行完毕返回。而 f3 消息处理函数则会直接执行,不受锁的影响。代码 run("f1") run("f2") run("f3") 就是服务依次处理发来的消息,由于 f1 涉及到异步,call_ret 用于恢复 f1 的执行。所以,客户端发送消息的顺序是 f1 f2 f3 但是消息处理完返回的顺序是 f3 f1 f2 。下面是执行结果。验证了我们的分析。

D:\workplace>lua test.lua
[f1] unlock first in f1
[f1] unlock second in f1, then wait call return
[f3] f3 return now, the first returned cmd
[f1] f1 return now
[f2] unlock in f2
[f2] f2 return now, returned after f1

现在应该对 skynet.queue 完全理解了,来回到我在项目中碰见的问题。这个问题是这样的,我们项目中一个 agent 服务承载着多个玩家,会为每个玩家调用 skynet.queue 分配锁,然后为锁住每个玩家的客户端发来的消息,用于串行化消息的执行(目的是为了减少开发中过多的异步考虑,减少心智负担)。而其他服务给玩家发送消息时,也会用统一把锁来锁住消息处理函数。客户端发送 req_leave 消息时,服务器回以 res_leave 消息,这中间有异步,所以 agent 上该玩家一直等待 req_leave 消息处理完,当消息处理慢一些时,此时其他服务主动给客户端推送的 notify_update 消息,在经过 agent 上此玩家时,也会被之前的锁给锁住,从而要等待 req_leave 消息执行完,导致 notify_update 消息在 res_leave 消息后返回。而我们的玩法假定 res_leave 后不会再有消息发送到客户端,所以就出现了问题。
之前花了大半天来查到底为什么 notify_update 消息在 res_leave 消息后返回,没有什么头绪,后面被同事查到了,我觉得还是挺有必要写篇博客记录一下的,来进一步了解 skynet 中的异步流。同事就是把客户端发送的消息和服务器发回的消息 grep 出来后,看了大概的消息流,发现该玩家总是会卡在处理 req_leave 这段消息期间(为了方便调试,我们调用了 skynet.sleep 延长了消息的执行),之后就会恢复正常。所以定位到是锁的问题。因此最终的解决方案是 notify_update 这种仅仅通过 agent 服务发回到客户端的消息,不需要加锁,直接执行即可。
通过这次问题,我发现我这次 grep 时忽略了消息的前后执行上下文,只关注了单个消息的日志细节,所以没有注意到卡消息流的日志输出,以后还是得注意一下。

猜你喜欢

转载自my.oschina.net/iirecord/blog/1670916