第 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 析构 → 触发超时回调

五、两套定时器对比

特性TimerQueueTimingWheel
数据结构最小堆分级环形队列
插入复杂度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

七、思考题

  1. Timer::restart()now + interval_ 而不是 when_ + interval_(上次计划时间 + 间隔)。这两种方式在定时器回调执行本身耗时很长时,行为有什么差别?
  2. timerIdSet_ 用于逻辑删除,但僵尸 Timer 仍在堆中占用内存。如果每秒创建 1000 个一次性定时器,这会产生什么问题?
  3. TimingWheel 中 EntryPtr = std::shared_ptr<void>,超时回调是通过析构函数触发的。如果用户忘记在其他地方释放 EntryPtr(比如存在全局 map 里),会发生什么?
  4. 为什么 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 毛刺

改进方案

  1. 懒清理 + 阈值重建:当 timers_.size() 远大于 timerIdSet_.size()(比如 2 倍以上)时,遍历堆重建,丢弃所有 ID 不在集合中的 Timer
  2. 使用 std::set<TimerPtr> 替代 priority_queuestd::set 支持 O(log n) 的任意元素删除,但插入和取堆顶的常数因子比堆大
  3. 定期 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 中存的是 WeakEntryPtrweak_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 发送一次心跳包),应该用 TimerQueuerunEvery(0.1, cb)),而不是 TimingWheel

游戏服务器的经验值:游戏服务器帧率通常是 10-30 FPS(33-100ms 一帧),心跳检测周期一般是 30-120 秒,1 秒的 tick 间隔在精度和性能之间取得了很好的平衡


学习日期:2025-03-18 | 上一课:第07课_Poller多路复用 | 下一课:第09课_网络地址与Socket封装