跳到主要内容高性能定时器:时间轮算法的工程实践 | 极客日志C++算法
高性能定时器:时间轮算法的工程实践
基于时间轮的高性能定时器在 C++ 网络服务中的工程实践。通过智能指针管理任务生命周期,利用 timerfd 驱动时间轮转动,结合 EventLoop 保证线程安全。核心优化包括使用 shared_ptr 引用计数实现任务刷新而非删除重加,析构函数自动触发回调,以及批量处理 timerfd 超时事件,有效解决僵尸连接清理问题。
无尘27 浏览 从零实现高性能定时器:时间轮算法的工程实践
一、定时器要解决什么问题?
在长连接服务中,有一个绕不开的需求:连接如果长时间没有活动,必须主动断开。
原因很现实:
- 客户端可能已经断网,但 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 核心代码(关键析构函数)
~TimerTask() {
if (!_canceled) _task_cb();
_release();
}
4.3 为什么这样设计?
核心依赖 C++ 的 shared_ptr 引用计数机制,我们可以用 shared_ptr 来自动管理任务的生命周期:
- 时间轮的每个格子,用 vector<shared_ptr> 存储任务(shared_ptr 持有任务,控制生命周期);
- 当指针走到该格子,我们只需要调用 _wheel[_tick].clear()(清空当前格子);
- clear() 会销毁所有 shared_ptr 对象 → 引用计数归零 → TimerTask 对象析构 → 析构函数执行 → 回调函数自动调用;
- 整个过程,我们不需要手动遍历任务、不需要手动调用回调、不需要手动清理任务,代码极简且不易出错。
五、TimerTask:定时任务的封装(可直接复用)
首先我们封装定时任务本身,每个任务对应一个长连接,核心是存储任务 ID、超时时长、回调函数,以及控制取消状态。
5.1 完整类实现
using TaskFunc = std::function<void()>;
using ReleaseFunc = std::function<void()>;
class TimerTask {
private:
uint64_t _id;
uint32_t _timeout;
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; }
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;
std::vector<std::vector<PtrTask>> _wheel;
std::unordered_map<uint64_t, WeakTask> _timers;
EventLoop *_loop;
int _timerfd;
std::unique_ptr<Channel> _timer_channel;
};
- _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) {
_loop->RunInLoop([=] { TimerAddInLoop(id, delay, cb); });
}
void TimerAddInLoop(uint64_t id, uint32_t delay, const TaskFunc &cb) {
PtrTask pt(new TimerTask(id, delay, cb));
pt->SetRelease(std::bind(&TimerWheel::RemoveTimer, this, id));
int pos = (_tick + delay) % _capacity;
_wheel[pos].push_back(pt);
_timers[id] = WeakTask(pt);
}
void RemoveTimer(uint64_t id) {
_timers.erase(id);
}
6.3 刷新任务(TimerRefresh)——高频核心操作
这是最常用的操作:当长连接收到数据(有活动)时,需要重置超时时间。传统实现是'删除旧任务、添加新任务',复杂度 O(1) 但逻辑繁琐,我的实现更巧妙。
void TimerRefresh(uint64_t id) {
_loop->RunInLoop([=] { TimerRefreshInLoop(id); });
}
void TimerRefreshInLoop(uint64_t id) {
auto it = _timers.find(id);
if (it == _timers.end()) { return; }
PtrTask pt = it->second.lock();
if (!pt) { _timers.erase(it); return; }
int delay = pt->DelayTime();
int pos = (_tick + delay) % _capacity;
_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); });
}
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() {
int timerfd = timerfd_create(CLOCK_MONOTONIC, 0);
if (timerfd < 0) {
perror("timerfd_create error");
exit(EXIT_FAILURE);
}
struct itimerspec itime;
memset(&itime, 0, sizeof(itime));
itime.it_value.tv_sec = 1;
itime.it_value.tv_nsec = 0;
itime.it_interval.tv_sec = 1;
itime.it_interval.tv_nsec = 0;
timerfd_settime(timerfd, 0, &itime, NULL);
return timerfd;
}
6.5.2 处理超时事件(驱动指针转动)
当 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);
}
void OnTime() {
int times = ReadTimefd();
for (int i = 0; i < times; i++) {
RunTimerTask();
}
}
void RunTimerTask() {
_tick = (_tick + 1) % _capacity;
_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 连接建立(添加定时器)
timer_wheel->TimerAdd(conn_id, 30, [conn] { conn->Shutdown(); });
此时:_wheel[(_tick+30)%60] 中添加了该任务的 shared_ptr,_timers 中添加了该任务的 weak_ptr。
8.2 收到数据(刷新定时器)
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 而非 list?
时间轮的任务操作是'批量插入、批量删除'(每次刷新批量插入,每次超时批量删除),vector 的 clear() 是连续内存操作,比 list 的节点遍历删除快得多;而且 vector 的内存更紧凑,缓存命中率更高,性能更优。
9.3 内存开销(是否可控?)
- _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 |
时间轮不是一个新概念,但工程落地的细节,只有实际写过、踩过坑,才能真正掌握。希望这篇文章能帮你跳过定时器的那些坑,顺利把网络框架的定时器层落地。
相关免费在线工具
- 加密/解密文本
使用加密算法(如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