跳到主要内容
极客日志极客日志面向AI+效率的开发者社区
首页博客我的书AI学习GitHub 精选镜像AI 生图工具UI配色美学关于
搜索内容 / 工具 / 仓库 / 镜像...⌘K搜索
注册
博客列表
C++算法

高性能定时器:时间轮算法的工程实践

基于时间轮的高性能定时器在 C++ 网络服务中的工程实践。通过智能指针管理任务生命周期,利用 timerfd 驱动时间轮转动,结合 EventLoop 保证线程安全。核心优化包括使用 shared_ptr 引用计数实现任务刷新而非删除重加,析构函数自动触发回调,以及批量处理 timerfd 超时事件,有效解决僵尸连接清理问题。

无尘发布于 2026/3/26更新于 2026/9/1171 浏览

从零实现高性能定时器:时间轮算法的工程实践

一、定时器要解决什么问题?

在长连接服务中,有一个绕不开的需求:连接如果长时间没有活动,必须主动断开。

原因很现实:

  • 客户端可能已经断网,但 TCP 层感知不到(比如手机突然没信号、异常退出);
  • 这些'僵尸连接'会一直占用 fd、内存、带宽等服务器资源;
  • 不及时清理,服务器迟早被这些无效连接拖垮。

所以我们需要一个简单直接的机制。

二、为什么选择时间轮?

常见的定时器实现有三种,各有优劣,我们先看一张对比表,一目了然:

方案插入复杂度删除复杂度适用场景
有序链表O(n)O(1)任务数量极少(几百个以内)
最小堆O(log n)O(log n)通用场景,任务量中等
时间轮O(1)O(1)大量短超时任务(网络服务核心场景)

网络服务通常有以下特点:

  1. 连接数量大(几万甚至几十万,高并发场景下更甚);
  2. 超时时间短且相近(比如大部分连接超时都设置为 30 秒);
  3. 频繁刷新(每次收到客户端数据,都要重置该连接的超时时间)。

这种场景下,时间轮的 O(1) 增删复杂度优势被无限放大——不管有多少任务,添加、删除、刷新的耗时都是固定的。

三、时间轮的基本原理(极简版)

一句话概括:一个循环转动的'钟表',每个刻度放对应时间要执行的任务,指针转一格,执行一格的任务。

3.1 想象一个钟表

假设表盘有 60 个格子,指针每秒走一格,每个格子里存放'这一秒要执行的所有任务'。

比如指针现在在第 10 格,我们要添加一个 5 秒后超时的任务,目标位置就是 (10 + 5) % 60 = 15,把任务放到第 15 格即可。

每过一秒,指针前进一格,清空当前格子的所有任务——清空的过程,就是任务超时、执行回调的过程。

3.2 核心操作(3 步搞定)
  • 添加任务:计算目标格子 = (当前指针位置 + 超时时长) % 表盘容量,任务入格;
  • 时间流逝:指针每秒前进一格,清空当前格子(执行所有超时任务);
  • 刷新任务:核心是'延长任务的生命周期',而非删除再添加。

四、核心设计:让析构触发回调(最巧妙的一步)

这是整个定时器实现中,最能简化代码、规避坑点的设计。

4.1 传统思路 vs 我的思路
  • 传统思路:时间到了 → 遍历当前格子的所有任务 → 逐个调用回调函数 → 手动清理任务;
  • 我的思路:时间到了 → 销毁任务对象 → 让任务的析构函数自动执行回调 → 无需手动调用、无需手动清理。
4.2 核心代码(关键析构函数)
~TimerTask() {
    if (!_canceled) _task_cb(); // 析构时,未取消则执行回调
    _release(); // 清理映射表,避免内存泄漏
}
4.3 为什么这样设计?

核心依赖 C++ 的 shared_ptr 引用计数机制,我们可以用 shared_ptr 来自动管理任务的生命周期:

  1. 时间轮的每个格子,用 vector<shared_ptr> 存储任务(shared_ptr 持有任务,控制生命周期);
  2. 当指针走到该格子,我们只需要调用 _wheel[_tick].clear()(清空当前格子);
  3. clear() 会销毁所有 shared_ptr 对象 → 引用计数归零 → TimerTask 对象析构 → 析构函数执行 → 回调函数自动调用;
  4. 整个过程,我们不需要手动遍历任务、不需要手动调用回调、不需要手动清理任务,代码极简且不易出错。

