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

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

写网络框架写到定时器这一层,很多人会卡住。

不是因为定时器概念难,而是工程上的细节太多:怎么和 epoll 配合?怎么高效地增删任务?怎么处理超时刷新?多线程下怎么保证安全?

这篇文章,我会把自己实现的一套基于时间轮的定时器系统完整拆解出来。不讲空洞概念,只讲怎么落地、为什么这样写、踩过哪些坑,新手也能跟着复刻。

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

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

原因很现实,没有多余的废话:

  • 客户端可能已经断网,但 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 核心代码(关键析构函数)

cpp 复制代码
~TimerTask() { 
    if (_canceled == false) _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. 整个过程,我们不需要手动遍历任务、不需要手动调用回调、不需要手动清理任务,代码极简且不易出错。

这一步设计,直接把"执行任务"和"清理任务"两个操作,合并成了一个 clear(),后续你会看到,它能极大简化代码。

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

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

5.1 完整类实现

cpp 复制代码
// 先定义回调函数类型(简化代码)
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 核心数据结构

cpp 复制代码
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<vector>,而非 list:vector 的 clear() 比 list 快,且内存更紧凑,时间轮是"批量插入、批量删除",vector 更适配;

  • _timers 用 weak_ptr:如果用 shared_ptr,会导致任务生命周期被"查找表"和"时间轮"双重持有,即使时间轮清空,任务也不会销毁,造成内存泄漏;weak_ptr 仅用于查找,不影响生命周期;

  • _capacity 默认为60(对应60秒),如果需要更长超时,可增大容量或实现多层时间轮(秒轮+分轮+时轮)。

6.2 添加任务(TimerAdd)

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

cpp 复制代码
// 线程安全接口(外部调用)
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)但逻辑繁琐,我的实现更巧妙。

cpp 复制代码
// 线程安全接口(外部调用)
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)

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

cpp 复制代码
// 线程安全接口(外部调用)
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
cpp 复制代码
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 变为可读时,说明时间到了,我们需要读取超时次数(避免事件堆积),然后驱动指针转动,执行超时任务。

cpp 复制代码
// 读取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 连接建立(添加定时器)

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

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

8.2 收到数据(刷新定时器)

cpp 复制代码
// 连接收到客户端数据,刷新定时器(重置为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 而非 vector?

时间轮的任务操作是"批量插入、批量删除"(每次刷新批量插入,每次超时批量删除),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

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

如果觉得有收获,欢迎点赞收藏,也欢迎在评论区交流你的实现思路和踩坑经历~

相关推荐
大江东去浪淘尽千古风流人物4 小时前
【LingBot-Depth】Masked Depth Modeling for Spatial Perception
人工智能·算法·机器学习·概率论
Ronaldinho Gaúch4 小时前
leetcode279完全平方数
c++·算法·动态规划
Howrun7774 小时前
C++_bind_可调用对象转化器
开发语言·c++·算法
有一个好名字4 小时前
力扣-迷宫中离入口最近的出口
算法·leetcode·职场和发展
乌萨奇也要立志学C++4 小时前
【洛谷】剪枝与优化 剪枝策略实战解析:数的划分与小猫爬山
算法·剪枝
踩坑记录4 小时前
leetcode hot100 226. 翻转二叉树 easy 递归 层序遍历 BFS
算法·leetcode·宽度优先
历程里程碑4 小时前
滑动窗口----滑动窗口最大值
javascript·数据结构·python·算法·排序算法·哈希算法·散列表
2301_822382764 小时前
嵌入式C++实时内核
开发语言·c++·算法
wWYy.4 小时前
malloc底层实现
算法