从零实现高性能定时器:时间轮算法的工程实践
一、定时器要解决什么问题?
在长连接服务中,有一个绕不开的需求:连接如果长时间没有活动,必须主动断开。
原因很现实:
- 客户端可能已经断网,但 TCP 层感知不到(比如手机突然没信号、异常退出);
- 这些'僵尸连接'会一直占用 fd、内存、带宽等服务器资源;
- 不及时清理,服务器迟早被这些无效连接拖垮。
所以我们需要一个简单直接的机制。
二、为什么选择时间轮?
常见的定时器实现有三种,各有优劣,我们先看一张对比表,一目了然:
本文详解基于时间轮的高性能定时器在 C++ 网络服务中的工程实践。通过智能指针管理任务生命周期,利用 timerfd 驱动时间轮转动,结合 EventLoop 保证线程安全。核心优化包括使用 shared_ptr 引用计数实现任务刷新而非删除重加,析构函数自动触发回调,以及批量处理 timerfd 超时事件,有效解决僵尸连接清理问题。
在长连接服务中,有一个绕不开的需求:连接如果长时间没有活动,必须主动断开。
原因很现实:
所以我们需要一个简单直接的机制。
常见的定时器实现有三种,各有优劣,我们先看一张对比表,一目了然:
| 方案 |
|---|
| 插入复杂度 |
|---|
| 删除复杂度 |
|---|
| 适用场景 |
|---|
| 有序链表 | O(n) | O(1) | 任务数量极少(几百个以内) |
| 最小堆 | O(log n) | O(log n) | 通用场景,任务量中等 |
| 时间轮 | O(1) | O(1) | 大量短超时任务(网络服务核心场景) |
网络服务通常有以下特点:
这种场景下,时间轮的 O(1) 增删复杂度优势被无限放大——不管有多少任务,添加、删除、刷新的耗时都是固定的。
一句话概括:一个循环转动的'钟表',每个刻度放对应时间要执行的任务,指针转一格,执行一格的任务。
假设表盘有 60 个格子,指针每秒走一格,每个格子里存放'这一秒要执行的所有任务'。
比如指针现在在第 10 格,我们要添加一个 5 秒后超时的任务,目标位置就是 (10 + 5) % 60 = 15,把任务放到第 15 格即可。
每过一秒,指针前进一格,清空当前格子的所有任务——清空的过程,就是任务超时、执行回调的过程。
这是整个定时器实现中,最能简化代码、规避坑点的设计。
~TimerTask() {
if (!_canceled) _task_cb(); // 析构时,未取消则执行回调
_release(); // 清理映射表,避免内存泄漏
}
核心依赖 C++ 的 shared_ptr 引用计数机制,我们可以用 shared_ptr 来自动管理任务的生命周期:
首先我们封装定时任务本身,每个任务对应一个长连接,核心是存储任务 ID、超时时长、回调函数,以及控制取消状态。
// 先定义回调函数类型(简化代码)
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; }
};
| 成员变量 | 作用 | 典型值/场景 |
|---|---|---|
| _id | 唯一标识任务,关联长连接 | 连接 ID(uint64_t,避免重复) |
| _timeout | 基础超时时长,刷新时复用 | 30 秒(长连接默认超时时间) |
| _canceled | 取消标记,避免已取消任务执行回调 | 默认 false,取消后设为 true |
| _task_cb | 超时核心逻辑(如关闭连接) | conn->Shutdown() |
| _release | 清理 TimerWheel 中的映射表 | 删除_timers 中对应 ID 的记录 |
注意:_release 必须绑定,否则任务销毁后,TimerWheel 中的 _timers 映射表会残留该任务的弱引用,导致野指针和内存泄漏。
TimerWheel 是整个定时器系统的主体,负责管理时间轮的转动、任务的增删改查、与 epoll 的配合,以及线程安全控制。
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)
};
关键设计说明:
外部调用接口,用于给新连接添加定时器,核心是'创建任务、绑定清理回调、计算刻度、入轮入表'。
// 线程安全接口(外部调用)
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);
}
这是最常用的操作:当长连接收到数据(有活动)时,需要重置超时时间。传统实现是'删除旧任务、添加新任务',复杂度 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);
}
核心原理:
刷新任务,本质是'延长任务的生命周期',而非'删除旧任务、添加新任务':
这个设计避免了'先删后加'的复杂逻辑,无需查找旧任务在哪个刻度,直接添加新引用即可,效率更高、代码更简洁。
当连接主动关闭时,需要取消对应的定时器(避免超时后再次执行关闭回调)。取消的核心是'标记取消',而非'删除任务'。
// 线程安全接口(外部调用)
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() 销毁,只是析构时不执行回调而已,清理逻辑仍会执行。
时间轮需要一个'心跳'来驱动指针前进,不能用 sleep(会阻塞线程),也不能用 poll(效率低)。Linux 提供的 timerfd 是最佳选择——它可以被 epoll 监听,每隔指定时间变为可读,完美融入 EventLoop 事件循环。
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;
}
当 timerfd 变为可读时,说明时间到了,我们需要读取超时次数(避免事件堆积),然后驱动指针转动,执行超时任务。
// 读取 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 通常是单线程的,而定时器的操作(增/刷/取消)可能来自不同线程:
如果多个线程同时操作 _wheel 和 _timers,会导致数据竞争(崩溃、野指针等问题)。
所有定时器操作,都通过 EventLoop 的 RunInLoop 接口,投递到 EventLoop 单线程执行,天然避免数据竞争,实现线程安全。
这也是为什么我们在 TimerAdd、TimerRefresh、TimerCancel 三个接口中,都加了 _loop->RunInLoop(…) 的封装——外部线程调用时,会把任务投递到 Loop 线程,由 Loop 线程统一执行实际的操作(TimerAddInLoop 等)。
RunInLoop 的实现逻辑很简单(这里不展开):如果当前线程是 Loop 线程,直接执行;如果不是,把任务放到任务队列,由 Loop 线程循环取出执行。
用一个长连接的完整生命周期,串联整个定时器的工作流程,让你彻底明白每一步的作用:
// 新连接创建成功后,添加 30 秒超时定时器
timer_wheel->TimerAdd(conn_id, 30, [conn] { conn->Shutdown(); }); // 超时回调:关闭连接
此时:_wheel[(_tick+30)%60] 中添加了该任务的 shared_ptr,_timers 中添加了该任务的 weak_ptr。
// 连接收到客户端数据,刷新定时器(重置为 30 秒超时)
timer_wheel->TimerRefresh(conn_id);
此时:任务的引用计数 +1,_wheel[(_tick+30)%60] 中再添加一份该任务的 shared_ptr。
60 秒是长连接超时的常用值(大部分场景下,30~60 秒无活动就可以关闭连接),选 60 可以让单轮时间轮覆盖大部分场景。
如果业务需要更长的超时(比如 5 分钟),有两种方案:
时间轮的任务操作是'批量插入、批量删除'(每次刷新批量插入,每次超时批量删除),vector 的 clear() 是连续内存操作,比 list 的节点遍历删除快得多;而且 vector 的内存更紧凑,缓存命中率更高,性能更优。
假设我们有 10 万连接,每个连接一个定时器:
即使是 100 万连接,总内存也只有 32 MB,对服务器来说几乎可以忽略。
这套基于时间轮的定时器,核心优势是'高性能、易落地、线程安全',完美适配长连接网络服务的场景。它的核心设计思想,其实就是把几个简单的技术点(时间轮、智能指针、timerfd、EventLoop)优雅地结合起来,用最少的代码解决工程上的复杂问题。
最后,用一张表总结核心设计点,方便你快速回顾:
| 设计目标 | 实现方式 |
|---|---|
| 高效增删改 | 时间轮 O(1) 复杂度 |
| 自动执行回调 | TimerTask 析构函数触发 |
| 刷新超时 | 增加 shared_ptr 引用计数 |
| 取消任务 | 标记 _canceled,不删除任务 |
| 线程安全 | 所有操作统一在 Loop 线程执行 |
| 时间驱动 | timerfd + epoll 融入 EventLoop |
时间轮不是一个新概念,但工程落地的细节,只有实际写过、踩过坑,才能真正掌握。希望这篇文章能帮你跳过定时器的那些坑,顺利把网络框架的定时器层落地。

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML 转 Markdown 互为补充。 在线工具,Markdown 转 HTML在线工具,online
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML 转 Markdown在线工具,online
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online