五、TimerTask:定时任务的封装(可直接复用)

首先我们封装定时任务本身,每个任务对应一个长连接,核心是存储任务 ID、超时时长、回调函数,以及控制取消状态。

5.1 完整类实现
// 先定义回调函数类型(简化代码)
using TaskFunc = std::function<void()>;
using ReleaseFunc = std::function<void()>;

class TimerTask {
private:
    uint64_t _id; // 任务 ID(通常是连接 ID,唯一标识)
    uint32_t _timeout; // 超时时长(单位:秒,如 30 秒)
    bool _canceled; // 是否被取消(取消后不执行回调)
    TaskFunc _task_cb; // 超时回调(通常是关闭连接)
    ReleaseFunc _release; // 销毁时的清理回调(清理映射表)
public:
    // 构造函数:初始化任务
    TimerTask(uint64_t id, uint32_t delay, const TaskFunc &cb) : _id(id), _timeout(delay), _task_cb(cb), _canceled(false) {}

    // 析构函数:自动执行回调和清理
    ~TimerTask() {
        if (!_canceled) _task_cb(); // 未取消则执行回调
        _release(); // 执行清理逻辑
    }

    // 取消任务(标记为取消,不影响清理)
    void Cancel() { _canceled = true; }

    // 设置清理回调(由 TimerWheel 绑定)
    void SetRelease(const ReleaseFunc &cb) { _release = cb; }

    // 获取基础超时时长(用于刷新任务)
    uint32_t DelayTime() { return _timeout; }
};
5.2 各成员作用(避坑说明)
成员变量作用典型值/场景
_id唯一标识任务,关联长连接连接 ID(uint64_t,避免重复)
_timeout基础超时时长,刷新时复用30 秒(长连接默认超时时间)
_canceled取消标记,避免已取消任务执行回调默认 false,取消后设为 true
_task_cb超时核心逻辑(如关闭连接)conn->Shutdown()
_release清理 TimerWheel 中的映射表删除_timers 中对应 ID 的记录

注意:_release 必须绑定,否则任务销毁后,TimerWheel 中的 _timers 映射表会残留该任务的弱引用,导致野指针和内存泄漏。

六、TimerWheel:时间轮的完整实现(核心代码)

TimerWheel 是整个定时器系统的主体,负责管理时间轮的转动、任务的增删改查、与 epoll 的配合,以及线程安全控制。

6.1 核心数据结构
class TimerWheel {
private:
    using WeakTask = std::weak_ptr<TimerTask>; // 弱引用,仅用于查找
    using PtrTask = std::shared_ptr<TimerTask>; // 强引用,控制生命周期
    int _tick; // 当前指针位置(刻度)
    int _capacity; // 表盘容量(如 60,对应最大 60 秒超时)
    // 时间轮本体:每个刻度对应一个任务列表
    std::vector<std::vector<PtrTask>> _wheel;
    // 任务快速查找表:ID → 弱引用(不影响生命周期,避免内存泄漏)
    std::unordered_map<uint64_t, WeakTask> _timers;
    EventLoop *_loop; // 绑定的事件循环(与 epoll 配合)
    int _timerfd; // Linux 时钟驱动(替代 sleep/poll)
    std::unique_ptr<Channel> _timer_channel; // epoll 事件封装(监听 timerfd)
};

关键设计说明:

  • _wheel 用 vector,而非 list:vector 的 clear() 比 list 快,且内存更紧凑,时间轮是'批量插入、批量删除',vector 更适配;
  • _timers 用 weak_ptr:如果用 shared_ptr,会导致任务生命周期被'查找表'和'时间轮'双重持有,即使时间轮清空,任务也不会销毁,造成内存泄漏;weak_ptr 仅用于查找,不影响生命周期;
  • _capacity 默认为 60(对应 60 秒),如果需要更长超时,可增大容量或实现多层时间轮(秒轮 + 分轮 + 时轮)。
