前言
最近我在实现一个仿 muduo 的 C++ 高并发服务器项目。对于一个网络服务器来说,除了处理客户端连接上的读写事件,还有一类非常重要的事件:超时事件。
比如:
- 客户端长时间没有发送数据,需要主动关闭连接
- 服务端需要定期发送心跳包
- 某些任务希望延迟一段时间后执行
- 定时清理无效连接或过期资源
在 Reactor 模型中,读写事件通常由 epoll 监听 socket fd 完成。那么定时事件应该怎么接入事件循环呢?
今天主要完成了两个实验:
- 使用 Linux 的
timerfd,理解"定时器也可以被抽象成文件描述符" - 实现一个基础时间轮
TimerWheel,用于管理大量定时任务
这两个部分结合起来,就可以为后续服务器中的连接超时管理打基础。
一、timerfd:把定时器抽象成 fd
Linux 提供了 timerfd 机制,可以把定时器封装成一个文件描述符。这样一来,定时器就可以像 socket 一样被 epoll 监听。
这点非常适合 Reactor 模型,因为事件循环本身就是围绕 fd 展开的。
我先写了一个简单的 timerfd.cpp 实验程序:
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdint.h>
#include <sys/timerfd.h>
int main()
{
int timerfd = timerfd_create(CLOCK_MONOTONIC, 0);
if (timerfd < 0) {
perror("timerfd_create error");
return -1;
}
struct itimerspec 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);
while(1) {
uint64_t times;
int ret = read(timerfd, ×, 8);
if (ret < 0) {
perror("read error");
return -1;
}
printf("超时了,距离上一次超时了%ld次\n", times);
}
close(timerfd);
return 0;
}
这里的核心接口是:
int timerfd = timerfd_create(CLOCK_MONOTONIC, 0);
CLOCK_MONOTONIC 表示使用单调递增时钟,不受系统时间修改影响。对于服务器定时任务来说,这比直接依赖系统实时时间更稳定。
接着通过 timerfd_settime 设置定时器:
struct itimerspec 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);
其中:
it_value表示第一次超时时间,这里是 1 秒后第一次触发it_interval表示后续周期触发间隔,这里是每隔 1 秒触发一次
然后通过 read 等待定时器触发:
uint64_t times;
int ret = read(timerfd, ×, 8);
如果当前还没有超时事件,read 会阻塞。等定时器超时后,read 返回,并把触发次数写入 times。
times 的含义是:距离上一次读取期间,定时器累计超时了多少次。
例如服务器因为某些原因一段时间内没有及时读取 timerfd,那么下一次读取时,times 可能大于 1。
这个实验说明了一件事:定时器可以被抽象成 fd。后续就可以把 timerfd 加入 epoll,让定时事件和网络 IO 事件统一由事件循环处理。
二、为什么还需要时间轮
有了 timerfd 之后,是否就能直接管理所有连接超时了?
理论上可以,但如果每一个连接都创建一个系统定时器,代价会比较高,也不方便统一管理。
在高并发服务器里,可能同时有成千上万个连接,每个连接都需要一个空闲超时任务。如果每个连接都单独维护系统级定时器,复杂度和资源开销都会变大。
因此更常见的做法是:
timerfd只负责产生周期性的时钟滴答,比如每秒触发一次- 时间轮负责管理大量业务定时任务
- 每次
timerfd触发后,推动时间轮向前走一格 - 时间轮走到某个槽位时,执行该槽位上的过期任务
这样就把系统定时器和业务定时任务拆开了。
可以简单理解为:
timerfd:负责报时,每秒告诉服务器"时间到了"
时间轮:负责管理哪些任务应该在什么时候执行
三、时间轮的核心设计
这次实现了两个核心类:
TimerTask:表示一个定时任务TimerWheel:表示时间轮,负责添加、刷新、取消和触发任务
1. TimerTask 定时任务对象
TimerTask 保存一个具体的定时任务:
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 == false) _task_cb();
_release();
}
void Cancel() { _canceled = true; }
void SetRelease(const ReleaseFunc &cb) { _release = cb; }
uint32_t DelayTime() { return _timeout; }
};
这里几个成员变量的含义是:
_id:定时任务的唯一编号_timeout:定时任务的超时时间_canceled:表示任务是否被取消_task_cb:真正要执行的任务回调_release:任务释放时执行的清理回调,用来从时间轮的管理表中移除任务
这个设计里比较巧妙的一点是:任务回调是在析构函数中执行的。
~TimerTask() {
if (_canceled == false) _task_cb();
_release();
}
也就是说,当时间轮某个槽位被清空时,槽位中保存的 shared_ptr<TimerTask> 会被释放。如果这个定时任务没有被刷新到其他槽位,也没有被取消,那么对象析构时就会执行 _task_cb。
如果任务已经被取消,则只做资源清理,不执行业务回调。
2. TimerWheel 时间轮结构
TimerWheel 的核心结构如下:
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;
};
几个成员变量的作用:
_tick:当前秒针位置_capacity:时间轮大小,目前设置为 60_wheel:时间轮数组,每个槽位保存一组定时任务_timers:通过任务 id 快速找到对应任务
这里 _wheel 使用 shared_ptr 保存任务,_timers 使用 weak_ptr 保存任务。
这样做的好处是:
- 时间轮槽位拥有任务对象的生命周期
- 哈希表只是用来查找任务,不强行延长对象生命周期
- 当任务真正过期并释放后,哈希表中的记录也会被清理
四、关键接口实现
1. 添加定时任务 TimerAdd
void TimerAdd(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);
}
添加定时任务时,先创建 TimerTask 对象,然后根据当前秒针位置和延迟时间计算任务应该放在哪个槽位:
int pos = (_tick + delay) % _capacity;
假设当前 _tick 在 10,任务延迟 5 秒执行,那么它应该放到第 15 个槽位。
如果超过了时间轮容量,就通过取模回到前面的槽位。
2. 刷新定时任务 TimerRefresh
void TimerRefresh(uint64_t id) {
auto it = _timers.find(id);
if (it == _timers.end()) {
return;
}
PtrTask pt = it->second.lock();
int delay = pt->DelayTime();
int pos = (_tick + delay) % _capacity;
_wheel[pos].push_back(pt);
}
刷新定时任务的作用是:把任务重新放到未来的某个槽位,从而延长它的生命周期。
这个接口非常适合连接超时管理。
比如服务器设置连接 60 秒没有数据就关闭。如果某个连接在第 30 秒收到了新数据,说明连接仍然活跃,就可以刷新它的定时任务,让它重新从当前时间开始计算 60 秒。
这里通过 _timers 找到任务对应的 weak_ptr,再调用 lock() 得到 shared_ptr,然后重新放入新的槽位。
有一个后续可以优化的点:weak_ptr::lock() 之后最好判断一下是否为空。
PtrTask pt = it->second.lock();
if (!pt) {
return;
}
因为任务可能已经释放,如果不判断就直接访问,存在空指针风险。当前 demo 中流程比较简单,还没有触发这个问题,但后续接入完整服务器时需要补上。
3. 取消定时任务 TimerCancel
void TimerCancel(uint64_t id) {
auto it = _timers.find(id);
if (it == _timers.end()) {
return;
}
PtrTask pt = it->second.lock();
if (pt) pt->Cancel();
}
取消任务并不是立即从所有槽位中删除任务,而是把任务标记为取消:
void Cancel() { _canceled = true; }
等任务对象后续析构时,会检查 _canceled:
if (_canceled == false) _task_cb();
如果任务已经被取消,就不会执行真正的业务回调。
这种方式避免了在时间轮多个槽位中查找并删除任务,逻辑更简单。
4. 推动时间轮 RunTimerTask
void RunTimerTask() {
_tick = (_tick + 1) % _capacity;
_wheel[_tick].clear();
}
这个函数应该每秒执行一次,相当于秒针向前走一步。
当清空当前槽位时,该槽位中保存的 shared_ptr<TimerTask> 会被释放。如果某个任务没有被其他槽位引用,那么它就会析构,并执行对应的超时回调。
这就是当前时间轮触发定时任务的核心机制。
五、测试代码与现象
为了测试时间轮,我写了一个简单的 Test 类:
class Test {
public:
Test() { std::cout << "构造" << std::endl; }
~Test() { std::cout << "析构" << std::endl; }
};
void DelTest(Test *t) {
delete t;
}
然后在 main 函数中添加一个 5 秒后删除 Test 对象的定时任务:
int main()
{
TimerWheel tw;
Test *t = new Test();
tw.TimerAdd(888, 5, std::bind(DelTest, t));
for(int i = 0; i < 5; i++) {
sleep(1);
tw.TimerRefresh(888);
tw.RunTimerTask();
std::cout << "刷新了一下定时任务,重新需要5s中后才会销毁\n";
}
tw.TimerCancel(888);
while(1) {
sleep(1);
std::cout << "-------------------\n";
tw.RunTimerTask();
}
return 0;
}
这段测试代码主要验证两个功能:
1. 验证刷新逻辑
任务最开始设置为 5 秒后执行。
但是在前 5 秒中,每秒都会调用一次:
tw.TimerRefresh(888);
这就相当于不断把任务重新放到当前时间 5 秒后的槽位中。
所以这个对象不会在最初的 5 秒时刻析构。
这对应到服务器项目中,就是客户端不断发送数据时,连接的超时时间会被不断刷新。
2. 验证取消逻辑
刷新结束后调用:
tw.TimerCancel(888);
这会把任务标记为取消。后续即使时间轮走到对应槽位,任务对象析构,也不会执行删除 Test 对象的回调。
也就是说,取消操作可以阻止超时任务真正执行。
六、今天实现的项目价值
今天这部分虽然还是实验代码,但已经把服务器定时器机制的核心思路跑通了。
总结一下:
timerfd可以把定时事件抽象成 fd,后续可以接入epoll- 时间轮可以统一管理大量定时任务,适合高并发场景
TimerAdd用于添加超时任务TimerRefresh用于刷新活跃连接的过期时间TimerCancel用于取消不需要执行的任务RunTimerTask用于推动时间轮并触发过期任务
对于仿 muduo 服务器来说,这套机制后续可以应用在连接管理中:
客户端连接建立 -> 添加超时任务
客户端发送数据 -> 刷新超时任务
客户端主动关闭 -> 取消超时任务
长时间无数据 -> 时间轮触发关闭连接
这也是高并发服务器中非常常见的连接生命周期管理方式。
七、后续优化方向
当前代码还是单线程 demo 版本,后续接入完整服务器时,还需要继续完善:
TimerRefresh中weak_ptr::lock()后需要判断是否为空- 需要把
timerfd加入epoll,由事件循环统一驱动时间轮 - 时间轮目前容量固定为 60,只适合 60 秒以内的延迟任务,后续可以扩展为更通用的设计
- 接入多线程 Reactor 后,需要考虑定时任务操作的线程安全问题
- 定时任务回调中如果涉及连接释放,需要注意对象生命周期管理
八、总结
今天主要完成了仿 muduo 项目中定时器模块的基础实验。
我先通过 timerfd.cpp 理解了 Linux 中定时器 fd 的用法,然后通过 timewheel.cpp 实现了一个简单的时间轮,用来管理定时任务的添加、刷新、取消和触发。
这部分内容后续会和 EventLoop、Poller、Channel 结合起来,让服务器不仅能处理 socket 读写事件,也能处理连接超时这类定时事件。
从项目角度看,这一步是在为后面的连接管理和高并发稳定性打基础。