1. 为什么要在 Lua 里自己造一个定时器
做游戏后端或者嵌入式脚本开发的朋友,大概率都遇到过这个场景:技能冷却要按毫秒扣、心跳包要每隔几秒发一次、某个 buff 要在 10 秒后自动消失。这些需求本质上都是同一件事——在未来的某个时间点执行一段逻辑。Lua 本身是一门非常精简的语言,标准库只提供了os.time、os.clock这类粗糙的时间接口,它不会帮你管理“什么时候该触发哪个回调”。所以只要项目稍微复杂一点,自己实现一套定时器几乎是绕不开的活。
我最早接触这个需求是在一个回合制战斗服务器上。当时策划要求“中毒效果每 2 秒跳一次伤害,持续 12 秒”,如果每个技能都写一堆while循环加sleep,代码会烂得没法维护。后来我把定时器抽象出来,发现它不仅能解决 buff 计时,还能统一处理网络超时、AI 行为节拍、资源刷新等一大堆问题。这篇文章就把我这几年反复打磨的 Lua 定时器方案完整拆开讲一遍,从数据结构选型到时间轮优化,从精度陷阱到内存回收,尽量把踩过的坑都摊开说清楚。不管你是刚接触 Lua 的新手,还是已经写过几版定时器但总觉得不够稳的老手,应该都能从里面捞到点能直接抄的东西。
需要先说明一点:Lua 定时器本身不产生“时间流逝”,它必须依附于一个驱动源。常见的有两种,一种是游戏引擎每帧调用一次Update(deltaTime),另一种是独立线程里跑一个循环去poll。本文的方案对这两种驱动方式都兼容,核心逻辑是一致的,区别只在于你把update函数挂在哪里。
2. 定时器的整体设计与核心思路拆解
2.1 需求拆解:一个合格的定时器要满足什么
在动手写代码之前,我习惯先把需求列清楚,不然写着写着就会跑偏。一个能在生产环境用的 Lua 定时器,至少要满足下面这几条:
- 支持一次性定时:延迟 N 毫秒后执行一次,执行完自动销毁。
- 支持循环定时:每隔 N 毫秒执行一次,可以指定执行次数或者无限循环。
- 支持取消:定时器还没触发时,能通过一个句柄把它干掉,比如玩家死亡时清掉所有 buff 计时。
- 精度可控:误差要在一个可接受范围内,不能出现“说好 1 秒结果 1.5 秒才触发”的情况。
- 性能可扩展:同时存在几千上万个定时器时,每帧的遍历开销不能爆炸。
- 内存安全:定时器销毁后要能被 GC 回收,不能因为闭包引用导致内存泄漏。
这几条里,前三条是功能底线,后三条是工程底线。很多网上的示例代码只做到了前三条,一上量就出问题,这也是我后来反复重构的原因。
2.2 三种主流实现方案对比
实现定时器,绕不开“怎么组织这些待触发的任务”这个问题。我前后用过三种方案,各有各的适用场景,这里先做个横向对比,方便你按自己的项目规模选型。
| 方案 | 数据结构 | 插入复杂度 | 触发查找复杂度 | 适用场景 |
|---|---|---|---|---|
| 无序数组遍历 | 数组 | O(1) | O(n) | 定时器数量少(几十个以内) |
| 最小堆 | 二叉堆 | O(log n) | O(log n) | 通用场景,几千到几万个 |
| 时间轮 | 环形数组+链表 | O(1) | O(1) 均摊 | 超大量、精度要求固定的场景 |
无序数组是最朴素的写法:把所有定时器塞进一个 list,每次 update 遍历一遍,谁的到期时间小于当前时间就触发。优点是实现简单到不能再简单,缺点是每次都要全量扫描。定时器一多,比如同时有 5000 个 buff 在跑,每帧扫 5000 次,60 帧就是 30 万次比较,CPU 直接给你脸色看。
最小堆是我目前最推荐的通用方案。它维护一个按到期时间排序的堆,堆顶永远是最早到期的那个。每次 update 只需要看堆顶,如果堆顶没到期,后面所有定时器都不用管,直接返回。这个“提前退出”的特性让它在定时器数量多但单帧触发少的情况下非常高效。插入和删除都是 O(log n),对于几千个定时器的量级完全够用。
时间轮是性能天花板,插入和触发都是 O(1)。它的思路是把时间切成一个个格子,每个格子挂一个链表,定时器按到期时间散列到对应格子里,指针每 tick 走一格。缺点是精度受格子粒度限制,而且实现复杂度明显上升。除非你的定时器数量到了十万级,否则我不建议一上来就上时间轮,维护成本不划算。
我的经验是:90% 的项目用最小堆就够了。先用最小堆把功能跑通,等真的遇到性能瓶颈再考虑换时间轮,不要过早优化。
2.3 为什么选最小堆而不是有序链表
有人会问,既然要按时间排序,为什么不用有序链表?插入的时候找到位置插进去,触发的时候从头取就行了。这个思路没错,但问题出在插入上。有序链表的插入是 O(n),因为你要从头遍历找到正确的位置。定时器频繁创建和销毁的场景下(比如 MOBA 游戏里技能满天飞),插入操作会非常频繁,O(n) 的插入会拖垮性能。而最小堆的插入是 O(log n),在 n 较大的时候优势明显。另外,堆的“只看堆顶”特性天然契合定时器的触发逻辑,代码写起来也更清爽。
3. 最小堆定时器的核心细节与实操要点
3.1 堆的底层存储与索引约定
最小堆本质上是一棵完全二叉树,但我们用数组来存就行,不需要真的建树。索引关系是这样的:假设父节点索引是i,那么左子节点是2*i,右子节点是2*i+1。反过来,子节点索引是j,父节点就是math.floor(j/2)。这套约定是堆操作的基础,一定要记牢。
我用一个 table 来存所有定时器节点,每个节点至少包含这几个字段:
-- 定时器节点结构 local timerNode = { expireTime = 0, -- 到期时间(毫秒) interval = 0, -- 循环间隔,0 表示一次性 callback = nil, -- 到期执行的回调函数 times = 1, -- 剩余执行次数,-1 表示无限 cancelled = false, -- 取消标记 handle = 0, -- 对外暴露的句柄 ID }这里有个细节值得说:expireTime我用的是绝对时间而不是相对时间。相对时间(比如“还有 3000 毫秒到期”)在每次 update 后都要重新计算,容易出错;绝对时间(比如“在 1234567890 毫秒时到期”)只需要在创建时算一次,之后比较大小就行,逻辑更干净。
3.2 堆的上浮与下沉操作
堆的两个核心操作是上浮(sift up)和下沉(sift down)。插入新节点时放在数组末尾,然后不断和父节点比较,如果比父节点小就交换,直到满足堆性质,这就是上浮。删除堆顶时,把末尾节点移到堆顶,然后不断和较小的子节点比较,如果比子节点大就交换,直到满足堆性质,这就是下沉。
-- 上浮:新插入的节点向上调整 local function siftUp(heap, index) while index > 1 do local parent = math.floor(index / 2) if heap[parent].expireTime <= heap[index].expireTime then break end heap[parent], heap[index] = heap[index], heap[parent] index = parent end end -- 下沉:堆顶节点向下调整 local function siftDown(heap, index, size) while true do local left = index * 2 local right = index * 2 + 1 local smallest = index if left <= size and heap[left].expireTime < heap[smallest].expireTime then smallest = left end if right <= size and heap[right].expireTime < heap[smallest].expireTime then smallest = right end if smallest == index then break end heap[smallest], heap[index] = heap[index], heap[smallest] index = smallest end end注意:比较的时候用的是
<=还是<会影响相同到期时间的定时器触发顺序。如果你希望先创建的定时器先触发,那在比较时就要把插入顺序作为第二关键字。我在实际项目里就遇到过“两个同时到期的 buff,结算顺序反了导致伤害计算错误”的问题,后来在节点里加了个自增的seq字段专门用来打破平局。
3.3 句柄机制与取消操作
对外暴露的句柄我用一个自增的整数 ID,而不是直接把节点 table 返回给调用方。这样做有两个好处:一是调用方拿到的是一个轻量级的数字,不会意外持有节点引用导致内存泄漏;二是取消的时候可以通过 ID 快速定位,配合一个handleMap就能实现 O(1) 查找。
取消操作我采用的是惰性删除:不直接从堆里摘除节点,而是把节点的cancelled标记置为 true。等到这个节点浮到堆顶被触发时,发现它已经被取消,就跳过执行并真正从堆里移除。这样做的好处是避免了在堆中间删除节点带来的复杂索引调整,代价是被取消的节点会短暂占用内存。对于绝大多数场景,这个代价可以忽略。
function Timer:cancel(handle) local node = self.handleMap[handle] if node then node.cancelled = true self.handleMap[handle] = nil end end实操心得:惰性删除虽然简单,但如果你的场景里“创建后立刻取消”非常频繁(比如每帧创建又每帧取消),堆里会堆积大量僵尸节点,导致堆变大、update 变慢。这种情况我建议加一个阈值,当僵尸节点占比超过 30% 时,主动做一次堆重建,把有效节点重新整理一遍。
4. 完整实操流程与核心环节实现
4.1 定时器管理器的初始化
先把整个管理器搭起来。我用一个 table 作为类,里面维护堆数组、当前大小、句柄自增计数和句柄映射表。
local Timer = {} Timer.__index = Timer function Timer.new() local self = setmetatable({}, Timer) self.heap = {} -- 最小堆数组 self.size = 0 -- 堆中节点数量 self.nextHandle = 1 -- 句柄自增计数 self.handleMap = {} -- handle -> node 映射 self.currentTime = 0 -- 当前时间(毫秒) return self endcurrentTime这个字段很关键,它由外部驱动源更新。游戏引擎每帧调用timer:update(deltaTime)时,我们就把currentTime累加上deltaTime。这样所有定时器都基于同一个时间基准,不会出现各自为政的情况。
4.2 创建定时器的完整流程
创建定时器分两种情况:一次性定时和循环定时。我把它统一成一个add函数,通过参数区分。
-- delay: 延迟毫秒数 -- interval: 循环间隔,0 表示一次性 -- callback: 到期回调 -- times: 执行次数,-1 表示无限循环 function Timer:add(delay, interval, callback, times) local node = { expireTime = self.currentTime + delay, interval = interval or 0, callback = callback, times = times or 1, cancelled = false, handle = self.nextHandle, } self.nextHandle = self.nextHandle + 1 self.handleMap[node.handle] = node -- 插入堆尾并上浮 self.size = self.size + 1 self.heap[self.size] = node siftUp(self.heap, self.size) return node.handle end这里有个容易忽略的点:delay和interval的单位要统一。我全程用毫秒,因为游戏引擎的deltaTime通常也是毫秒。如果你的驱动源给的是秒,记得在入口处乘 1000,别混着用,不然会出现“说好 1 秒结果 1 毫秒就触发”的惨案。
4.3 update 驱动与到期触发逻辑
update 是整个定时器的心脏,每次驱动源推进时调用。核心逻辑是:看堆顶,如果堆顶到期时间大于当前时间,说明所有定时器都没到期,直接返回;否则弹出堆顶,执行回调,如果是循环定时器就重新计算到期时间再插回堆里。
function Timer:update(deltaTime) self.currentTime = self.currentTime + deltaTime while self.size > 0 do local top = self.heap[1] if top.expireTime > self.currentTime then break -- 堆顶都没到期,后面的更不可能到期 end -- 弹出堆顶 self.heap[1] = self.heap[self.size] self.heap[self.size] = nil self.size = self.size - 1 if self.size > 0 then siftDown(self.heap, 1, self.size) end -- 处理取消的节点 if top.cancelled then self.handleMap[top.handle] = nil else -- 执行回调 if top.callback then top.callback(top.handle) end -- 循环定时器重新入堆 if top.interval > 0 and top.times ~= 1 then if top.times > 0 then top.times = top.times - 1 end top.expireTime = top.expireTime + top.interval self.size = self.size + 1 self.heap[self.size] = top siftUp(self.heap, self.size) else self.handleMap[top.handle] = nil end end end end这段代码里有几个细节值得展开说。第一,循环定时器重新入堆时,新的到期时间是top.expireTime + top.interval,而不是self.currentTime + top.interval。这个区别很重要:前者能保证长期运行的循环定时器不会因为每帧的微小误差累积而“越跑越慢”,后者则会让误差不断叠加。第二,while循环而不是if,是因为一次 update 可能跨越多个定时器的到期时间,必须把所有到期的都处理完。第三,回调执行放在弹出堆之后,这样即使回调里又创建了新定时器,也不会影响当前这轮的处理。
4.4 一个可直接运行的完整示例
把上面的代码拼起来,再加个简单的测试驱动,就是一个能跑的完整版本了。
-- 模拟游戏主循环 local timer = Timer.new() -- 创建一个 1 秒后触发的一次性定时器 timer:add(1000, 0, function(handle) print("一次性定时器触发,handle = " .. handle) end) -- 创建一个每 500 毫秒触发一次的循环定时器,共触发 3 次 local loopHandle = timer:add(500, 500, function(handle) print("循环定时器触发,handle = " .. handle) end, 3) -- 模拟每帧 16 毫秒,跑 3 秒 for frame = 1, 200 do timer:update(16) end跑下来你会看到循环定时器在第 500、1000、1500 毫秒各触发一次,一次性定时器在第 1000 毫秒触发。注意循环定时器第三次触发后times减到 0,就不再入堆了。
提示:实际项目里
update的调用频率要和你的精度需求匹配。如果定时器精度要求是 10 毫秒,那驱动源的间隔就不能大于 10 毫秒,否则再好的定时器算法也救不了。这是物理限制,不是代码能解决的。
5. 常见问题与排查技巧实录
5.1 定时器不触发或延迟触发
这是最常见的问题,排查思路我整理成了一张表,按可能性从高到低排。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 完全不触发 | update 没被调用 | 在 update 里打日志确认驱动源正常 |
| 延迟很久才触发 | 时间单位不统一 | 检查 delay 和 deltaTime 是否都是毫秒 |
| 偶尔漏触发 | 单次 update 跨度过大 | 检查是否有卡帧导致 deltaTime 异常大 |
| 循环定时器越跑越慢 | 用 currentTime 累加而非 expireTime | 改用 expireTime + interval |
我印象最深的一次是某个 buff 死活不触发,查了半天发现是创建定时器时传的 delay 是秒(比如 5),但 update 传的 deltaTime 是毫秒(16),结果 5 毫秒就触发了,看起来就像“瞬间消失”。这种单位问题非常隐蔽,建议在add函数入口加个断言,delay 小于 1 的时候打个警告。
5.2 回调里取消自己导致的诡异行为
如果在一个定时器的回调里调用timer:cancel(自己的handle),会发生什么?按照我上面的实现,回调执行时节点已经从堆里弹出了,handleMap里还有它的引用。cancel 会把cancelled置为 true,但此时节点已经不在堆里,这个标记没有任何作用。等回调返回后,如果是循环定时器,它还会被重新入堆,导致“取消了但还在跑”。
解决办法是在循环定时器重新入堆之前,再检查一次cancelled标记:
if top.interval > 0 and top.times ~= 1 and not top.cancelled then -- 重新入堆 end踩坑记录:这个 bug 我在项目里遇到过,表现是“玩家死亡后 buff 还在跳伤害”,查了两天才定位到。回调里取消自己的场景其实很常见,比如“执行完这次就停”的逻辑,一定要处理好。
5.3 大量定时器同时到期的性能抖动
假设你有一个“整点刷新”的需求,10000 个资源点都在同一毫秒到期。这时候 update 会在一次调用里连续弹出 10000 个节点,每个都要执行回调和下沉操作,单帧耗时可能飙到几十毫秒,直接造成卡顿。
我的处理办法是分帧触发:给 update 加一个单帧最大触发数量限制,比如每帧最多处理 200 个,剩下的留到下一帧。代价是部分定时器会延迟一两帧触发,但对于“整点刷新”这种场景,延迟 16 毫秒完全无感。
local MAX_TRIGGER_PER_FRAME = 200 local triggered = 0 while self.size > 0 and triggered < MAX_TRIGGER_PER_FRAME do -- ... 触发逻辑 triggered = triggered + 1 end5.4 内存泄漏的排查与预防
Lua 定时器最常见的内存泄漏是闭包持有外部引用。比如回调里引用了某个大 table,只要定时器没销毁,这个 table 就回收不了。预防办法是在取消定时器时,主动把callback置为 nil:
function Timer:cancel(handle) local node = self.handleMap[handle] if node then node.cancelled = true node.callback = nil -- 断开闭包引用 self.handleMap[handle] = nil end end另外,循环定时器如果忘了设置times上限,会一直跑下去,永远不会被回收。我建议在创建无限循环定时器时,强制要求调用方传一个明确的times = -1,而不是默认值,这样至少能在代码审查时一眼看出来“这里有个无限循环”。
6. 进阶优化:从最小堆到时间轮的演进思路
6.1 什么时候该考虑时间轮
最小堆在定时器数量几千到几万的时候表现很好,但如果你的项目到了十万级甚至百万级,比如大型多人在线场景里每个玩家身上挂十几个 buff,堆的 O(log n) 操作就会开始吃力。这时候时间轮的优势就体现出来了:插入和触发都是 O(1),而且触发时不需要比较,直接按格子取链表就行。
时间轮的核心思路是把时间轴切成 N 个格子,每个格子代表一个时间跨度(比如 100 毫秒)。指针每 100 毫秒走一格,走到哪个格子就把那个格子里的所有定时器触发掉。定时器按expireTime % N散列到对应格子。对于超过一圈的定时器,需要记录圈数,指针走到时圈数减一,减到 0 才真正触发。
6.2 分层时间轮解决长延迟问题
单层时间轮有个问题:如果格子数是 512,每格 100 毫秒,那最多只能表示 51.2 秒的延迟。超过这个时间的定时器怎么办?答案是分层时间轮,类似钟表的时针、分针、秒针。秒轮走一圈,分轮走一格;分轮走一圈,时轮走一格。长延迟的定时器先放在高层轮里,等降到低层轮再精确触发。
分层时间轮的实现复杂度比最小堆高不少,我一般只在确定有性能瓶颈时才上。如果你的项目还没到那个量级,老老实实用最小堆,把精力花在业务逻辑上更划算。
6.3 精度与性能的权衡取舍
最后说个容易被忽视的点:定时器精度不是越高越好。如果你把驱动间隔设成 1 毫秒,CPU 会被频繁唤醒,功耗和性能都会受影响。实际项目里,16 毫秒(约 60 帧)的精度对绝大多数游戏逻辑已经足够,技能冷却差个十几毫秒玩家根本感知不到。真正需要高精度的场景(比如音游判定),应该用专门的音频时钟,而不是通用定时器。
我在实际项目里的做法是:把定时器精度和帧率绑定,帧率 30 就用 33 毫秒,帧率 60 就用 16 毫秒。这样既保证了逻辑的流畅性,又不会浪费性能。如果某个定时器确实需要更高精度,再单独给它开小灶,而不是让所有定时器都陪着一起高频运转。
这套最小堆定时器方案我前后在三个项目里用过,从几千个定时器的小型服务到几万个定时器的中型游戏,表现都很稳定。代码量不大,核心逻辑两百行左右,但覆盖了创建、取消、循环、精度控制、内存回收这些关键环节。你可以直接拿去用,也可以根据自己的场景做裁剪。如果后面真的遇到了性能天花板,再按第 6 节的思路往时间轮演进,路径是清晰的。