6.2 添加任务(TimerAdd)

外部调用接口,用于给新连接添加定时器,核心是'创建任务、绑定清理回调、计算刻度、入轮入表'。

// 线程安全接口(外部调用)
void TimerAdd(uint64_t id, uint32_t delay, const TaskFunc &cb) {
    // 投递到 EventLoop 线程执行,保证线程安全
    _loop->RunInLoop([=] { TimerAddInLoop(id, delay, cb); });
}

// 内部实际实现(仅在 EventLoop 线程执行)
void TimerAddInLoop(uint64_t id, uint32_t delay, const TaskFunc &cb) {
    // 1. 创建任务对象(shared_ptr 控制生命周期)
    PtrTask pt(new TimerTask(id, delay, cb));
    // 2. 绑定清理回调:任务销毁时,从_timers 中删除自身
    pt->SetRelease(std::bind(&TimerWheel::RemoveTimer, this, id));
    // 3. 计算目标刻度(避免越界)
    int pos = (_tick + delay) % _capacity;
    // 4. 任务入轮 + 入查找表
    _wheel[pos].push_back(pt);
    _timers[id] = WeakTask(pt);
}

// 清理映射表(由 TimerTask 的析构函数调用)
void RemoveTimer(uint64_t id) {
    _timers.erase(id);
}
6.3 刷新任务(TimerRefresh)——高频核心操作

这是最常用的操作:当长连接收到数据(有活动)时,需要重置超时时间。传统实现是'删除旧任务、添加新任务',复杂度 O(1) 但逻辑繁琐,我的实现更巧妙。

// 线程安全接口(外部调用)
void TimerRefresh(uint64_t id) {
    _loop->RunInLoop([=] { TimerRefreshInLoop(id); });
}

// 内部实际实现(仅在 EventLoop 线程执行)
void TimerRefreshInLoop(uint64_t id) {
    // 1. 查找任务(弱引用转换为强引用)
    auto it = _timers.find(id);
    if (it == _timers.end()) { return; } // 任务不存在(可能已超时销毁)
    PtrTask pt = it->second.lock(); // weak_ptr → shared_ptr(成功则任务还存在)
    if (!pt) { _timers.erase(it); return; }
    // 2. 计算新的目标刻度(复用基础超时时长)
    int delay = pt->DelayTime();
    int pos = (_tick + delay) % _capacity;
    // 3. 关键操作:再放一份到新刻度(引用计数 +1)
    _wheel[pos].push_back(pt);
}

核心原理:

刷新任务,本质是'延长任务的生命周期',而非'删除旧任务、添加新任务':

  • 刷新前:_wheel[旧刻度] 有一个 shared_ptr,任务引用计数 = 1;
  • 刷新后:_wheel[旧刻度] 和 _wheel[新刻度] 各有一个 shared_ptr,引用计数 = 2;
  • 指针走到旧刻度:clear() 销毁旧刻度的 shared_ptr,引用计数 = 1,任务不销毁;
  • 指针走到新刻度:clear() 销毁新刻度的 shared_ptr,引用计数 = 0,任务销毁,回调执行。

这个设计避免了'先删后加'的复杂逻辑,无需查找旧任务在哪个刻度,直接添加新引用即可,效率更高、代码更简洁。

6.4 取消任务(TimerCancel)

当连接主动关闭时,需要取消对应的定时器(避免超时后再次执行关闭回调)。取消的核心是'标记取消',而非'删除任务'。

// 线程安全接口(外部调用)
void TimerCancel(uint64_t id) {
    _loop->RunInLoop([=] { TimerCancelInLoop(id); });
}

// 内部实际实现(仅在 EventLoop 线程执行)
void TimerCancelInLoop(uint64_t id) {
    auto it = _timers.find(id);
    if (it == _timers.end()) { return; }
    PtrTask pt = it->second.lock();
    if (pt) { pt->Cancel(); } // 仅标记为取消,不删除任务
}

为什么不直接删除任务?

因为任务可能存在于时间轮的某个刻度中,查找并删除任务需要遍历刻度,复杂度 O(n),效率低。而'标记取消'是 O(1) 操作,任务会在时间轮流转到对应刻度后,被 clear() 销毁,只是析构时不执行回调而已,清理逻辑仍会执行。

