目录
一、定时器应用
二、定时器在工作中的场景
三、定时器的触发方式
1.网络事件和定时事件在一个线程中处理
2.网络事件和定时事件在不同线程中处理
四、定时器设计
1.接口设计
2.数据结构设计
五、红黑树
六、时间轮
1.从时钟运行看时间轮的原理
2.时间轮的使用场景
3.设计时间轮
定时器属于基础组件,不管是用户空间的程序开发,还是内核空间的程序开发,很多时候都需要有定时器作为基础组件的支持。常见的应用有:
总之,定时器是用来处理延时任务,即间隔多少秒后触发某个任务。
定时器在工作中的场景,常见的有以下几种:
这种情况下需要协同处理,即将定时事件杂糅到网络事件中一起进行处理。
为什么可以协同处理?
reactor是基于事件的网络模型,其IO处理是同步的,事件处理是异步的(因为注册事件和处理事件是在两个流程中,所以是异步的)。我们的定时任务的处理也是异步的,即我们可以使用reactor进行协同处理。
怎么协同处理?
利用IO多路复用“阻塞”收集就绪事件的接口,如epoll_wait/select/poll的最后一个参数timeout进行协同处理。
有哪些场景?
有什么特征?
对于此种触发方式,定时任务在单独的线程中检测,通常处理大量的定时任务。
那么它是怎么进行处理的呢?
// 初始化定时器
void init_timer();
// 添加定时器
Node* add_timer(int expire, callback cb);
// 删除定时器
bool del_timer(Node* node);
// 找到最近要触发的定时任务
Node* find_nearest_timer();
// 更新检测定时器
void update_timer();
// 清除定时器
// void clear_timer();
其中,find_nearest_timer()只在第一种触发方式中使用。
本质:按照定时任务的优先级进行组织,谁先执行谁的优先级就更高。定时器的组织方式有两类:
我们可以使用红黑树来实现定时器,其使用的触发方式为网络事件和定时事件在一个线程中处理。C++中实现了红黑树的容器有set、multiset、map和multimap等。此处我们使用set容器来实现定时器。
对于相同触发时间的定时任务,越后插入的,插入位置放在红黑树越右侧,这样可以保证后面插入的后执行。定时器实现代码参考如下:
#include
#include
#include
#include
#include
#include
using namespace std;
/*此结构用来唯一标识一个定时节点*/
struct TimerNodeBase {
time_t expire;//触发时间
int64_t id;//用来描述插入先后顺序
};
struct TimerNode : public TimerNodeBase {
using Callback = std::function;
Callback func;
TimerNode(int64_t id, time_t expire, Callback func) : func(func) {
this->expire = expire;
this->id = id;
}
};
/*比较仿函数,使用基类引用多态特性*/
bool operator < (const TimerNodeBase &lhd, const TimerNodeBase &rhd) {
if (lhd.expire < rhd.expire)
return true;
else if (lhd.expire > rhd.expire)
return false;
return lhd.id < rhd.id;
}
class Timer {
public:
/*
steady_clock 是单调的时钟,表示系统启动到当前的时间;只会增长,适合用于记录程序耗时;
system_clock 是系统的时钟;因为系统的时钟可以修改;甚至可以网络对时; 所以用系统时间计算时间差可能不准。
high_resolution_clock 是当前系统能够提供的最高精度的时钟;它也是不可以修改的。相当于 steady_clock 的高精度版本。
*/
static time_t GetTick() {
auto sc = chrono::time_point_cast(chrono::steady_clock::now());
auto temp = chrono::duration_cast(sc.time_since_epoch());
return temp.count();
}
TimerNodeBase AddTimer(time_t msec, TimerNode::Callback func) {
time_t expire = GetTick() + msec;
auto ele = timermap.emplace(GenID(), expire, func);//emplace 系列函数通过直接构造对象的方式避免了内存的拷贝和移动。
return static_cast(*ele.first);
}
bool DelTimer(TimerNodeBase &node) {
auto iter = timermap.find(node);
if (iter != timermap.end()) {
timermap.erase(iter);
return true;
}
return false;
}
bool CheckTimer() {
auto iter = timermap.begin();
if (iter != timermap.end() && iter->expire <= GetTick()) {
iter->func(*iter);
timermap.erase(iter);
return true;
}
return false;
}
time_t TimeToSleep() {
auto iter = timermap.begin();
if (iter == timermap.end()) {
return -1;
}
time_t diss = iter->expire-GetTick();
return diss > 0 ? diss : 0;
}
private:
static int64_t GenID() {
return gid++;
}
static int64_t gid;
set> timermap;
};
int64_t Timer::gid = 0;
int main() {
int epfd = epoll_create(1);
unique_ptr timer = make_unique();
int i =0;
timer->AddTimer(1000, [&](const TimerNode &node) {
cout << Timer::GetTick() << "node id:" << node.id << " revoked times:" << ++i << endl;
});
timer->AddTimer(1000, [&](const TimerNode &node) {
cout << Timer::GetTick() << "node id:" << node.id << " revoked times:" << ++i << endl;
});
timer->AddTimer(3000, [&](const TimerNode &node) {
cout << Timer::GetTick() << "node id:" << node.id << " revoked times:" << ++i << endl;
});
auto node = timer->AddTimer(2100, [&](const TimerNode &node) {
cout << Timer::GetTick() << "node id:" << node.id << " revoked times:" << ++i << endl;
});
timer->DelTimer(node);
cout << "now time:" << Timer::GetTick() << endl;
epoll_event ev[64] = {0};
while (true) {
/*
-1 永久阻塞
0 没有事件立刻返回,有事件就拷贝到 ev 数组当中
t > 0 阻塞等待 t ms,
timeout 最近触发的定时任务离当前的时间
*/
int n = epoll_wait(epfd, ev, 64, timer->TimeToSleep());
for (int i = 0; i < n; i++) {
/**/
}
/* 处理定时事件*/
while(timer->CheckTimer());
}
return 0;
}
时间轮的实现要重点关注两点:时间精度(1s)和时间范围(12)。
时间轮为什么要分成多个层级?
我们用tick表示当前时间,其取值范围为时间范围,我们只需要记录一个指针。当秒针移动一圈,说明下一分钟的任务快执行了。当分钟移动一圈,说明下一小时的任务快执行了。
任务节点需要包括expire(定时器触发时间)、callback(回调函数)和next(指向相同触发时间的任务)三个字段。
添加节点时需要根据time判断放在哪一层,并通过expire=time+tick0计算超时时间。
当下一分钟是任务快执行时,需要重新进行映射。
为什么需要重新映射?因为时间精度为秒,只执行秒针层的任务。分针层和时针层的任务如果需要执行都需要向上一层级重新映射。
怎么重新映射?
对于删除节点,我们可以怎么做呢?由于存在重新映射,节点的位置可能发生变化。所以我们不能直接删除节点。但是我们可以添加一个字段cancel,并赋值为true。当任务触发时,遇到这个标记就不执行具体任务。
包括内核、skynet、kafka和netty等。
需要考虑如下因素:
本专栏知识点是通过<零声教育>的系统学习,进行梳理总结写下文章,对c/c++linux系统提升感兴趣的读者,可以点击链接,详细查看详细的服务:
服务器高级架构体系:C/C++Linux服务器开发/后台架构师【零声教育】-学习视频教程-腾讯课堂