Skynet 定时器模块的封装:从简单实现到高性能设计

在 Skynet 框架中,定时器是服务开发中最常用的功能之一。无论是延迟执行任务、周期性心跳上报,还是技能冷却、倒计时逻辑,都离不开定时机制。本文将带你从最基础的 skynet.timeout 封装出发,逐步演进到一个高效、可扩展、支持取消与唤醒的通用定时器模块设计。


一、基本需求:我们需要什么样的定时器?

在实际开发中,常见的定时器功能需求通常包括:

Skynet 提供了原生接口 skynet.timeout(ti, func),其中 ti 的单位是 10 毫秒。我们可以基于它做一层简单的封装。


二、最简单的封装方案

1. 秒级延迟执行

function timeout(sec, func)
    return skynet.timeout(sec * 100, func) -- 转换为 10ms 单位
end

✅ 简单直接,适合一次性任务。


2. 支持取消的定时器

官方推荐方式:利用闭包 upvalue

Skynet 官方文档给出了一个巧妙的实现:

function cancelable_timeout(ti, func)
    local function cb()
        if func then
            func()
        end
    end
    local function cancel()
        func = nil -- 利用 upvalue 特性取消执行
    end
    skynet.timeout(ti * 100, cb)
    return cancel
end

-- 使用示例
local cancel = cancelable_timeout(5, dosomething)
cancel() -- 取消执行

✅ 实现轻巧
❌ 返回的是函数,不便于统一管理和扩展

更适合封装的方式:使用 ID 映射表

我们可以用一个全局表来管理所有待执行的回调,并通过 ID 控制生命周期:

local M = {}

local timers = {}
local id_inc = 0

function M.new_cancelable_timeout(sec, func)
    local id = id_inc + 1
    id_inc = id
    timers[id] = func

    skynet.timeout(sec * 100, function()
        if timers[id] then
            func()
        end
        timers[id] = nil
    end)

    return id
end

function M.cancel(id)
    timers[id] = nil
end

✅ 易于集成进模块系统
❌ 每个定时器都会调用一次 skynet.timeout,存在性能隐患


3. 循环定时器的实现

周期性任务可以通过递归注册实现:

function timeout_repeat(sec, func)
    local function loop()
        func()
        skynet.timeout(sec * 100, loop)
    end
    skynet.timeout(sec * 100, loop)
end

❌ 问题明显:每个循环任务都会占用一个 timeout 消息通道,在高并发场景下容易造成消息堆积。


三、性能瓶颈分析

当一个服务中存在大量玩家或定时任务时(例如每个玩家都有多个 Buff 定时器),频繁调用 skynet.timeout 会导致:

根据 Skynet 官方建议

“如果你的服务想大量使用定时器,可以考虑一个更好的方法:即在一个 service 里,尽量只使用一个 skynet.timeout,用它来驱动自己的定时事件模块。”

这启发我们设计一个 集中式定时器管理模块,仅依赖 单个主定时器 驱动所有任务。


💡 小插曲:关于旧实现的说明
其实我之前在蓝桥云课上做过一个类似的定时器封装课程:《Skynet 游戏服务器开发实战》,里面也实现过基于时间轮的定时器模块。不过那套方案已经是几年前的设计,架构略显过时,因此不特别推荐

如果你出于参考或怀旧目的想看看早期思路,可以用这个专属优惠码购买:2CZ2UA5u。权当是技术演进的一个脚注吧 😄


四、优化方案:基于最小堆的高效定时器模块

1. 设计目标


2. 接口设计

local timer_obj = timer.timeout(sec, func)           -- 延迟执行一次
local timer_obj = timer.repeat_immediately(sec, func) -- 立即执行 + 周期循环
local timer_obj = timer.repeat_delayed(sec, func)     -- 延迟后周期执行
timer.shutdown()                                     -- 服务退出时调用

🎯 返回值为 定时器对象(TimerObj),支持链式调用:

local t = timer.repeat_immediately(10, heartbeat)
t:cancel() -- 取消
t:wakeup() -- 提前触发

3. 定时器对象定义

---@class TimerObj
local timer_mt = {}
timer_mt.__index = timer_mt

-- 取消定时器
function timer_mt:cancel()
    -- 实现见下文
end

-- 强制提前触发(用于事件驱动刷新)
function timer_mt:wakeup()
    -- 实现见下文
end

4. 内部结构设计

我们采用 最小堆(Min Heap) 来维护所有定时任务,按到期时间排序。

核心数据结构

local g_minheap     -- 最小堆:key=到期时间戳(毫秒),payload=timer_id
local g_timers      -- 定时器元信息表:{id, interval, callback, is_repeat}
local g_task_co     -- 主协程引用
local g_sleeping    -- 是否处于 sleep 状态
local g_skip_sleep  -- 是否跳过下一次 sleep
local g_task_running -- 是否已启动主循环

