倒计时与定时器管理
概述
游戏里到处是时间:回合倒计时、技能冷却、Buff 时长、匹配超时、每日刷新……定时器用得好不好,直接影响服务器性能与玩法准确性。本文讲解游戏中的倒计时实现、HashedWheelTimer 与 ScheduledExecutorService 对比、以及定时任务的取消与清理。
一、游戏中的定时场景
| 场景 | 粒度 | 量级 | 说明 |
|---|---|---|---|
| 回合倒计时 | 秒级 | 每回合 1 个 | 大量、短命 |
| 技能冷却 | 秒级 | 每玩家多个 | 大量、短命 |
| Buff 时长 | 秒级 | 每玩家多个 | 大量、短命 |
| 匹配超时 | 秒级 | 池级少量 | 少量 |
| 定时落盘 | 分钟级 | 全局少量 | 常驻 |
| 每日刷新 | 天级 | 全局一个 | 常驻 |
特征分析:
海量短命定时器(倒计时/冷却)→ 大头
少量长命任务(落盘/刷新)→ 常规
两类场景对定时器的"创建/取消"开销敏感二、HashedWheelTimer
2.1 时间轮原理
HashedWheelTimer(Netty 提供)时间轮:
一个环形槽数组(如 512 槽)
指针按固定 tick 周期(如 10ms)转动
任务按"到期 tick 数"挂到对应槽
指针到槽 → 执行该槽上的任务链表
复杂度:
添加/取消任务 O(1)
无排序开销,海量任务友好为什么适合游戏:
回合/冷却/匹配都是大量短任务
O(1) 的 add/cancel 开销极低
单线程驱动,无需锁(单实例内串行)2.2 使用示例
java
import io.netty.util.HashedWheelTimer;
import io.netty.util.Timeout;
public class GameTimer {
private final HashedWheelTimer timer = new HashedWheelTimer();
// 倒计时:到期执行
public Timeout schedule(Runnable task, long delayMs) {
return timer.newTimeout(t -> task.run(), delayMs, TimeUnit.MILLISECONDS);
}
// 取消任务
public void cancel(Timeout timeout) {
if (timeout != null) {
timeout.cancel();
}
}
}构造参数:
new HashedWheelTimer(
tickDuration, // tick 周期(如 10ms)
unit, // 时间单位
ticksPerWheel) // 槽数(如 512)
精度 = tickDuration;周期 = tick × 槽数
倒计时粒度 10ms 足够2.3 时间轮注意事项
局限:
精度受 tickDuration 限制
任务执行耗时 → 阻塞后续任务(长任务别放这)
不支持动态调度(固定 delay 一次/周期要自己重排)
使用规范:
时间轮任务要快(毫秒级,如发消息/改状态)
耗时操作内部异步化
大量任务用时间轮,少量长任务用 Scheduled三、ScheduledExecutorService
3.1 使用方式
java
public class ScheduledService {
private final ScheduledExecutorService scheduler =
Executors.newScheduledThreadPool(4);
// 延迟执行
public ScheduledFuture<?> schedule(Runnable task, long delayMs) {
return scheduler.schedule(task, delayMs, TimeUnit.MILLISECONDS);
}
// 固定周期(定时落盘等常驻任务)
public ScheduledFuture<?> scheduleAtFixedRate(Runnable task, long periodMs) {
return scheduler.scheduleAtFixedRate(task, periodMs, periodMs, TimeUnit.MILLISECONDS);
}
}优势:
支持固定周期(rate/delay)→ 常驻任务首选
线程池多线程 → 可并行
精确(毫秒级调度)
注意:
每任务一个"时间对象",海量任务开销高
任务并发需自行保证线程安全
周期性任务要防异常中断(try/catch 包裹)3.2 两者对比
| 维度 | HashedWheelTimer | ScheduledExecutorService |
|---|---|---|
| 添加/取消复杂度 | O(1) | 堆排序 O(logN) |
| 海量短任务 | 极适合 | 开销高 |
| 周期任务 | 不支持(自排) | 原生支持 |
| 并发 | 单线程串行 | 多线程并行 |
| 精度 | tick 粒度 | 毫秒级 |
| 适用 | 回合/冷却/匹配 | 落盘/刷新/监控 |
选型结论:
倒计时/冷却/匹配超时 → HashedWheelTimer
定时落盘/每日刷新/监控 → ScheduledExecutorService
两者可并存(各司其职)四、定时任务的取消与清理
4.1 取消时机
必须取消的场景:
玩家操作完成(出牌后取消回合倒计时)
玩家离开(冷却/倒计时不再有意义)
房间解散(房间内全部定时器清理)
状态变更(倒计时被新状态覆盖)
不取消的后果:
任务到期执行"过时操作"
幽灵任务堆积 → 内存泄漏
错误触发广播(房间已解散)4.2 取消模式
java
public class TurnTimerManager {
private final Map<Long, Timeout> turnTimeouts = new ConcurrentHashMap<>();
// 开启回合倒计时(先取消旧的)
public void startTurnTimeout(long playerId, long roomId, long millis) {
cancel(playerId); // 取消旧的,防重复
Timeout timeout = GameTimer.schedule(() -> {
turnTimeouts.remove(playerId);
// 触发时校验:仍在房间、仍轮到他
onTurnTimeout(playerId, roomId);
}, millis);
turnTimeouts.put(playerId, timeout);
}
// 取消并清理
public void cancel(long playerId) {
Timeout timeout = turnTimeouts.remove(playerId);
if (timeout != null) {
timeout.cancel();
}
}
// 房间解散:清理房间内全部
public void clearRoom(long roomId) {
// 遍历该房间玩家逐个 cancel
}
}取消规范:
任务注册时保存 Timeout 引用
用 Map 跟踪"玩家/房间 → 任务"
触发回调里先 remove(防重复执行)
执行前再次校验上下文(还在房间/仍是当前)4.3 防"幽灵回调"
过时回调防护:
回调触发时重新校验:
玩家是否仍在房间
状态是否仍匹配(还是他的回合)
房间是否已解散
校验不过 → 直接返回,不执行
示例:回合倒计时到期,但玩家已出牌
→ 出牌时已 cancel → 不会触发
万一竞态触发 → 校验"仍是当前玩家"后拒绝4.4 定时器池监控
监控指标:
未完成任务数(挂起量)
任务执行耗时(慢任务告警)
取消率(创建 vs 取消平衡)
时间轮溢出(任务堆积告警)
泄漏排查:
玩家离开后任务是否清理(泄漏主因)
房间解散是否清空房间任务
压测后挂起任务应回归基线五、倒计时实现模式
5.1 服务器倒计时权威
服务器倒计时:
服务器记录"到期时间点"(绝对时间)
客户端倒计时仅展示(可校准)
不依赖定时器做精度:
到期时间点 = 创建时间 + 时长
判定是否到期:now >= 到期时间点
即使定时器触发晚,结果仍准确java
public class Cooldown {
private final Map<Integer, Long> ends = new ConcurrentHashMap<>();
// 开始冷却:记录绝对到期时间
public void start(int skillId, long durationMs) {
ends.put(skillId, System.currentTimeMillis() + durationMs);
}
// 是否可用:与绝对时间比较(无需定时器)
public boolean isReady(int skillId) {
Long end = ends.get(skillId);
return end == null || System.currentTimeMillis() >= end;
}
// 剩余时间:供客户端展示
public long remaining(int skillId) {
Long end = ends.get(skillId);
return end == null ? 0 : Math.max(0, end - System.currentTimeMillis());
}
}绝对时间方案的好处:
定时器只用于"到期通知"(广播倒计时结束)
判定逻辑不依赖定时器精度
进程重启后基于持久化时间也能准确判定5.2 广播与同步
倒计时变化广播:
开始/结束才广播(减少流量)
中途展示由客户端本地推算
跨时钟校准:服务器下发 serverTime 差值六、常见问题
| 问题 | 处理 |
|---|---|
| 任务泄漏 | 玩家离开/房间解散清理 |
| 过时回调 | 触发时校验上下文 |
| 精度不足 | 绝对时间判定 |
| 慢任务阻塞 | 时间轮不放耗时任务 |
| 重复倒计时 | 开启前先 cancel |
七、小结
定时器管理的核心是"按场景选工具 + 严格清理":HashedWheelTimer 以 O(1) 的添加/取消成本承载回合倒计时、冷却、匹配超时这类海量短任务,任务体内只做毫秒级快操作;ScheduledExecutorService 的多线程与周期能力留给定时落盘、每日刷新等常驻任务;取消与清理是防泄漏的关键——玩家离开、房间解散必须清空关联任务,回调触发时重新校验上下文防幽灵执行;绝对到期时间方案让判定不依赖定时器精度。定时器虽是配角,管理不好却会让服务器慢性失血。