目录
首先容器的排序可以按着触发时间以及执行序划分不同的定时器,前者利用红黑树,后者使用时间轮。先讨论红黑树,后者后面会讲。
当然还有种数据结构也可以满足:最小堆(约束了父子之间的大小关系)。
一:定时器的概念以及应用
1、定义:
定时器是一种用于计时和调度任务的工具,它允许在特定的时间间隔内执行某个任务,或者在特定的时间点执行某个操作。在计算机领域,定时器被广泛应用于操作系统的任务调度、网络传输的控制、实时系统的处理等多个方面。
通俗来讲就是定时器中组织大量定时任务的模块,由容器和检测触发机制构成,其中容器组织大量定时任务,而检测触发机制需要检测最近要触发定时的任务。
2、常见应用:
网络中发送的心跳报文,玩游戏的时候释放技能后有冷却,手机中的倒计时等等,这些都使用了定时器。
二:定时器的触发方式
对于服务端来说,驱动服务器业务逻辑的事件包括网络事件、定时事件、以及信号事件;通常来说网络事件和定时事件会进行协同处理;
1、利用IO多路复用的超时参数
通过这句话咱们可以想到IO多路复用的epoll中的wait函数,他是将发生的事件给返回,其中最后一个参数是timeout,这就是一个定时器。比如当一个客户端10秒还没发送数据,咱们可以认为这个客户端死掉了,可以在服务端这里将客户端断开连接。
2、抽象成fd
在epoll中,他将定时器抽象成了一个fd,可以将这个fd放到epoll中进行管理,当定时器触发后,会返回这个事件,然后就可以处理这个函数了。
#include <sys/timerfd.h>
// 创建一个定时器文件描述符,可以像网络 IO 一样,将这个 fd 交由 IO 多路复用来管理
int timerfd_create(int clockid, int flags);
// 设置一个触发时间,IO 多路复用将会检测这个过期事件,然后通知应用程序定时事件就绪
int timerfd_settime(int fd, int flags,
const struct itimerspec *new_value,
struct itimerspec *old_value
);
三:容器和检测触发机制的思考
首先容器的排序可以按着触发时间以及执行序划分不同的定时器,前者利用红黑树,后者使用时间轮。先讨论红黑树,后者后面会讲。
这个定时器为什么要使用红黑树呢?首先可以想一下,定时器是不是按着时间的先后顺序进行执行,当我们检查第一个先插入进来的任务,如果第一个任务没有超时。那么他后面后插入进来的任务是不是都不会超时呢?显然是不会超时的,因此我们需要对第一个任务进行检测,当第一个任务超时,那就取出来执行任务,然后再次检测第一个。
而且插入任务先后顺序并不重要,重要的是时间的先后顺序,因此需要一个将他们的时间进行排序的数据结构,看样子红黑树比较满足。而底层使用红黑树的STL容器都有Map、Set、MultiMap、MultiSet。如果将他们封装成任务,并插入到这些容器中去,让他们根据时间自动排序的话,会得到一个我们需要的方案。
当然还有种数据结构也可以满足:最小堆(约束了父子之间的大小关系)。
1:是一颗完全二叉树;(可以数组来存储);
2:某一个节点的值总是小于等于它的子节点的值;
3:堆中任意一个节点的子树都是最小堆;堆中任意一个节点的子树都是最小堆;
最小堆的根节点永远是最小的,即使添加新结点和删除根节点,通过算法也可以满足最小堆,这样我们每次就可以读取根节点来怕判断是不是超时了。
四:设计定时器(基于红黑树的set容器)
1、结构体
为什么不将下面的内个结构体的操作放到上面Base中的结构体中呢?我们现在假设下面的操作是全部在上面的,那我们将这个节点插入到红黑树中,当继续插入节点后,红黑树不平衡了,需要通过旋转来维持平衡,这个时候,会将一些结点进行拷贝移动的操作。如果这些数据全部在一个节点中,那么会发生大量的数据拷贝和移动问题。
但是我们通过一个继承操作,将一些数据放在子类中,这样会提高性能。而且这样之后利用多态性质,父类的引用可以指向子类对象,通过这个性质,我们在容器结构体中进行对比操作就容易了。
//定时器类中的set容器
set<TimerNode, std::less<>> timeouts;
struct TimerNodeBase {
time_t expire; //当前时间
uint64_t id; //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->expire = expire;
this->id = id;
}
};
2、结构体的比较排序
通过上述的多态性质,父类引用可以指向子类,,这样无论是TimerNodeBase 和TimerNode 、TimerNode 和TimerNodeBase 、TimerNode和TimerNode、TimerNodeBase 和TimerNodeBase 这四种情况都可以进行对比操作了。
//在红黑树平衡的时候,需要进行对比,因此需要自己写一个用来对比的函数,这里重载了 < 用来对比
bool operator < (const TimerNodeBase &lhd, const TimerNodeBase &rhd) {
if (lhd.expire < rhd.expire) {
return true;
} else if (lhd.expire > rhd.expire) {
return false;
} else return lhd.id < rhd.id;
}
3、添加定时器任务
这里的添加是添加超时时间,和回调函数。例子直接使用了lambda表达式。GeTick是获取当前的时间。咱们先看if语句,首先判断是否为空,第二个是判断当前时间是不是小于容器最后一个的时间,这个先不管。
3.1:普通插入
里面使用了emplace,他和insert有什么区别呢,比如说你用insert插入一个结点的话,首先是先创建一个结点,然后找到内个对应的位置,然后再创建结点,然后拷贝过去,这样浪费很多空间。
而emplace是直接在对应的位置执行构造函数,这样避免了不必要拷贝或移动操作,性能开销较小。而GenID是一个自增长,不需要我们自己去给id赋值,保证唯一性。后面是move移动语义。这样就可以将一个结点插入进去。
3.2:优化插入
按理说只需要一个能正常插入的就好了,因为这个容器会自动排序,但是为什么还要优化一下呢,首先if内是判断当前的时间是否小于容器最后一个的时间。如果说我们现在按顺序插入十个心跳报文的定时器,那么他们肯定有时间的先后顺序,但是我们每次插入一个心跳报文,肯定在上一个报文的后面,但是你使用普通的插入,那么它每次搜索的时间就是O(logn)。
也就是说你每次插入的都是最大的时间,应该直接放到容器的最后,但是每次却进行搜索,因此不太合理,所以进行优化操作,如果插入的是最大的时间,那么直接插入到最后面,也就是下面优化的操作,插入到最后(crbegin)的位置。这里的就是O(1)了。
TimerNodeBase AddTimer(int msec, TimerNode::Callback func) {
time_t expire = GetTick() + msec;
if (timeouts.empty() || expire <= timeouts.crbegin()->expire) {
auto pairs = timeouts.emplace(GenID(), expire, std::move(func));
return static_cast<TimerNodeBase>(*pairs.first);
}
auto ele = timeouts.emplace_hint(timeouts.crbegin().base(), GenID(), expire, std::move(func));
return static_cast<TimerNodeBase>(*ele);
}
//使用例子lambda表达式传入回调函数
timer->AddTimer(1000, [&](const TimerNode &node) {
cout << Timer::GetTick() << " node id:" << node.id << " revoked times:" << ++i << endl;
});
4、定时器的删除、执行、抽象fd
这些函数不太重要,会使用就可以。
void DelTimer(TimerNodeBase &node) {
auto iter = timeouts.find(node);
if (iter != timeouts.end())
timeouts.erase(iter);
}
void HandleTimer(time_t now) {
auto iter = timeouts.begin();
while (iter != timeouts.end() && iter->expire <= now) {
iter->func(*iter);
iter = timeouts.erase(iter);
}
}
int timerfd = timerfd_create(CLOCK_MONOTONIC, 0);
struct epoll_event ev = {.events=EPOLLIN | EPOLLET};
epoll_ctl(epfd, EPOLL_CTL_ADD, timerfd, &ev);
这是网易的一道面试题,让当场写出定时器比较快的方案,比较好的方案是使用MultiMap,使用这个就可以不使用自增长的ID,因为可以存储相同时间,让时间成为Key,让回调函数成为Value。其实和这个也差不多。
五:设计定时器(时间轮)
1、定义
时间轮本质上是一个环形的数组,每个数组元素代表一个时间间隔,例如1秒、1分、1时等。时间轮通过指针的旋转来管理和执行定时任务,每个槽通常使用双向链表来存储该时间点需要执行的任务,以便高效地添加、删除和遍历任务。这么说可能不理解,上图!
![]() | ![]() |
2、时间轮的容器
左边是我们所说的时间轮,而右边是我们常见的钟表,时间轮的思路就是通过钟表来的。右边的钟表,每走60秒,分针就走一格,而每走60分钟,时针走一格,通过这个思路来理解时间轮。
我们定义时间轮从上往下的双向链表是秒、分、时。当秒走动,秒针往右边挪动,当走完60格,回到起点,然后分钟的双向链表向右挪动指针,和钟表的思路是一样的。其实通过这样的结构就可以包含半天的时间,如果只用一个数组存储半天的时间,那么这个数组的大小就是60*60*12,但是我们时间轮所占的大小是60+60+12,这两个相差很大。
3、时间轮的类型
分为单层级时间轮和多层级时间轮,多层级时间轮就有多个容器进行存储。
4、检测触发机制
首先我们已经了解了这个时间轮是怎么进行移动的了,下面讲添加结点和重新映射的问题。
4.1、添加结点
这个节点是通过时间进行分配层级的,tick是当前的时间,dis是超时时间,将这两相加得到需要存放的位置,假如时间为5000秒,那么需要将这5000秒进行拆分,拆成了1时23分20秒,那么将他进行映射到一小时那里。
struct timer_node {
struct timer_node *next;
uint32_t expire; //tick + dis
handler_pt callback;
uint8_t cancel;
int id;
};
4.2、重新映射
我们只有在第一层中取出过期任务,当第一层指针移动到哪,从该槽位取出所有过期任务,将任务分发给其他线程进行处理。上一层的指针移动一圈,下面一层的指针移动一格,当下面一层的指针指向了有任务的凹槽,那么该凹槽的时间会向上一层映射时间,直到映射到最顶层,通过指针指向并取出任务。
如何进行映射?比如咱们的5000秒,当过了一小时之后,这个小时的指针指向了1,那么被映射到这里的1时23分20秒会被重新映射到上面的分层,也就是将这个减去小时,变成了23分20秒,那么他被存放在了23分处,当分这一层的指针挪到了23分处,那么这里的时间也会被重新映射到秒层,也就是20秒,当秒针指向20,这个任务被取出进行执行操作。
对于时间轮的具体代码实现,以后会再讲的,感谢大家观看!https://xxetb.xetslk.com/s/2D96kH