关于定时器的设计方案:红黑树,最小堆,时间轮的讲解

目录

一:定时器的概念以及应用

        1、定义:

        2、常见应用:

二:定时器的触发方式

        1、利用IO多路复用的超时参数

        2、抽象成fd

三:容器和检测触发机制的思考

        首先容器的排序可以按着触发时间以及执行序划分不同的定时器,前者利用红黑树,后者使用时间轮。先讨论红黑树,后者后面会讲。

        当然还有种数据结构也可以满足:最小堆(约束了父子之间的大小关系)。

四:设计定时器(基于红黑树的set容器)

        1、结构体

        2、结构体的比较排序

        3、添加定时器任务

                3.1:普通插入

                3.2:优化插入

        4、定时器的删除、执行、抽象fd

五:设计定时器(时间轮)

        1、定义

        2、时间轮的容器

        3、时间轮的类型

        4、检测触发机制

                4.1、添加结点

                4.2、重新映射


一:定时器的概念以及应用

        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

  • 30
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值