在 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. 设计目标
- ✅ 仅使用一个
skynet.timeout
驱动所有定时任务 - ✅ 支持一次性、周期性、立即执行等多种模式
- ✅ 支持取消、唤醒(wakeup)操作
- ✅ 高性能、低延迟、支持补帧
- ✅ 自动初始化 + 服务关闭时清理资源
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 框架
六、后续计划
- 开源完整代码(GitHub)
- 集成进我正在封装的 Skynet 框架中,实现自动初始化/销毁
- 增加调试工具:查看活跃定时器列表、统计信息、泄漏检测
- 探索支持“绝对时间触发”、“持久化定时任务”等特性
七、参考资料
- Skynet 官方文档:https://github.com/cloudwu/skynet/wiki/LuaAPI
- BinaryHeap 实现:https://github.com/Tieske/binaryheap.lua
- 相关课程参考(非必需):蓝桥云课 - Skynet 实战(优惠码:
2CZ2UA5u
) - 定时器代码位置: lualib/timer.lua
✍️ 作者备注:本文源于我在实际游戏服务器开发中的经验总结。最初的定时器封装很简单,但随着业务复杂度上升,性能问题逐渐暴露。最终通过最小堆 + 单主循环的设计,解决了大规模定时任务的性能瓶颈。希望这篇分享能帮助你写出更健壮、高效的 Skynet 服务!
📌 最终目标:让开发者只需关注业务逻辑,无需担心定时器带来的性能陷阱。
欢迎留言交流,也欢迎 Star 我未来的开源 Skynet 框架项目!