一、定时器到底是干什么的
在程序开发中,经常会遇到一些"过一段时间再执行"的任务,例如:
-
客户端连接 30 秒没有数据就主动断开;
-
3 秒之后执行一个回调函数;
-
每隔一段时间检查连接状态;
-
延迟删除某个资源;
-
网络请求超过规定时间后认为超时。
这些功能本质上都可以抽象成一个定时任务:
当前时间 + 延迟时间 = 任务到期时间
例如当前时间是:
1000ms
添加一个 3000ms 后执行的任务:
expire = 1000 + 3000;
那么这个任务的到期时间就是:
4000ms
程序运行过程中只需要不断判断:
当前时间 >= expire
如果条件成立,就说明任务已经到期,可以执行对应的回调函数。
因此,一个定时任务最基本需要保存三个东西:
struct TimerNodeBase {
time_t expire; // 到期时间
int64_t id; // 定时任务唯一编号
};
除了到期时间之外,还需要保存任务真正到期以后要执行的函数。
struct TimerNode : public TimerNodeBase {
using Callback = std::function<void(const TimerNode &node)>;
Callback func; // 定时器到期后执行的回调函数
TimerNode(int64_t id, time_t expire, Callback func) : func(func) {
this->id = id;
this->expire = expire;
}
};
所以可以简单理解为:
TimerNode = 到期时间 + 任务ID + 回调函数
这就是一个定时任务最核心的数据结构。
二、定时任务应该怎样保存
一个程序中通常不会只有一个定时任务。
比如可能同时存在:
任务1:1000ms 后执行
任务2:5000ms 后执行
任务3:2000ms 后执行
任务4:3000ms 后执行
程序肯定希望能够快速找到:
最近要到期的任务
因此,定时任务一般需要按照到期时间进行排序。
这里可以直接使用 C++ 的 std::set 保存:
std::set<TimerNode, std::less<>> timeouts;
为了让 set 知道两个定时任务谁应该排在前面,需要定义比较规则:
bool operator<(const TimerNodeBase &left, const TimerNodeBase &right) {
if (left.expire < right.expire) return true;
if (left.expire > right.expire) return false;
return left.id < right.id;
}
比较过程首先看:
expire
到期时间越早,任务越靠前。
如果两个任务到期时间一样:
expire1 == expire2
再比较:
id
这是因为 set 本身不允许出现完全相同的元素。
例如两个任务都在 3000ms 后执行:
任务1:expire = 3000,id = 1
任务2:expire = 3000,id = 2
虽然到期时间一样,但因为 id 不同,所以两个任务都可以正常保存。
这样 set 中的数据大致就是:
1000ms -> id1
2000ms -> id3
3000ms -> id4
5000ms -> id2
而:
timeouts.begin()
始终指向最早到期的任务。
三、添加和删除一个定时任务
首先需要获得当前时间。
定时器通常不直接使用系统日期时间,而是使用单调时钟:
static time_t GetTick() {
return std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()
).count();
}
这里使用:
std::chrono::steady_clock
它非常适合计算程序运行时间,因为不会受到修改系统时间的影响。
添加定时任务时,只需要计算:
到期时间 = 当前时间 + 延迟时间
例如:
TimerNodeBase AddTimer(time_t msec, TimerNode::Callback func) {
time_t expire = GetTick() + msec;
auto result = timeouts.emplace(GenID(), expire, std::move(func));
return static_cast<TimerNodeBase>(*result.first);
}
调用时:
timer->AddTimer(3000, [](const TimerNode &node) {
std::cout << "3000ms 定时任务执行,id = " << node.id << std::endl;
});
整个过程可以理解为:
AddTimer(3000)
↓
获取当前时间
↓
计算 expire
↓
创建 TimerNode
↓
插入 set
如果某个定时任务还没有到期,但已经不需要了,也可以主动删除:
bool DelTimer(TimerNodeBase &node) {
auto iter = timeouts.find(node);
if (iter == timeouts.end()) return false;
timeouts.erase(iter);
return true;
}
使用方式:
auto node = timer->AddTimer(5000, [](const TimerNode &node) {
std::cout << "timer expired" << std::endl;
});
timer->DelTimer(node);
这样这个 5 秒定时任务就不会再被执行。
四、定时任务到期后怎样执行
前面已经按照到期时间把所有任务保存到了 set 中。
那么检查超时任务就非常简单了。
因为:
timeouts.begin()
永远是最早到期的任务,所以只需要从头开始检查。
void HandleTimer(time_t now) {
auto iter = timeouts.begin();
while (iter != timeouts.end() && iter->expire <= now) {
iter->func(*iter); // 执行定时任务
iter = timeouts.erase(iter); // 执行完成后删除
}
}
假设当前时间:
now = 3500ms
当前定时任务:
1000ms
2000ms
3000ms
5000ms
7000ms
那么前面三个任务:
1000 <= 3500
2000 <= 3500
3000 <= 3500
都已经到期,需要依次执行。
检查到:
5000 > 3500
就可以直接停止。
因为后面的任务只会更晚,不可能已经超时。
除了执行任务之外,还可以计算距离下一个任务还有多久:
time_t TimeToSleep() {
auto iter = timeouts.begin();
if (iter == timeouts.end()) return -1;
time_t diff = iter->expire - GetTick();
return diff > 0 ? diff : 0;
}
例如:
当前时间:1000ms
最近任务:4000ms
那么:
TimeToSleep() = 3000ms
这个时间在网络服务器中非常有用。
五、定时器与 epoll 事件循环的配合
定时器经常和 epoll 一起使用。
epoll_wait 的第四个参数就是超时时间:
epoll_wait(epfd, events, maxevents, timeout);
例如:
epoll_wait(epfd, events, 64, 3000);
表示:
如果一直没有网络事件,最多等待 3000ms 就返回。
因此,可以把:
距离最近一个定时任务还有多久
直接作为 epoll_wait 的超时时间。
核心代码如下:
while (true) {
// 计算距离最近一个定时任务还有多少毫秒
int timeout = timer->TimeToSleep();
// 等待网络事件,同时利用 timeout 驱动定时器
int n = epoll_wait(epfd, events, 64, timeout);
// 获取当前时间
time_t now = Timer::GetTick();
// 处理网络事件
for (int i = 0; i < n; ++i) {
// 处理 socket 读写等事件
}
// 处理已经到期的定时任务
timer->HandleTimer(now);
}
整个运行流程实际上非常清晰:
添加定时任务
↓
按照 expire 排序保存
↓
找到最近到期的任务
↓
计算距离到期还有多久
↓
作为 epoll_wait 的 timeout
↓
epoll_wait 返回
↓
处理网络事件
↓
检查并执行超时任务
↓
继续下一轮循环
这样就不需要单独创建一个线程一直轮询:
while (true) {
检查定时器;
sleep(...);
}
而是直接把定时器融入事件循环中。
这也是网络服务器中比较常见的一种设计方式。
不过,使用 std::set 只是定时器的一种实现方式。真正需要管理大量定时任务时,还可以使用:
最小堆
红黑树
跳表
时间轮
它们解决的问题其实是一样的:
如何高效地管理大量定时任务,并快速找到即将到期的任务。
区别主要在于插入、删除、查找以及大量定时任务场景下的性能。
理解了本文的基本流程之后,再去学习最小堆、红黑树以及时间轮实现定时器,就会容易很多。