📦 推荐使用成熟库:Tieske/binaryheap.lua 中的 unique heap,避免重复造轮子。


5. 初始化机制:懒加载(Lazy Init)

为了避免未使用定时器的服务也启动主循环,我们采用 延迟初始化 策略:

local function ensure_init()
    if g_task_running then return end

    g_task_running = true
    g_task_co = coroutine.running()

    skynet.fork(main_loop) -- 启动主循环协程
end

所有对外接口(如 timeout)都会先调用 ensure_init(),确保首次使用时才启动。

✅ 无需手动初始化
✅ 节省资源,按需启用


6. 主循环逻辑

local function main_loop()
    while g_task_running do
        local now = skynet.time() * 1000 -- 当前时间(毫秒)
        local next_time = g_minheap:minKey()

        if next_time and next_time <= now then
            process_expired_timers(now) -- 批量处理到期任务
        else
            local sleep_ms = math.max((next_time or now + 1000) - now, 1)
            g_sleeping = true
            skynet.sleep(sleep_ms // 10) -- 转换为 10ms 单位
            g_sleeping = false
        end

        if g_skip_sleep then
            g_skip_sleep = false
        end
    end
end

⚠️ 注意补帧:若系统卡顿导致错过多个时间点,需批量处理所有已到期任务。


7. wakeup 的安全实现

这是最容易出错的地方:不能误唤醒非定时器引起的 sleep

例如,协程正在等待 HTTP 响应,此时心跳定时器到期,不应触发 skynet.wakeup

我们通过 g_sleeping 标志判断当前是否真的因定时器而 sleep:

function timer_mt:wakeup()
    local id = self._id

    if g_minheap:valueByPayload(id) then
        -- 已在堆中,更新其触发时间
        g_minheap:update(id, millisecond())
    else
        -- 不在堆中(可能刚执行完周期任务),重新插入
        if g_timers[id] then
            g_minheap:insert(millisecond(), id)
        end
    end

    log.debug("Timer wakeup", "timer_obj", self)
    do_wakeup()
end

local function do_wakeup()
    if g_sleeping then
        skynet.wakeup(g_task_co)
        g_sleeping = false
    else
        g_skip_sleep = true -- 下一轮主循环跳过 sleep
    end
end

✅ 精准控制:只有在定时器 sleep 期间才能被唤醒
🚫 避免了 httpc.timeout 被提前中断的问题


8. 定时器类型语义说明

方法 行为
timeout(sec, func) sec 秒后执行一次
repeat_immediately(sec, func) 立即执行一次,之后每 sec 秒执行
repeat_delayed(sec, func) 首次延迟 sec 秒,之后周期执行

💡 典型应用场景:

  • repeat_immediately: 心跳上报、状态同步(不能等第一轮)
  • repeat_delayed: 冷却时间、倒计时任务

9. shutdown 清理机制

服务关闭时必须释放资源,防止内存泄漏和协程悬挂:

function M.shutdown()
    g_task_running = false

    if g_minheap then
        g_minheap:clear()
        g_minheap = nil
    end

    g_timers = {}
    g_task_co = nil

    if g_task_co then
        skynet.wakeup(g_task_co)
    end
end

✅ 可集成进框架,在服务退出钩子中自动调用

10. 新增一个随机延迟执行的周期性定时器

--- 创建一个首次随机的延迟执行的周期性定时器
-- @param name 定时器名称
-- @param sec 执行间隔,单位是秒
-- @param func 定时器回调函数
-- @param times 执行次数,如果不提供,则始终周期性执行
function M.repeat_random_delayed(name, sec, func, times)
    ensure_init()
    local first = mrandom(1, sec)
    local timer_obj = new_timer_obj(name, sec, func, first, times)
    insert_timer(timer_obj)
    return timer_obj
end

为什么会有这个接口呢?因为我在接入 sproto-orm 之后,数据在内存中是需要定时把脏数据写入 MongoDB 的,如果多个玩家数据在同一时刻被加载,那么后续定时落地的时机也会是同时,加这个接口就是为了把这个同时打散,让落地定时器更均匀分布,不至于压力集中。


五、总结:为什么这么做?

方案 优点 缺点
timeout 调用 实现简单 消息多、性能差
单主定时器 + 最小堆 高效、可控、支持复杂逻辑 实现稍复杂

我们选择后者,是因为:


六、后续计划


七、参考资料


✍️ 作者备注:本文源于我在实际游戏服务器开发中的经验总结。最初的定时器封装很简单,但随着业务复杂度上升,性能问题逐渐暴露。最终通过最小堆 + 单主循环的设计,解决了大规模定时任务的性能瓶颈。希望这篇分享能帮助你写出更健壮、高效的 Skynet 服务!


📌 最终目标:让开发者只需关注业务逻辑,无需担心定时器带来的性能陷阱。

欢迎留言交流,也欢迎 Star 我未来的开源 Skynet 框架项目!

点击进入评论 ...