6.5 时间驱动:timerfd + epoll(核心心跳)

时间轮需要一个'心跳'来驱动指针前进,不能用 sleep(会阻塞线程),也不能用 poll(效率低)。Linux 提供的 timerfd 是最佳选择——它可以被 epoll 监听,每隔指定时间变为可读,完美融入 EventLoop 事件循环。

6.5.1 创建 timerfd
static int CreateTimerfd() {
    // 创建 timerfd,CLOCK_MONOTONIC:系统启动后流逝的时间(不受系统时间修改影响)
    int timerfd = timerfd_create(CLOCK_MONOTONIC, 0);
    if (timerfd < 0) {
        // 错误处理(实际工程中需添加日志)
        perror("timerfd_create error");
        exit(EXIT_FAILURE);
    }
    // 设置定时周期:首次 1 秒后触发,后续每 1 秒触发一次
    struct itimerspec itime;
    memset(&itime, 0, sizeof(itime));
    itime.it_value.tv_sec = 1; // 第一次超时:1 秒后
    itime.it_value.tv_nsec = 0;
    itime.it_interval.tv_sec = 1; // 之后每隔 1 秒触发一次(周期)
    itime.it_interval.tv_nsec = 0;
    // 应用定时设置
    timerfd_settime(timerfd, 0, &itime, NULL);
    return timerfd;
}
6.5.2 处理超时事件(驱动指针转动)

当 timerfd 变为可读时,说明时间到了,我们需要读取超时次数(避免事件堆积),然后驱动指针转动,执行超时任务。

// 读取 timerfd 的超时次数(自上次读取后,超时了几次)
int ReadTimefd() {
    uint64_t times;
    ssize_t n = read(_timerfd, &times, sizeof(times));
    if (n != sizeof(times)) {
        // 错误处理(实际工程中需添加日志)
        perror("read timerfd error");
        return 0;
    }
    return static_cast<int>(times);
}

// timerfd 可读事件回调(由 EventLoop 触发)
void OnTime() {
    int times = ReadTimefd(); // 获取累积超时次数
    // 批量处理所有超时(避免 epoll 阻塞导致的时间堆积)
    for (int i = 0; i < times; i++) {
        RunTimerTask();
    }
}

// 驱动指针转动,执行当前刻度的超时任务
void RunTimerTask() {
    // 指针前进一格(循环转动,避免越界)
    _tick = (_tick + 1) % _capacity;
    // 清空当前刻度:销毁所有 shared_ptr → 任务析构 → 执行回调
    _wheel[_tick].clear();
}

注意:必须读取'超时次数'并批量处理。因为 epoll 可能因为处理其他事件(如读写事件)耗时过久,导致 timerfd 超时了多次(比如阻塞了 3 秒,timerfd 就超时了 3 次),如果只处理一次,会导致时间轮指针滞后,任务超时不准确。

七、和 EventLoop 的配合(线程安全保障)

网络框架中,EventLoop 通常是单线程的,而定时器的操作(增/刷/取消)可能来自不同线程:

  • 添加任务:可能在主线程(新连接建立时);
  • 刷新任务:可能在 IO 线程(收到客户端数据时);
  • 取消任务:可能在 IO 线程(连接主动关闭时)。

如果多个线程同时操作 _wheel 和 _timers,会导致数据竞争(崩溃、野指针等问题)。

解决方案:统一在 Loop 线程执行

所有定时器操作,都通过 EventLoop 的 RunInLoop 接口,投递到 EventLoop 单线程执行,天然避免数据竞争,实现线程安全。

这也是为什么我们在 TimerAdd、TimerRefresh、TimerCancel 三个接口中,都加了 _loop->RunInLoop(…) 的封装——外部线程调用时,会把任务投递到 Loop 线程,由 Loop 线程统一执行实际的操作(TimerAddInLoop 等)。

RunInLoop 的实现逻辑很简单(这里不展开):如果当前线程是 Loop 线程,直接执行;如果不是,把任务放到任务队列,由 Loop 线程循环取出执行。

八、完整工作流程(串联所有逻辑)

