在Linux中,提供了几个定时操作的相关函数,我们先来认识一下:
c++
int timerfd_create (int clockid, int flags);
timerfd_create 是 Linux 系统中用于创建定时器文件描述符的系统调用,我们先来详细介绍一下他的两个参数。
clockid有两个可以填入的参数:
-
CLOCK_REALTIME:系统实时时间,如果修改了系统时间就会出问题。比如我们现在是晚上七点,我们设置一个七点半的闹钟,当时间过了29分钟后我们修改了当前系统的时间还是晚上七点,他就会重新计时,导致出错。
-
CLOCK_MONOTONIC:以系统启动时间进行递增的一个基准值(定时器不会随着系统时间改变而改变)
flags: 0-默认阻塞属性。
之所以会有flags这个参数,是我们Linux下一切皆文件,所以定时这个操作实际上也是依靠的文件操作。
这个函数的返回值是一个int类型,实际上返回的就是一个文件描述符。
每隔一段时间(定时器的超时时间),系统就会给这个描述符对应的定时器写入一个8字节数据。
当我们创建了一个定时器,定时器设置的超时时间是3s,那么每3s,将会计算一次超时。从启动开始,每隔3s中,系统都会给描述如写入一个1,表示从上一次读取数据到现在超时了1次。假设30s之后才读取数据,则这时候就会读取到一个10,表示上一次读取数据到限制超时了10次。
由于会写入数据,我们就需要把这个数据读取出来,所以flags选择的就是我们是阻塞的读还是非阻塞的读。
这个接口完了之后呢,我们再来看一下下一个接口,这个就很重要了:
c++
int timerfd_settime (int fd, int flags, struct itimerspec *new , struct itimerspec *old);
用于启动、停止或修改 由 timerfd_create 创建的定时器文件描述符的系统调用。
我们来介绍一下它的几个参数。
-
fd(文件描述符)由
timerfd_create()返回的定时器文件描述符。 -
flags(时间标志)决定
new_value中的时间是如何解释的,通常有以下两种取值:0(相对时间) :表示new_value中设置的时间是相对于当前时刻的相对时间(即"多少秒之后触发")。TFD_TIMER_ABSTIME(绝对时间) :表示new_value中设置的时间是时钟的绝对时间戳 (即"到达某个具体时间点时触发")。如果使用此标志,new_value的时间必须与创建fd时指定的时钟源(如CLOCK_MONOTONIC)相匹配。
-
new(新的定时设置)指向
struct itimerspec结构体,用于指定新的超时时间。该结构体包含两个核心成员:it_value(首次超时时间) :- 如果将其设为非零值,定时器将被启动。
- 如果将其两个字段都设为
0,定时器将被停止。
it_interval(周期触发间隔) :- 如果将其设为非零值,定时器在首次触发后,会按照这个间隔周期性地重复触发。
- 如果将其设为
0,定时器在首次触发后将只触发一次并自动停止。
-
old(旧的定时设置)如果不为
NULL,该指针会带回调用此函数之前 的定时器设置。这在需要临时修改定时器并在之后想要恢复原状时非常有用;如果不需要,传NULL即可。
itimerspec结构体的结构:
c++
struct timespec {
time_t tv_sec; /* Seconds */
long tv_nsec; /* Nanoseconds */
};
struct itimerspec {
struct timespec it_interval; /* 第⼀次之后的超时间隔时间 */
struct timespec it_value; /* 第⼀次超时时间 */
};
tv_sec的单位是秒,tv_nsec是纳秒。
有了以上两个接口的基础知识后,我们就可以尝试用这两个接口来实现的一个时间轮的思想。
时间轮定时器的基本思想
最基础的定时器思想就是每一个单位时间都去查看是否超时。这样毫无疑问是落后的,每次超时都要将所有的连接遍历⼀遍,如果有上万个连接,效率⽆疑是较为低下的。
这时候⼤家就会想到,我们可以针对所有的连接,根据每个连接最近⼀次通信的系统时间建⽴⼀个⼩根堆,这样只需要每次针对堆顶部分的连接逐个释放,直到没有超时的连接为⽌,这样也可以⼤ 提高处理的效率。
上述⽅法可以实现定时任务,但是这⾥给⼤家介绍另⼀种⽅案:时间轮。
时间轮的思想来源于钟表,如果我们定了⼀个3点钟的闹铃,则当时针⾛到3的时候,就代表时间到了。
同样的道理,如果我们定义了⼀个数组,并且有⼀个指针,指向数组起始位置,这个指针每秒钟向后⾛动⼀步,⾛到哪⾥,则代表哪⾥的任务该被执⾏了,那么如果我们想要定⼀个3s后的任务,则只需要将任务添加到tick+3位置,则每秒中⾛⼀步,三秒钟后tick⾛到对应位置,这时候执⾏对应位置的任务即可。
但是,同⼀时间可能会有⼤批量的定时任务,因此我们可以给数组对应位置下拉⼀个数组,这样就可以在同⼀个时刻上添加多个定时任务了。

