从零实现高性能定时器:时间轮算法的工程实践
写网络框架写到定时器这一层,很多人会卡住。
不是因为定时器概念难,而是工程上的细节太多:怎么和 epoll 配合?怎么高效地增删任务?怎么处理超时刷新?多线程下怎么保证安全?
这篇文章,我会把自己实现的一套基于时间轮的定时器系统完整拆解出来。不讲空洞概念,只讲怎么落地、为什么这样写、踩过哪些坑,新手也能跟着复刻。
一、定时器要解决什么问题?
在长连接服务中,有一个绕不开的需求:连接如果长时间没有活动,必须主动断开。
原因很现实,没有多余的废话:
-
客户端可能已经断网,但 TCP 层感知不到(比如手机突然没信号、异常退出);
-
这些"僵尸连接"会一直占用 fd、内存、带宽等服务器资源;
-
不及时清理,服务器迟早被这些无效连接拖垮。
所以我们需要一个简单直接的机制:
给每个连接设置一个"倒计时" → 有活动就重置倒计时 → 倒计时归零就执行回调(通常是关闭连接)
这就是定时器在长连接服务中的核心应用场景,没有之一。
二、为什么选择时间轮?
常见的定时器实现有三种,各有优劣,我们先看一张对比表,一目了然:
| 方案 | 插入复杂度 | 删除复杂度 | 适用场景 |
|---|---|---|---|
| 有序链表 | O(n) | O(1) | 任务数量极少(几百个以内) |
| 最小堆 | O(log n) | O(log n) | 通用场景,任务量中等 |
| 时间轮 | O(1) | O(1) | 大量短超时任务(网络服务核心场景) |
再回到我们的网络服务,它有三个典型特点:
-
连接数量大(几万甚至几十万,高并发场景下更甚);
-
超时时间短且相近(比如大部分连接超时都设置为 30 秒);
-
频繁刷新(每次收到客户端数据,都要重置该连接的超时时间)。
这种场景下,时间轮的 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 来自动管理任务的生命周期:
-
时间轮的每个格子,用 vector<shared_ptr> 存储任务(shared_ptr 持有任务,控制生命周期);
-
当指针走到该格子,我们只需要调用 _wheel[_tick].clear()(清空当前格子);
-
clear() 会销毁所有 shared_ptr 对象 → 引用计数归零 → TimerTask 对象析构 → 析构函数执行 → 回调函数自动调用;
-
整个过程,我们不需要手动遍历任务、不需要手动调用回调、不需要手动清理任务,代码极简且不易出错。
这一步设计,直接把"执行任务"和"清理任务"两个操作,合并成了一个 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, ×, 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 正常超时(无活动)
-
时间流逝,_tick 逐步递增,直到走到任务所在的刻度;
-
OnTime 被触发,读取超时次数(假设为1),调用 RunTimerTask();
-
_tick 前进一格,_wheel[_tick].clear(),销毁该格子的 shared_ptr;
-
任务引用计数归零,TimerTask 析构,执行回调 conn->Shutdown(),关闭连接;
-
析构函数调用 _release(),从 _timers 中删除该任务的记录。
8.4 有活动(超时延长)
-
_tick=0:添加任务到 _wheel[30],引用计数=1;
-
_tick=10:收到数据,刷新任务,添加到 _wheel[40],引用计数=2;
-
_tick=30:clear() _wheel[30],引用计数=1,任务不销毁;
-
_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 |
时间轮不是一个新概念,但工程落地的细节,只有实际写过、踩过坑,才能真正掌握。希望这篇文章能帮你跳过定时器的那些坑,顺利把网络框架的定时器层落地。
如果觉得有收获,欢迎点赞收藏,也欢迎在评论区交流你的实现思路和踩坑经历~