用一个长连接的完整生命周期,串联整个定时器的工作流程,让你彻底明白每一步的作用:

8.1 连接建立(添加定时器)
// 新连接创建成功后,添加 30 秒超时定时器
timer_wheel->TimerAdd(conn_id, 30, [conn] { conn->Shutdown(); }); // 超时回调:关闭连接

此时:_wheel[(_tick+30)%60] 中添加了该任务的 shared_ptr,_timers 中添加了该任务的 weak_ptr。

8.2 收到数据(刷新定时器)
// 连接收到客户端数据,刷新定时器(重置为 30 秒超时)
timer_wheel->TimerRefresh(conn_id);

此时:任务的引用计数 +1,_wheel[(_tick+30)%60] 中再添加一份该任务的 shared_ptr。

8.3 正常超时(无活动)
  1. 时间流逝,_tick 逐步递增,直到走到任务所在的刻度;
  2. OnTime 被触发,读取超时次数(假设为 1),调用 RunTimerTask();
  3. _tick 前进一格,_wheel[_tick].clear(),销毁该格子的 shared_ptr;
  4. 任务引用计数归零,TimerTask 析构,执行回调 conn->Shutdown(),关闭连接;
  5. 析构函数调用 _release(),从 _timers 中删除该任务的记录。
8.4 有活动(超时延长)
  1. _tick=0:添加任务到 _wheel[30],引用计数=1;
  2. _tick=10:收到数据,刷新任务,添加到 _wheel[40],引用计数=2;
  3. _tick=30:clear() _wheel[30],引用计数=1,任务不销毁;
  4. _tick=40:clear() _wheel[40],引用计数=0,任务销毁,回调执行(若仍无活动)。

九、工程落地细节与避坑总结

9.1 为什么 _capacity 选 60?

60 秒是长连接超时的常用值(大部分场景下,30~60 秒无活动就可以关闭连接),选 60 可以让单轮时间轮覆盖大部分场景。

如果业务需要更长的超时(比如 5 分钟),有两种方案:

  • 简单方案:增大 _capacity(如 300,对应 5 分钟);
  • 优雅方案:实现多层时间轮(秒轮 60 格、分轮 60 格、时轮 24 格),适合超时间隔跨度大的场景。
9.2 为什么用 vector 而非 list?

时间轮的任务操作是'批量插入、批量删除'(每次刷新批量插入,每次超时批量删除),vector 的 clear() 是连续内存操作,比 list 的节点遍历删除快得多;而且 vector 的内存更紧凑,缓存命中率更高,性能更优。

9.3 内存开销(是否可控?)

假设我们有 10 万连接,每个连接一个定时器:

  • _wheel:60 个 vector,共 10 万个 shared_ptr;
  • _timers:10 万个 weak_ptr;
  • 每个智能指针(64 位系统)约 16 字节,总内存约 3.2 MB(10 万×16×2),完全可控。

即使是 100 万连接,总内存也只有 32 MB,对服务器来说几乎可以忽略。

9.4 常见坑点汇总
  • 坑 1:_timers 用 shared_ptr 导致内存泄漏 → 解决方案:用 weak_ptr;
  • 坑 2:忘记绑定 _release 回调 → 解决方案:创建任务时必须 SetRelease,清理 _timers;
  • 坑 3:不处理 timerfd 的累积超时 → 解决方案:读取超时次数,批量执行 RunTimerTask;
  • 坑 4:多线程操作时间轮 → 解决方案:所有操作通过 RunInLoop 投递到 Loop 线程;
  • 坑 5:刷新任务时'先删后加' → 解决方案:直接添加新引用,利用引用计数延长生命周期。

十、总结

这套基于时间轮的定时器,核心优势是'高性能、易落地、线程安全',完美适配长连接网络服务的场景。它的核心设计思想,其实就是把几个简单的技术点(时间轮、智能指针、timerfd、EventLoop)优雅地结合起来,用最少的代码解决工程上的复杂问题。

最后,用一张表总结核心设计点,方便你快速回顾:

设计目标实现方式
高效增删改时间轮 O(1) 复杂度
自动执行回调TimerTask 析构函数触发
刷新超时增加 shared_ptr 引用计数
取消任务标记 _canceled,不删除任务
线程安全所有操作统一在 Loop 线程执行
时间驱动timerfd + epoll 融入 EventLoop

时间轮不是一个新概念,但工程落地的细节,只有实际写过、踩过坑,才能真正掌握。希望这篇文章能帮你跳过定时器的那些坑,顺利把网络框架的定时器层落地。

目录

  1. 从零实现高性能定时器:时间轮算法的工程实践
  2. 一、定时器要解决什么问题?
  3. 二、为什么选择时间轮?
  4. 三、时间轮的基本原理(极简版)
  5. 3.1 想象一个钟表
  6. 3.2 核心操作(3 步搞定)
  7. 四、核心设计:让析构触发回调(最巧妙的一步)
  8. 4.1 传统思路 vs 我的思路
  9. 4.2 核心代码(关键析构函数)
  10. 4.3 为什么这样设计?
  11. 五、TimerTask:定时任务的封装(可直接复用)
  12. 5.1 完整类实现
  13. 5.2 各成员作用(避坑说明)
  14. 六、TimerWheel:时间轮的完整实现(核心代码)
  15. 6.1 核心数据结构
  16. 6.2 添加任务(TimerAdd)
  17. 6.3 刷新任务(TimerRefresh)——高频核心操作
  18. 6.4 取消任务(TimerCancel)
  19. 6.5 时间驱动:timerfd + epoll(核心心跳)
  20. 6.5.1 创建 timerfd
  21. 6.5.2 处理超时事件(驱动指针转动)
  22. 七、和 EventLoop 的配合(线程安全保障)
  23. 解决方案:统一在 Loop 线程执行
  24. 八、完整工作流程(串联所有逻辑)
  25. 8.1 连接建立(添加定时器)
  26. 8.2 收到数据(刷新定时器)
  27. 8.3 正常超时(无活动)
  28. 8.4 有活动(超时延长)
  29. 九、工程落地细节与避坑总结
  30. 9.1 为什么 _capacity 选 60?
  31. 9.2 为什么用 vector 而非 list?
  32. 9.3 内存开销(是否可控?)
  33. 9.4 常见坑点汇总
  34. 十、总结

更多推荐文章

查看全部
  • 程序员为何需要了解 GPT 并掌握 AI 大模型构建技术
  • 自然科学领域机器学习与深度学习:从数据预处理到时空建模
  • Java 面向对象核心:继承、多态与抽象类接口
  • 基于 Python+Vue 的养老院服务预订管理系统设计与实现
  • Python 中 == 与 is 的区别解析及 AI 编程提示词优化
  • SpringBoot 联合 PostGIS 实现在线地图行政区划离线存储
  • AIGC 插画生成技术解析与 Stable Diffusion 实战
  • 本地 AI 绘图实战:Z-Image-Turbo_UI 界面体验与性能测试
  • 前端 CI/CD 流程与自动化部署实践
  • 搜索旋转排序数组:二分查找 C++ 解法
  • Java 并发核心:单例、生产者消费者、定时器与线程池实战
  • 基于 Java 和 Leaflet 的湖南省道路长度 WebGIS 系统实现
  • Flutter serial 库在鸿蒙系统的适配指南与串口通信实战
  • Python AI 入门与学习路线指南
  • 让小爱音箱 Pro 接入豆包 AI 实现智能交互
  • IntelliJ IDEA 创建 Spring Boot 项目完整指南
  • π0 机器人控制模型拆解:VLM、动作专家与 Flow Matching
  • 预训练语言模型与 BERT 实战应用
  • 网络安全入门常见误区与学习路径指南
  • Git 核心概念解析:从版本控制到团队协作实战

相关免费在线工具

  • 加密/解密文本

    使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online

  • Gemini 图片去水印

    基于开源反向 Alpha 混合算法去除 Gemini/Nano Banana 图片水印,支持批量处理与下载。 在线工具,Gemini 图片去水印在线工具,online

  • Base64 字符串编码/解码

    将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online

  • Base64 文件转换器

    将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online

  • Markdown转HTML

    将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online

  • HTML转Markdown

    将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online