第 8 课:定时器系统(Timer + TimerQueue + TimingWheel)#
对应源文件:
trantor/net/inner/Timer.h/.cc — 单个定时器对象trantor/net/inner/TimerQueue.h/.cc — 定时器优先队列(最小堆)trantor/utils/TimingWheel.h/.cc — 分级时间轮(高并发连接超时)
一、三个类的分工#
1
2
3
4
5
6
| Timer — 单个定时器的数据:到期时间、回调、是否重复
│
TimerQueue — 管理所有定时器,最小堆,驱动到期回调
│
TimingWheel — 用于大量连接的超时检测,O(1) 插入/删除
基于 TimerQueue 的 runEvery 驱动
|
两套定时器,用途不同:
TimerQueue:精确定时,适合少量定时任务(heartbeat 广播、延迟关闭等)TimingWheel:粗粒度超时,适合万级连接的空闲超时检测
二、Timer:单个定时器#
2.1 数据成员#
1
2
3
4
5
6
| TimerCallback callback_; // 到期执行的函数
TimePoint when_; // 到期时间(steady_clock,单调时钟)
TimeInterval interval_; // 重复间隔(microseconds,为 0 表示一次性)
bool repeat_; // = (interval_.count() > 0)
TimerId id_; // 全局唯一 ID(原子递增)
static std::atomic<TimerId> timersCreated_; // 全局计数器
|
2.2 ID 生成#
1
2
3
4
5
| // Timer.cc 第 21 行
std::atomic<TimerId> Timer::timersCreated_ = ATOMIC_VAR_INIT(InvalidTimerId); // 初始值 0
// 构造时(Timer.cc 第 29 行)
id_(++timersCreated_) // 原子前自增,从 1 开始
|
每个 Timer 对象构造时自动分配唯一 ID,多线程安全,不需要锁。InvalidTimerId = 0 作为哨兵值。
2.3 restart():重复定时器重置#
1
2
3
4
5
6
7
8
| // Timer.cc 第 47-54 行
void Timer::restart(const TimePoint &now)
{
if (repeat_)
when_ = now + interval_; // 从当前时刻往后推 interval_
else
when_ = std::chrono::steady_clock::now(); // 一次性定时器:设为当前(不会再触发)
}
|
重复定时器的下次到期时间是基于本次实际触发时刻(now),而不是上次计划时刻加间隔。这样即使某次回调延迟了,也不会引起"追赶效应"(连续触发补偿)。
2.4 比较运算符#
1
2
| bool Timer::operator<(const Timer &t) const { return when_ < t.when_; }
bool Timer::operator>(const Timer &t) const { return when_ > t.when_; }
|
供最小堆(priority_queue)排序用:when_ 最小(最早到期)的排在堆顶。
三、TimerQueue:最小堆 + timerfd#
3.1 数据结构#
1
2
3
4
5
6
7
| // 最小堆:堆顶 = 最早到期的 Timer
std::priority_queue<TimerPtr,
std::vector<TimerPtr>,
TimerPtrComparer> timers_;
// 有效 ID 集合:用于 invalidateTimer(逻辑删除)
std::unordered_set<uint64_t> timerIdSet_;
|
TimerPtrComparer:
1
2
3
4
5
| struct TimerPtrComparer {
bool operator()(const TimerPtr &x, const TimerPtr &y) const {
return *x > *y; // 大的在后,小的(早到期)在堆顶
}
};
|
priority_queue 默认是最大堆(堆顶最大),用 > 比较器变成最小堆(堆顶最小)。
3.2 Linux 路径:timerfd 驱动#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| timerfd = timerfd_create(CLOCK_MONOTONIC)
│
↓
包装成 Channel → 注册到 EventLoop(Poller 监听)
│
↓
timerfd_settime(timerfd, ..., 最早到期时间)
│
[内核定时到期]
↓
timerfd 可读 → Channel::handleEvent → TimerQueue::handleRead()
│
↓
取出所有已到期的 Timer → run() → reset(重复的重新入队)
│
↓
timerfd_settime 设置下一个最早到期时间
|
timerfd 的优势:
- 定时器变成一个普通的可读 fd,统一纳入 epoll 管理
- 不需要专门的定时器线程
CLOCK_MONOTONIC:单调时钟,不受系统时间跳变影响
1
2
3
4
5
6
7
8
9
| // TimerQueue.cc 第 54-66 行
static void resetTimerfd(int timerfd, const TimePoint &expiration)
{
struct itimerspec newValue;
memset(&newValue, 0, sizeof(newValue));
newValue.it_value = howMuchTimeFromNow(expiration); // 距现在多久
// it_interval = 0:不重复(TimerQueue 手动管理重复逻辑)
timerfd_settime(timerfd, 0, &newValue, &oldValue);
}
|
每次只设置最近一个到期时间,到期后 handleRead 里批量取出所有已到期 Timer,再设置下一个。
3.3 非 Linux 路径:轮询方式#
非 Linux 系统没有 timerfd,改用:
1
2
3
| // EventLoop.cc(非 Linux)
poller_->poll(timerQueue_->getTimeout(), &activeChannels_);
timerQueue_->processTimers(); // poll 返回后立即处理到期定时器
|
getTimeout() 返回最近一个定时器还剩多少毫秒,作为 poll 的超时参数。到期时间到了,poll 自然超时返回,再处理定时器。
3.4 addTimer:安全跨线程添加#
1
2
3
4
5
6
7
8
9
| // TimerQueue.cc 第 186-204 行
TimerId TimerQueue::addTimer(TimerCallback &&cb, const TimePoint &when,
const TimeInterval &interval)
{
auto timerPtr = std::make_shared<Timer>(std::move(cb), when, interval);
// 通过 runInLoop 保证在 Loop 线程操作 timers_(最小堆不是线程安全的)
loop_->runInLoop([this, timerPtr]() { addTimerInLoop(timerPtr); });
return timerPtr->id(); // 立即返回 ID(不等 Loop 线程执行)
}
|
timerPtr 被 lambda 捕获(引用计数 +1),保证在 Loop 线程执行前 Timer 对象不被销毁。
3.5 invalidateTimer:逻辑删除#
最小堆不支持随机删除(O(n) 才能找到),trantor 用逻辑删除:
1
2
3
4
| void TimerQueue::invalidateTimer(TimerId id)
{
loop_->runInLoop([this, id]() { timerIdSet_.erase(id); });
}
|
Timer 还留在堆里,但执行时检查 timerIdSet_:
1
2
3
4
5
6
7
| // handleRead/processTimers 中
for (auto const &timerPtr : expired) {
if (timerIdSet_.find(timerPtr->id()) != timerIdSet_.end()) {
timerPtr->run(); // ID 还在集合里才执行
}
// ID 不在集合里 → 已被 invalidate,跳过
}
|
O(1) 取消:向 timerIdSet_ erase 一个 ID,比从堆中删除快得多。代价是堆里留有"僵尸" Timer,但它们最终会自然到期并被 getExpired 弹出。
3.6 getExpired + reset:到期处理完整流程#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| // 弹出所有到期 Timer
std::vector<TimerPtr> TimerQueue::getExpired(const TimePoint &now)
{
std::vector<TimerPtr> expired;
while (!timers_.empty() && timers_.top()->when() < now) {
expired.push_back(timers_.top());
timers_.pop();
}
return expired;
}
// 处理完后:重复的重新入队,一次性的移除 ID
void TimerQueue::reset(const std::vector<TimerPtr> &expired, const TimePoint &now)
{
for (auto const &timerPtr : expired) {
if (timerIdSet_.find(timerPtr->id()) != timerIdSet_.end()) {
if (timerPtr->isRepeat()) {
timerPtr->restart(now); // 重新计算下次到期时间
insert(timerPtr); // 重新入堆
} else {
timerIdSet_.erase(timerPtr->id()); // 一次性:清除 ID
}
}
}
// 重新设置 timerfd 为下一个最早到期时间
}
|
四、TimingWheel:分级时间轮#
4.1 为什么需要时间轮?#
游戏服务器可能有 10 万级别在线连接,每个连接需要检测空闲超时(如 60 秒无数据则断开)。
用 TimerQueue 给每个连接单独设置定时器:
- 堆里有 10 万个 Timer
getExpired 每次 O(n) 遍历- 大量到期时集中处理,延迟毛刺
时间轮:O(1) 插入/删除,O(1) 处理到期,专为高并发连接超时设计。
4.2 时间轮的核心思想#
1
2
3
4
5
6
7
8
9
10
| 类比时钟表盘:
秒针走一圈(60格),分针走一格
分针走一圈(60格),时针走一格
TimingWheel 的结构:
每个 wheel 有 100 个 bucket(格)
每秒 tick 一次,秒轮转动一格
秒轮转满100格,分轮转动一格
分轮转满100格,时轮转动一格
...
|
构造参数:
1
2
3
4
5
6
7
| // TimingWheel.h 第 30-31 行
#define TIMING_BUCKET_NUM_PER_WHEEL 100 // 默认每轮 100 格
#define TIMING_TICK_INTERVAL 1.0 // 默认 1 秒一 tick
TimingWheel(loop, maxTimeout=600, // 最大超时 600 秒
ticksInterval=1.0, // 每格 1 秒
bucketsNumPerWheel=100); // 每轮 100 格
|
轮数自动计算(TimingWheel.cc 第 30-37 行):
1
2
3
4
5
6
7
8
| size_t maxTickNum = maxTimeout / ticksInterval; // 600
auto ticksNum = bucketsNumPerWheel; // 100
wheelsNum_ = 1;
while (maxTickNum > ticksNum) {
++wheelsNum_;
ticksNum *= bucketsNumPerWheel_;
}
// 600 > 100 → wheelsNum_=2(100*100=10000 >= 600)
|
maxTimeout=600 秒,每格 1 秒,100 格一轮,需要 2 轮(100^2=10000 秒容量)。
4.3 数据结构#
1
2
3
4
5
6
7
| // 分级轮:wheels_[0] 是秒轮,wheels_[1] 是分轮,...
std::vector<BucketQueue> wheels_;
// BucketQueue = std::deque<EntryBucket>
// EntryBucket = std::unordered_set<EntryPtr>
// EntryPtr = std::shared_ptr<void>
std::atomic<size_t> ticksCounter_; // 总 tick 计数
|
内存结构示意(2轮,每轮3格简化版):
1
2
3
4
5
6
7
| wheels_[0] (秒轮,deque):
[bucket0: {entryA, entryB}] [bucket1: {}] [bucket2: {entryC}]
↑ front(即将到期)
wheels_[1] (分轮,deque):
[bucket0: {wrapper1}] [bucket1: {}] [bucket2: {}]
↑ front
|
4.4 Tick 驱动:runEvery 定时转动#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| // TimingWheel.cc 第 43-62 行
timerId_ = loop_->runEvery(ticksInterval_, [this]() {
++ticksCounter_;
size_t t = ticksCounter_;
size_t pow = 1;
for (size_t i = 0; i < wheelsNum_; ++i) {
if ((t % pow) == 0) { // 每 pow 个 tick,第 i 轮转一格
EntryBucket tmp;
wheels_[i].front().swap(tmp); // 取出最前面的 bucket
wheels_[i].pop_front();
wheels_[i].push_back(EntryBucket()); // 后面补一个空 bucket
// tmp 在这里析构 → 触发 EntryBucket 里所有 EntryPtr 的析构
}
pow = pow * bucketsNumPerWheel_;
}
});
|
关键:wheels_[i].front().swap(tmp) 把 bucket 的内容移走,tmp 在 for 循环结束时析构,里面所有 EntryPtr 引用计数归零,对象析构 → 析构函数就是超时回调!
4.5 CallbackEntry:RAII 触发超时回调#
1
2
3
4
5
6
7
8
9
10
| // TimingWheel.h 第 48-61 行
class CallbackEntry {
public:
CallbackEntry(std::function<void()> cb) : cb_(std::move(cb)) {}
~CallbackEntry() {
cb_(); // 析构时执行回调(超时动作)
}
private:
std::function<void()> cb_;
};
|
精妙设计:不需要主动"触发"回调,只需丢弃 EntryPtr(引用计数归零),析构函数自动执行超时逻辑(如关闭连接)。
4.6 连接超时检测的使用方式#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // TcpServer 初始化时
auto timingWheel = std::make_shared<TimingWheel>(loop, 60); // 60 秒超时
// 连接建立时
auto entry = std::make_shared<TimingWheel::CallbackEntry>([conn]() {
LOG_INFO << "连接超时,关闭: " << conn->peerAddr().toIpPort();
conn->forceClose();
});
timingWheel->insertEntry(60, entry); // 60 秒后析构 → 关闭连接
// 收到数据时(刷新超时)
auto newEntry = std::make_shared<TimingWheel::CallbackEntry>([conn]() {
conn->forceClose();
});
// 用新 entry 替换旧 entry
conn->setContext(newEntry); // 旧 entry 引用计数降为 0 → 析构(但不会触发回调?)
timingWheel->insertEntry(60, newEntry); // 重新插入
|
实际上 trantor 的 TcpServer 用的是 keepAlive 机制(把 Entry 存在连接的 context 里,每次收到数据更新),Entry 的引用计数由时间轮 bucket 持有:
1
2
3
4
5
6
7
8
| entry 被两处持有:
1. wheels_[i][bucket] 里
2. conn->setContext() 里
收到数据时:
创建新 entry,conn->setContext(newEntry) → 旧 context(旧 entry)引用计数 -1
新 entry 插入时间轮 → 新 entry 被时间轮持有
旧 entry 引用计数 = 0(只剩时间轮那份) → 等时间轮自然清出 bucket 才触发析构
|
4.7 insertEntryInloop:多级放置算法#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| // TimingWheel.cc 第 93-125 行
void TimingWheel::insertEntryInloop(size_t delay, EntryPtr entryPtr)
{
delay = static_cast<size_t>(delay / ticksInterval_ + 1); // 转换为 tick 数
for (size_t i = 0; i < wheelsNum_; ++i) {
if (delay <= bucketsNumPerWheel_) {
// 放在第 i 轮的 delay-1 格
wheels_[i][delay - 1].insert(entryPtr);
break;
}
if (i < wheelsNum_ - 1) {
// 超出当前轮范围,用 CallbackEntry 包装:
// 当外层 bucket 到期时,把 entryPtr 重新插入内层轮
entryPtr = std::make_shared<CallbackEntry>(
[this, delay, i, t, entryPtr]() {
wheels_[i][(delay + ...) % bucketsNumPerWheel_].insert(entryPtr);
});
}
delay = delay / bucketsNumPerWheel_; // 缩小 delay,进入外层轮
}
}
|
类比时钟:设置 125 秒后超时(每格 1 秒,100 格一轮):
- 125 > 100,不能直接放秒轮
- 转换:125 / 100 = 1(分轮第 1 格),余 25(秒轮第 25 格)
- 在分轮第 1 格放一个"wrapper entry"
- 1 分钟后 wrapper entry 析构 → 把真实 entry 插入秒轮第 25 格
- 再过 25 秒,真实 entry 析构 → 触发超时回调
五、两套定时器对比#
| 特性 | TimerQueue | TimingWheel |
|---|
| 数据结构 | 最小堆 | 分级环形队列 |
| 插入复杂度 | O(log n) | O(1) |
| 删除复杂度 | O(1) 逻辑删除 | O(1)(替换 EntryPtr) |
| 到期处理 | O(k),k=到期数 | O(k) |
| 精度 | 微秒级 | ticksInterval 精度(默认 1 秒) |
| 适用场景 | 少量精确定时 | 万级连接超时检测 |
| 超时回调触发 | 主动调用 | RAII 析构自动触发 |
六、完整定时流程(Linux timerfd)#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| EventLoop::runAfter(5.0, cb)
│
▼
TimerQueue::addTimer(cb, now+5s, 0)
→ new Timer(cb, when, 0)
→ loop_->runInLoop( addTimerInLoop )
│
▼
addTimerInLoop:
timerIdSet_.insert(timer->id())
insert(timer) → timers_.push(timer)
如果是最早到期:resetTimerfd(timerfd_, timer->when())
→ timerfd_settime 通知内核 5 秒后唤醒
│
[5 秒后]
▼
timerfd 可读 → Channel::handleEvent → TimerQueue::handleRead
readTimerfd(timerfd_) // 清空计数
expired = getExpired(now) // 弹出所有到期 Timer
for timer in expired:
if timer->id() in timerIdSet_:
timer->run() // 执行回调
reset(expired, now) // 重复的重新入堆,一次性的删 ID
resetTimerfd(下一个到期时间)
|
核心收获#
TimerQueue 最小堆:到期时间最早的排堆顶,Linux 用 timerfd 接入 epoll,定时精度高TimerID = (Timer*, seq):seq 序列号防同地址复用导致误删已注销的定时器TimingWheel O(1) 插入/删除:shared_ptr<Entry> 放入槽位,引用计数归零 = 超时触发extendLife() = 把 Entry 的 shared_ptr 移到最新槽位(延长超时),游戏心跳包处理的标准用法- 选型建议:精确定时用
runAfter(TimerQueue),海量连接心跳超时用 TimingWheel
七、思考题#
Timer::restart() 用 now + interval_ 而不是 when_ + interval_(上次计划时间 + 间隔)。这两种方式在定时器回调执行本身耗时很长时,行为有什么差别?timerIdSet_ 用于逻辑删除,但僵尸 Timer 仍在堆中占用内存。如果每秒创建 1000 个一次性定时器,这会产生什么问题?- TimingWheel 中
EntryPtr = std::shared_ptr<void>,超时回调是通过析构函数触发的。如果用户忘记在其他地方释放 EntryPtr(比如存在全局 map 里),会发生什么? - 为什么 TimingWheel 的 ticksInterval 默认是 1 秒,而不是更精细的 100 毫秒?提高精度有什么代价?
八、思考题参考答案#
1. now + interval_ vs when_ + interval_ 在回调耗时长时的差别#
源码位置:Timer.cc 第 47-55 行
1
2
3
4
5
6
7
| void Timer::restart(const TimePoint &now)
{
if (repeat_)
when_ = now + interval_; // 当前实际触发时刻 + 间隔
else
when_ = std::chrono::steady_clock::now();
}
|
这里的 now 参数是 TimerQueue::handleRead() 或 processTimers() 中取到的当前时刻,而 when_ 是上次计划到期时间。两种策略的差异在于回调本身耗时较长(假设 interval = 100ms,回调执行耗时 80ms)时:
方案 A(trantor 采用的):now + interval_
1
2
3
4
5
| t=0 计划到期
t=5 实际被 handleRead 取出并执行(now=5)
t=85 回调执行完毕
restart → when_ = 5 + 100 = 105
t=105 下次触发
|
两次回调之间的实际间隔 = 100ms(从上次触发时刻算起),但绝对时间轴上两次触发分别在 t=5 和 t=105,间隔稳定。如果回调耗时波动(有时 10ms 有时 80ms),实际触发间隔始终是 interval,不会追赶。
方案 B(假设用 when_ + interval_)
1
2
3
4
5
| t=0 计划到期(when_=0)
t=5 实际被取出并执行
t=85 回调执行完毕
restart → when_ = 0 + 100 = 100
t=100 下次触发(距上次实际触发仅 95ms)
|
如果回调耗时更极端(比如 120ms > interval 100ms):
1
2
3
4
5
6
7
| t=0 计划到期(when_=0)
t=5 实际被取出并执行
t=125 回调执行完毕
restart → when_ = 0 + 100 = 100 < now=125
→ 已经过期!立刻又被 getExpired 弹出 → 再次执行回调
restart → when_ = 100 + 100 = 200 < now≈245
→ 再次过期...连续触发,产生"追赶效应"(catching-up)
|
总结:
now + interval_:保证每次触发后至少等 interval 时长才触发下一次,不追赶,适合心跳、统计等场景when_ + interval_:试图维持绝对时间线上的均匀间隔,如果回调耗时超过 interval 就会连续补偿触发,适合需要精确对齐时间轴的场景(如音视频同步),但 trantor 作为网络库不需要这种语义
2. 大量一次性定时器导致的僵尸堆积问题#
源码关键点:TimerQueue.cc 第 219-222 行的 invalidateTimer 和第 252-266 行的 getExpired
每秒创建 1000 个一次性定时器的场景分析:
堆膨胀问题:
- 假设定时器超时时间分布在 1-60 秒之间,任意时刻堆中有约 60000 个 Timer
- 如果用户通过
invalidateTimer 取消了部分定时器,被取消的 Timer 仍然留在堆中(timerIdSet_ 里 erase 了 ID,但堆里的 shared_ptr<Timer> 没有移除) - 最坏情况:所有 Timer 都被取消后又新建,堆中的僵尸数量持续增长
内存消耗:
- 每个
shared_ptr<Timer> 占约 16 字节(指针 + 控制块指针) - 每个 Timer 对象本身约 64-80 字节(callback + when + interval + id 等)
- 控制块约 32 字节
- 每个僵尸 Timer 总共约 128 字节
- 10 万个僵尸 = 约 12.5 MB,对服务器来说不算致命但也不容忽视
性能问题:
getExpired 每次从堆顶弹出到期 Timer,复杂度 O(k * log n),k 是到期数,n 是堆大小- 僵尸 Timer 到期后被弹出,执行时检查
timerIdSet_ 发现 ID 不在 → 跳过执行,但弹出操作本身(堆的下沉调整)仍然要做 - 如果僵尸大量集中在同一时间点到期,会产生一次性的 CPU 毛刺
改进方案:
- 懒清理 + 阈值重建:当
timers_.size() 远大于 timerIdSet_.size()(比如 2 倍以上)时,遍历堆重建,丢弃所有 ID 不在集合中的 Timer - 使用
std::set<TimerPtr> 替代 priority_queue:std::set 支持 O(log n) 的任意元素删除,但插入和取堆顶的常数因子比堆大 - 定期 GC:每隔一段时间在 idle 时清理堆中的无效 Timer
trantor 选择当前方案是因为在大多数场景下,定时器数量有限(千级),逻辑删除的简洁性和 O(1) 取消的优势远大于僵尸堆积的代价。
3. 忘记释放 EntryPtr 导致超时回调永不触发#
源码关键点:TimingWheel.h 第 48-61 行的 CallbackEntry 析构函数
1
| ~CallbackEntry() { cb_(); } // 析构 = 超时回调
|
TimingWheel 的超时机制完全依赖 shared_ptr 的引用计数归零触发析构。如果用户在其他地方(如全局 map、成员变量)额外持有了 EntryPtr:
直接后果:
1
2
3
4
5
6
7
8
9
10
11
| std::map<int, EntryPtr> globalMap; // 全局 map 持有一份
auto entry = std::make_shared<CallbackEntry>([conn]() {
conn->forceClose();
});
timingWheel->insertEntry(60, entry);
globalMap[connId] = entry; // 引用计数 = 3(entry + 时间轮 bucket + globalMap)
// 60 秒后,时间轮 bucket 清空
// entry 引用计数从 3 降到 2(时间轮释放)
// 但 globalMap 和局部变量 entry 还持有 → 引用计数 ≠ 0 → 不析构 → 超时回调永不触发
|
后果:
- 该连接永远不会因超时被关闭,变成"僵尸连接"
- 如果用户不断往 globalMap 里添加 entry 而不清理,还会造成内存泄漏
- 这是一种静默错误,没有任何日志告警,排查困难
正确做法:
- 只在时间轮 bucket 中持有
shared_ptr,其他地方用 weak_ptr - 或者像 trantor 的 TcpConnection 那样:在连接的 context 中存的是
WeakEntryPtr(weak_ptr<void>),每次收到数据时创建新的 entry 并插入时间轮,旧 entry 随时间轮自然清出 - 如果确实需要在外部取消超时,应该通过
weak_ptr::lock() 来检查 entry 是否仍然有效
4. ticksInterval 默认 1 秒而非 100 毫秒的原因#
源码关键点:TimingWheel.cc 第 43 行
1
2
3
4
| timerId_ = loop_->runEvery(ticksInterval_, [this]() {
++ticksCounter_;
// ... 遍历所有轮,swap bucket ...
});
|
精度 vs 代价的权衡:
ticksInterval 决定了时间轮的最小精度和 tick 回调的触发频率。将其从 1 秒降到 100 毫秒意味着:
CPU 开销增加 10 倍:
- tick 回调每秒从执行 1 次变成 10 次
- 每次 tick 需要检查是否需要转动各级轮(虽然操作本身是 O(1),但函数调用、缓存失效、
shared_ptr 引用计数操作等常数开销不可忽略) - 10 万连接的服务器上,每次 tick 可能要析构数百个 entry(如果大量连接同时超时)
内存占用增加:
- 同样的 maxTimeout=600 秒,1 秒精度需要 600 个 bucket(2 轮,100+100=200 个 bucket)
- 100ms 精度需要 6000 个 tick → 需要 2 轮(100+100),但每个 bucket 的粒度更细,散列更均匀,bucket 数量不变
- 但如果要用更多的 bucket 来避免 hash 冲突,内存会增加
上下文切换增加:
runEvery(0.1, ...) 在 Linux 上通过 timerfd 每 100ms 唤醒一次 EventLoop- 如果 EventLoop 同时处理大量 I/O 事件,频繁的定时器唤醒会增加 epoll_wait 的返回次数
- 在低负载时,EventLoop 本可以 sleep 1 秒,现在被迫每 100ms 醒一次,功耗增加
实际场景不需要高精度:
- TimingWheel 的典型用途是连接空闲超时检测(如 60 秒无数据则断开)
- 对于这种场景,超时时间本身就是"模糊"的(60 秒 vs 61 秒无本质区别)
- 1 秒精度意味着最大误差 1 秒,对于空闲超时完全可以接受
- 如果需要精确到毫秒级的定时(如每 100ms 发送一次心跳包),应该用
TimerQueue(runEvery(0.1, cb)),而不是 TimingWheel
游戏服务器的经验值:游戏服务器帧率通常是 10-30 FPS(33-100ms 一帧),心跳检测周期一般是 30-120 秒,1 秒的 tick 间隔在精度和性能之间取得了很好的平衡
学习日期:2025-03-18 | 上一课:第07课_Poller多路复用 | 下一课:第09课_网络地址与Socket封装