当然,上述的单层时间轮设计在实际落地时也存在一些明显的缺陷。比如,如果我们想定义一个 60s 后的任务,就必须将底层数组的元素个数至少设置为 60;那如果是一个 1 小时后的定时任务呢?我们难道要定义一个拥有 3600 个元素的巨大数组吗?这无疑是非常消耗内存且麻烦的。
为了解决这个问题,工程上通常采用多层级的时间轮 设计。这就好比我们生活中的钟表,有秒针轮、分针轮、时针轮。当一个任务的超时时间 time 满足 60 < time < 3600 时,我们只需要计算 time / 60,将其放入分针轮对应的存储位置即可。只有当秒针轮转满一圈(tick / 3600 等于对应位置)时,才会触发级联,将分针轮里的任务向秒针轮进行降级移动。
不过,回到我们当前的应用场景,倒是不用把架构设计得这么复杂。我们设计一个一分钟以内的简单高效的单层时间轮就完全够用了。
但是,我们还得考虑另一个更棘手的问题:当前的设计是时间到了,系统主动去执行定时任务来释放连接。那能不能换一种思路,让时间到了后,任务"自动"执行呢?这时候,我们就联想到了 C++ 中一个非常经典的特性------类的析构函数。
一个类的析构函数,会在对象被释放时自动被执行。那么,如果我们把"定时任务的具体逻辑"封装在类的析构函数内,是不是就意味着这个定时任务在对象被释放的瞬间就会自动执行?
但仅仅为了这个目的而设计一个额外的任务类,好像有些不划算。而且,这里我们又必须面对一个真实的业务痛点:假设有一个连接建立成功了,我们给它设置了一个 30s 后的定时销毁任务。但是,在第 10s 的时候,这个连接又进行了一次正常的通信。这时候,我们应该在第 30s 的时候强行关闭,还是在第 40s(10s + 30s)的时候关闭呢?
毫无疑问,应该是第 40s。也就是说,原本第 30s 的那个任务必须失效,我们需要给它"续命"。可是,我们该如何优雅地实现这个"旧任务失效"的操作呢?
这时候,就轮到我们的主角------智能指针 shared_ptr 登场了。
shared_ptr 内部维护着一个引用计数器,只有当计数为 0 的时候,才会真正释放对象并触发析构函数。利用这个特性,我们的逻辑就通顺了:
假如连接在第 10s 进行了一次通信,我们不需要去时间轮里费力地删除旧任务,而是直接继续向定时任务中添加一个指向同一个任务对象 的 shared_ptr(设置 30s 后,即第 40s 到期)。这时候,这个任务对象的引用计数就变成了 2。
当时间来到第 30s,原本的那个定时任务被时间轮释放,计数减 1 变为 1。因为计数不为 0,对象并不会被真正释放,析构函数也就不会执行------这就相当于第 30s 的任务自动失效了。只有等到第 40s,最后一个 shared_ptr 也被释放,计数归零,定时任务才会被真正执行。
时间轮定时器的简单实现
接下来,我们来实际操作一下。
首先,我们在设计定时器时得考虑一个问题:一个定时任务,它不仅仅要记录"多久之后执行",还得能灵活地"取消"或者"刷新"。如果仅仅把它当做一个简单的函数指针存在数组里,当我们需要根据 ID 去取消某个特定任务时,就会非常麻烦。
因此,我们需要把定时任务封装成一个独立的类------TimerTask。在这个类里,我们不仅要保存任务的 ID、超时时间,还要保存外界传进来的回调函数 task_cb。但是,这里又有一个棘手的问题:当这个任务对象在时间轮里被销毁时,我们怎么通知外部的时间轮(TimerWheel),让它把哈希表里对应的索引也删掉呢?
这时候,我们就用到了 C++ 的回调机制。我们在 TimerTask 里预留了一个 ReleaseTask 类型的函数 _release。当析构函数被调用时,除了执行真正的业务回调,还会自动触发这个 _release,从而把哈希表里的残留数据清理干净。
cpp
class TimerTask
{
private:
uint64_t _id; // 定时器任务的对象id
uint32_t _timeout; // 超时时间设置
TaskFunc _task_cb; // 该定时器对象要执行的定时任务
ReleaseTask _release; // 用于删除TimerWheel中保存的定时器对象信息
bool _canceled; // false等于没取消
public:
TimerTask(uint64_t id, uint32_t delay, const TaskFunc &cb)
: _id(id), _timeout(delay), _task_cb(cb), _canceled(false)
{
}
~TimerTask() // 配合时间轮与shared_ptr的引用计数使用
{
if (!_canceled)
_task_cb(); // 如果没被取消,执行真正的业务逻辑
_release(); // 通知时间轮删除哈希表中的对应项
}
// ... 省略部分 Setter 和 Getter
};
接下来,就是核心组件------时间轮(TimerWheel)的搭建了。
我们的时间轮底层其实就是一个 vector 数组,每个槽位里放着一个 shared_ptr 的列表。同时,为了能通过 ID 快速找到任务,我们还搭配了一个 unordered_map 哈希表。但这里有个细节要注意:哈希表里存的不能是 shared_ptr,否则就算时间轮里的任务到期了,哈希表还拽着它不放,引用计数永远降不到 0,析构函数就永远无法触发。所以,哈希表里我们存的是 weak_ptr,它只负责"观察"对象是否存在,却不干涉对象的生命周期。
cpp
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;
// 哈希表存 weak_ptr,只观察不控制生命周期
std::unordered_map<uint64_t, WeakTask> _timers;
private:
void RemoveTime(uint64_t id)
{
auto it = _timers.find(id);
if (it != _timers.end())
{
_timers.erase(it); // 找到了对应id的weakptr就删除
}
}
// ...
};
有了这些铺垫,我们就可以来看看具体的业务逻辑是怎么跑通的了:
1. 添加任务(TimerAdd)
当我们往时间轮里添加任务时,会创建一个 shared_ptr 管理的 TimerTask 对象,把它扔进时间轮对应的槽位里。同时,生成一个对应的 weak_ptr 扔进哈希表,并把这个任务的"清理函数"绑定好。
cpp
void TimerAdd(uint64_t id, uint32_t delay, const TaskFunc &cb)
{
PtrTask pt(new TimerTask(id, delay, cb)); // 创建一个shared_ptr
pt->SetRelease(std::bind(&TimerWheel::RemoveTime, this, id)); // 绑定哈希表清理函数
// 接下来就是设置任务时间
int pos = (_tick + delay) % _capacity;
_wheel[pos].push_back(pt);
// 添加weakptr给哈希表
_timers[id] = WeakTask(pt);
}
2. 刷新任务(TimerRefresh)
这就是我们之前讨论的"续命"操作。当连接活跃时,我们通过 ID 在哈希表里找到那个 weak_ptr,把它 lock 成一个新的 shared_ptr,然后重新扔进时间轮未来的某个槽位。这样一来,同一个任务对象就有了多个 shared_ptr 在引用,旧的到期了也不会被释放,只有最后一次续命的任务到期,引用计数归零,才会真正执行析构。
cpp
void TimerRefresh(uint64_t id)
{
auto it = _timers.find(id);
if (it == _timers.end()) return; // 没有这个任务无法刷新
PtrTask pt = it->second.lock(); // lock函数会返回这个对象的一个有效的 shared_ptr
int pos = (_tick + pt->DelayTime()) % _capacity;
_wheel[pos].push_back(pt); // 重新扔进未来的槽位,实现续命
}
3. 取消任务(TimerCancel)
如果我们想主动取消,只需要通过哈希表找到任务,调用它的 SetCancel 接口,把内部的 _canceled 标志位设为 true。这样等到析构函数触发时,发现标志位变了,就不会去执行那个业务回调了。
cpp
void TimerCancel(uint64_t id)
{
auto it = _timers.find(id);
if (it == _timers.end()) return;
PtrTask pt = it->second.lock();
if (pt)
pt->SetCancel(); // 仅仅打上取消标记,不立即释放
}
4. 时间流逝(RunTimeTask)
这个函数就像钟表的秒针,每秒走一格。它的工作非常简单粗暴------把当前秒针指向的槽位 clear 掉。这一 clear,槽位里所有的 shared_ptr 都会被释放,如果引用计数归零,那些精心设计的析构函数就会自动开始工作。
cpp
void RunTimeTask() // 这个函数应该每秒执行一次(每刻度)
{
_tick = (_tick + 1) % _capacity;
_wheel[_tick].clear(); // 清空指定位置的数组,释放shared_ptr,触发析构
}
最后,我们在 main 函数里模拟了整个流程:先添加一个 5 秒后删除 Test 对象的任务,然后在前 5 秒不断地刷新它(续命),让它始终无法被真正释放。等停止刷新后,任务到期,Test 对象被成功析构。在最后,我们可以测试一下主动取消任务的功能。
结语:
聊到这里,咱们这套基于 C++ 智能指针与时间轮的定时器设计,基本上就聊透了。从最开始单层时间轮的简单高效,到利用 shared_ptr 和 weak_ptr 的引用计数机制巧妙解决"任务刷新"和"自动析构"的痛点,这其实就是一个典型的从"理论"到"落地"的过程。
这套设计虽然看起来简单,但它解决了高并发服务器里最头疼的"连接管理"问题:既不用每次都去遍历所有连接(效率高),又能优雅地处理连接的"续命"和"死亡"(逻辑清晰)。
再顺口补充几点,我们在执行析构函数的时候会执行传进来的任何函数cb。这里就可能会出现一些问题,如果cb函数中有一些操作可能会导致这个进程堵住,那就会影响整个时间轮定时器。
所以在高性能网络框架(如 Netty、Skynet 等)中,有一条铁律:定时器回调必须极其轻量,绝对不能包含任何阻塞或耗时操作。
如果你确实需要在定时器到期时执行一些耗时任务(比如你的 Del 需要清理大量数据或写数据库),标准的工程化解决方案是 "异步解耦" :
-
回调只做"发令枪" :
你的
cb(Del)在定时器触发时,绝对不能亲自去干脏活累活。它唯一的工作应该是把这个"清理任务"打包成一个消息,扔到一个**任务队列(Task Queue)或者线程池(Thread Pool)**中,然后立刻返回。c++// 错误的做法:直接在回调里做耗时操作 void Del(Test* t) { // 耗时的数据库删除操作... delete t; } // 正确的做法:回调只负责投递任务 void Del(Test* t) { // 把 delete t 和后续的清理工作打包,扔给后台的工作线程池去处理 ThreadPool::getInstance()->pushTask([t]() { // 在工作线程中执行耗时操作 // 耗时的数据库删除操作... delete t; }); } -
时间轮继续飞速运转 :
因为
Del仅仅是把任务丢进队列(这是一个极快的内存操作),它会立刻执行完毕并返回。~TimerTask()析构结束,clear()执行完毕,RunTimeTask继续走向下一秒。这样,你的时间轮就能始终保持精准的滴答节奏,不受任何耗时业务逻辑的影响。
附Demo的完整代码:
c++
#include <iostream>
#include <functional>
#include <cstdint>
#include <memory>
#include <vector>
#include <unordered_map>
#include <unistd.h>
using TaskFunc = std::function<void()>; // 定义的超时的任务的类型
using ReleaseTask = std::function<void()>; // 定义的删除哈希表中内容的操作任务
class TimerTask
{
private:
uint64_t _id; // 定时器任务的对象id
uint32_t _timeout; // 超时时间设置,定时任务的超时时间
TaskFunc _task_cb; // 该定时器对象要执行的定时任务
ReleaseTask _release; // 用于删除TimerWheel中保存的定时器对象信息
bool _canceled; // false等于没取消
public:
TimerTask(uint64_t id, uint32_t delay, const TaskFunc &cb) // 我们希望这个id是外界给的而不是我们在内部自动生成的,保持唯一性,这个定时器可能在多个线程中使用,所以可能会有重复的id
: _id(id), _timeout(delay), _task_cb(cb), _canceled(false)
{
}
~TimerTask() // 配合时间轮与shared_ptr的引用计数使用
{
if (!_canceled)
_task_cb();
_release();
}
void SetCancel()
{
_canceled = true;
}
void SetRelease(const ReleaseTask &rt) // 当我们的一个任务到时间被删除后,他的shared_ptr会被释放,那么对应id的weakptr也需要被删除,传递给外界设置删除函数
{
_release = rt;
}
uint32_t DelayTime() const
{
return _timeout;
}
};
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;
// 在某些场景下,你需要知道一个对象是否还存在,但又不想因为你的观察而延长它的生命周期,使用weak_ptr
std::unordered_map<uint64_t, WeakTask> _timers; // 通过id找到所有的定时器的weakptr对象,weakptr对象与sharedptr是两套独立的计数,weakptr只能找到对象但不能控制释放
// 但是我们要注意当sharedptr的对象真的要释放掉的时候我们也需要把对应的weakptr在哈希表中删掉,所以我们还需要一个额外的操作来删除哈希表的内容
private:
void RemoveTime(uint64_t id)
{
// 从哈希表中搜索id
auto it = _timers.find(id);
if (it != _timers.end())
{
_timers.erase(it); // 找到了对应id的weakptr就删除
}
}
public:
TimerWheel() // 构造函数中我们可以固定一个最大时间,比如就是60秒,随后秒针应该初识指向0
: _tick(0), _capacity(60), _wheel(_capacity)
{
}
void TimerAdd(uint64_t id, uint32_t delay, const TaskFunc &cb)
{
PtrTask pt(new TimerTask(id, delay, cb)); // 创建一个shared_ptr
pt->SetRelease(std::bind(&TimerWheel::RemoveTime, this, id)); // 由于Removetime是一个类函数,第一个参数是隐藏的this指针所以需要使用bind绑定一下参数使得函数类型符合一个void()
// 接下来就是设置任务时间
int pos = (_tick + delay) % _capacity;
_wheel[pos].push_back(pt);
// 添加weakptr给哈希表
_timers[id] = WeakTask(pt); // weakptr可以接受来自sharedptr的构造
}
void TimerRefresh(uint64_t id) // 刷新定时任务
{
// 在哈希表中查找这个id的任务
auto it = _timers.find(id);
if (it == _timers.end())
{
return; // 没有这个任务无法刷新
}
PtrTask pt = it->second.lock(); // lock函数会返回这个对象的一个有效的 shared_ptr
int pos = (_tick + pt->DelayTime()) % _capacity;
_wheel[pos].push_back(pt);
}
void TimerCancel(uint64_t id)
{
// 在哈希表中查找这个id的任务
auto it = _timers.find(id);
if (it == _timers.end())
{
return; // 没有这个任务无法刷新
}
PtrTask pt = it->second.lock(); // lock函数会返回这个对象的一个有效的 shared_ptr
if (pt)
pt->SetCancel();
}
void RunTimeTask() // 这个函数应该每秒执行一次(每刻度)
{
_tick = (_tick + 1) % _capacity;
_wheel[_tick].clear(); // 清空指定位置的数组,就会把数组中保存的所有管理定时器对象的shared_ptr释放掉
}
};
class Test
{
public:
Test()
{
std::cout << "构造" << std::endl;
}
~Test()
{
std::cout << "析构" << std::endl;
}
};
void Del(Test *t)
{
delete t;
}
int main()
{
TimerWheel tw;
Test *t = new Test();
tw.TimerAdd(888, 5, std::bind(Del, t));
for (int i = 0; i < 5; ++i)
{
sleep(1);
tw.TimerRefresh(888);
tw.RunTimeTask();
std::cout << "刷新了一下计时,重新数五秒钟结束" << std::endl;
}
int count = 0;
while (1)
{
std::cout << count << std::endl;
if(count==3)
{
tw.TimerCancel(888);
}
if (count == 5)
break;
sleep(1);
count++;
tw.RunTimeTask();
}
return 0;
}