【高并发服务器】二、时间轮定时器设计与实现

文章目录

Ⅰ. 简单的秒级定时任务实现

​ 在当前的高并发服务器中,我们不得不考虑一个问题,那就是连接的超时关闭问题,所以我们需要避免一个连接长时间不通信,但是也不关闭,空耗资源的情况。

​ 这时候我们就需要一个定时任务模块,定时的将超时过期的连接进行释放。其中我们会使用到 linux 系统提供的系统调用接口,如下所示:

创建定时器

c 复制代码
#include <sys/timerfd.h>
int timerfd_create(int clockid, int flags);
  • clockid
    • 该参数指定定时器进程的标志,有如下两个标志:
      • CLOCK_REALTIME:以系统时间为计时的基准值。这种方案不推荐,因为如果修改了系统时间就会出问题。
      • CLOCK_MONOTONIC:以距离系统启动的相对时间为基准值。推荐这种方案,因为定时器不会随着系统时间改变而改变。
  • flags
    • 0:默认阻塞属性
    • TFD_NONBLOCK:非阻塞式定时器
    • TFD_CLOEXEC :这和 open 函数中的 O_CLOEXEC 功能是一样的,是一种安全机制,可以确保被继承到子进程中的文件描述符不会被意外地传递给新的程序。
  • 返回值
    • 成功返回一个新的文件描述符。
    • 失败返回 -1,并且设置错误码。

​ 其实定时器的原理很简单,就是以设定的定时属性,每隔一段时间(定时器的超时时间),系统就会给这个文件描述符对应的定时器写入一个 8 字节的数据 ,每超时一次就往该 8 字节的数据上累加 1表示超时的次数

​ 所以我们在获取超时次数的时候,一般需要使用 uint64_t 类型去获取

设置定时器

c 复制代码
#include <sys/timerfd.h>
int timerfd_settime(int fd, int flags, const struct itimerspec *new_value, struct itimerspec *old_value);
  • fd
    • 就是上面 timerfd_create() 函数返回的文件描述符。
  • flags
    • 0:表示设置的超时时间是相对时间。
    • TFD_TIMER_ABSTIME:表示会将指定的时间解释为绝对时间,当当前时间达到指定的绝对时间时,定时器将触发事件。如果设置的绝对时间小于当前时间,即指定的绝对时间已经过期,那么定时器会立即触发事件。
  • new_value
    • 一个指向 struct itimerspec 结构体的指针,用于指定新的定时器参数。
  • old_value
    • 一个指向 struct itimerspec 结构体的指针,用于获取旧的定时器参数。可以为 NULL,表示不获取旧的参数。
  • 返回值
    • 成功返回 0 ,失败返回 -1

​ 其中 struct itimerspec 结构体定义如下所示:

c 复制代码
struct timespec {
    time_t tv_sec;   /*  秒  */
    long   tv_nsec;  /* 纳秒 */
};

struct itimerspec {
   struct timespec it_interval;  /* 定时器的触发间隔,第一次之后的超时间隔时间 */
   struct timespec it_value;     /* 定时器的初始值,即第一次超时时间 */
};

​ 下面做一个简单的测试:

cpp 复制代码
#include <iostream>
#include <sys/timerfd.h>
#include <unistd.h>
using namespace std;

int main()
{
    // 1. 创建定时器
    int timerfd = timerfd_create(CLOCK_MONOTONIC, 0);
    if(timerfd == -1)
    {
        cerr << "timerfd_create error" << endl;
        return -1;
    }

    // 2. 设置定时器
    struct itimerspec newtimer;
    newtimer.it_value.tv_sec = 1;    // 设置第一次超时的时间
    newtimer.it_value.tv_nsec = 0;
    newtimer.it_interval.tv_sec = 2; // 设置第一次超时后每次的超时间隔时间
    newtimer.it_interval.tv_nsec = 0;
    timerfd_settime(timerfd, 0, &newtimer, nullptr);

    // 3. 打印测试定时情况
    time_t presec = time(nullptr);
    while(true)
    {
        uint64_t timer; // 比如是8个字节的变量
        int ret = read(timerfd, &timer, 8);
        if(ret < 0)
        {
            cerr << "read error" << endl;
            return -1;
        }
        cout << "超时了!当前已经累积" << time(nullptr) - presec << "秒" << endl;
    }
    close(timerfd);
    return 0;
}

Ⅱ. 时间轮定时器

1、时间轮定时器的思想

​ 在上述的例子中存在一个很大的问题,每次超时都要将所有的连接遍历一遍,如果有上万个连接,效率无疑是较为低下的。

​ 这时候大家就会想到,我们可以针对所有的连接,根据每个连接最近一次通信的系统时间建立一个 小根堆,这样只需要每次针对堆顶部分的连接逐个释放,直到没有超时的连接为止,这样也可以大大提高处理的效率。

​ 虽然上述方法可以实现定时任务,但是这里给大家介绍另一种方案:时间轮。时间轮的思想来源于钟表,如果我们定了一个三点钟的闹铃,则当时针走到三的时候,就代表时间到了。

​ 同样的道理,如果我们定义了一个数组,并且有一个指针 ticktick 指向数组起始位置,并且 tick 每秒钟向后走动一步,走到哪里,则代表哪里的任务超时了,该执行对应的超时任务了 ,比如我们想要定一个三秒后的任务,则只需要将任务添加到 tick + 3 位置,则每秒中走一步,三秒钟后 tick 走到对应位置,这时候执行对应位置的任务即可。

​ 但是,同一时间可能会有大批量的定时任务,我们需要考虑如何在同一时刻支持添加多个定时任务,因此我们可以 使用二维数组 ,这样就可以在同一个时刻(也就是二维数组的同一列或者同一行)上添加多个定时任务了。

​ 而 时间轮数组元素数量其实代表的就是时间轮的一个周期长短 ,我们项目中设置的超时时间大概是 30 秒,所以只需要 30 个元素大小,然后每次走到数组末尾就从头开始,相当于是一个环形结构。

​ 但是有一个问题,就是如果要设置的时间很长,比如说一天,那么就需要 60 * 60 * 24 大小的数组,这显然是比较浪费空间的,其实我们只需要使用三个数组,两个 60 元素大小的数组分别表示秒和分,而 24 元素大小的数组表示时,然后每次先走的就是秒的数组,走到出界的话分数组就走一格,时数组也是如此,这种叫做 多层级的时间轮 !不过我们项目只需要 30 秒的时间,所以这里就不设计这种结构了!

​ 此时还有一个问题,就是一个非活跃连接有可能又开始交互了,此时我们是需要去重置这个非活跃连接的超时任务的,那么之前放在时间轮数组中的超时任务该怎么办呢❓❓❓

​ 这里就得使用 类的析构函数 + 智能指针shared_ptr 来帮忙了!

  1. 首先,我们使用类来封装一个超时任务,然后 将该超时任务放在类的析构函数中,当该类被销毁的时候,自然就会调用超时任务执行。
  2. 接着因为有需要刷新超时任务的需要,所以之前的超时任务就不应该被执行,最简单的做法就是跟根据刷新的任务的 id,然后去时间轮数组中找到对应的超时任务将其删掉。

​ 这种方法没毛病,但是效率却不怎么高,因为涉及到遍历和删除,所以我们要换个思路:每个超时任务类由 shared_ptr 管理,而时间轮数组存放的不再是超时任务类了,而是超时任务的管理者 shared_ptr 对象

​ 此时我们就不需要去删除原来的超时任务了,而是借用 shared_ptr 的计数器机制,为这个超时任务新生成一个执行该超时任务的 shared_ptr 对象,然后添加到时间轮数组中,此时该超时任务类的计数是大于一的,所以当前面的 shared_ptr 对象超时之后被释放了,也只是将该超时任务类的计数器减一而已,而不会去执行该超时任务类的析构函数,也就不会执行超时任务了!

而只有当 shared_ptr 的计数只剩一个的时候,并且还超时了,才会去释放超时任务类,调用超时任务

​ 此时有一个问题,因为新生成的 shared_ptr 需要指向前面的已经出现的指向超时任务类的 shared_ptr,才会让计数器加一,比如下面的例子:

cpp 复制代码
int* a = new int;
shared_ptr<int> p1(a);
shared_ptr<int> p2(a);  // 使用原始数据进行构造,则指向的空间是独立的,所以计数还是1
shared_ptr<int> p3(p1); // 使用p1来进行构造,所以和p1共同指向同一个空间,所以计数是2

​ 所以我们就必须要找到之前存在的指向超时任务类的 shared_ptr 对象,所以我们可以 使用一个哈希表来存储这些 shared_ptr 对象 ,并且用一个 id 来唯一标识每一个 shared_ptr 对象,这样子就能快速找到对应的 shared_ptr

​ 此时又引入了一个问题,如果我们直接用哈希表存放这些 shared_ptr 对象的话,计数器会偷偷的加一,导致对应的超时任务类永远都不会计数掉到 0 而调用析构函数,最后就导致了内存泄漏!

​ 为了解决这个问题,我们得派上助手 weak_ptr 来帮忙,让它来实际存储超时任务类,这样子就不会导致计数器的增加!

cpp 复制代码
#include <iostream>
#include <vector>
#include <memory>
#include <unordered_map>
#include <functional>
#include <unistd.h>

using func_t = std::function<void()>;        // 超时任务的函数类型,由使用者传入
using remove_t = std::function<void()>;      // 用于释放weak_ptr的函数类型,由TimerWheel传入
 
// 定时任务类,封装一个定时任务
class TimerTask
{
private:
    uint64_t _id;       // 当前超时任务类的ID
    uint32_t _timeout;  // 超时时间
    func_t _task;       // 超时任务
    remove_t _remove;   // 释放TimerWheel中的weak_ptr
    bool _cancel;       // 为true表示要取消任务,为false表示正常执行任务
public:
    TimerTask(uint64_t id, uint32_t timeout, const func_t& task) : _id(id), _timeout(timeout), _task(task), _cancel(false) {}

    ~TimerTask()
    {
        // 如果没有取消任务,才执行释放函数
        if(_cancel == false)
        {
            // 析构函数进行超时任务以及weak_ptr释放函数的执行
            _task();
            _remove();
        }
    }

    uint64_t get_id() { return _id; }
    uint32_t get_timeout() { return _timeout; }
    void set_remove(const remove_t& remove) { _remove = remove; }
    void set_cancel() { _cancel = true; }
};

// 时间轮类
class TimerWheel
{
    using shared_t = std::shared_ptr<TimerTask>;
    using weak_t = std::weak_ptr<TimerTask>;
private:
    int _tick;                                     // 当前的时间轮秒数,每一秒就往后走一步
    int _capacity;                                 // 时间轮数组大小,即时间轮的周期
    std::vector<std::vector<shared_t>> _wheel;     // 时间轮数组
    std::unordered_map<uint64_t, weak_t> _table;   // 保存所有定时任务对象的weak_ptr,这样才能在不影响shared_ptr计数器的同时,获取其shared_ptr
public:
    TimerWheel() : _tick(0), _capacity(60), _wheel(_capacity) {}

    // 时间运行函数
    void run_timer()
    {
        // 一秒钟走一步,每次将到达的位置处的shared_ptr进行清空,如果是最后一次任务的话会自动调用其析构函数进行释放
        _tick = (_tick + 1) % _capacity;
        _wheel[_tick].clear();
    }

    // 添加定时任务
    void add_timertask(uint64_t id, uint32_t timeout, const func_t& task)
    {
        // 1. 创建一个定时任务,由智能指针管理
        shared_t newtask(new TimerTask(id, timeout, task));
        if(newtask.get() == nullptr)
            return;
        
        // 2. 设置释放函数
        newtask->set_remove(std::bind(&TimerWheel::remove_timer, this, id));

        // 3. 向时间轮数组中添加定时任务
        int pos = (_tick + newtask->get_timeout()) % _capacity; // 注意需要取模,防止越界
        _wheel[pos].push_back(newtask);

        // 4. 将定时任务交给哈希表管理,记得要使用weak_ptr才不会导致计数增加
        _table[id] = weak_t(newtask);
    }

    // 刷新定时任务
    void refresh_timertask(uint64_t id)
    {
        // 1. 首先通过哈希表找到保存的超时任务的weak_ptr
        auto it = _table.find(id);
        if(it == _table.end())
            return;
        
        // 2. 通过weak_ptr构造一个shared_ptr出来
        shared_t refresh_task(it->second.lock());

        // 3. 将刷新任务添加到时间轮数组中
        int pos = (_tick + refresh_task->get_timeout()) % _capacity; // 注意需要取模,防止越界
        _wheel[pos].push_back(refresh_task);

        // 4. 将定时任务交给哈希表管理,记得要使用weak_ptr才不会导致计数增加
        _table[id] = weak_t(refresh_task);
    }

    // 取消定时任务
    void cancel_timertask(uint64_t id)
    {
        // 先判断在不在哈希表中
        auto it = _table.find(id);
        if(it == _table.end())
            return;

        // 先拿到shared_ptr,再通过其取消任务
        shared_t st(it->second.lock()); 
        if(st.get() != nullptr)
            st->set_cancel();   
    }
private:
    // 在哈希表中去除并且释放weak_ptr
    void remove_timer(uint64_t id)
    {
        // 先判断在不在哈希表中
        auto it = _table.find(id);
        if(it == _table.end())
            return;

        _table.erase(id);
    }
};

class Test
{
public:
    Test() { std::cout << "Test构造完成" << std::endl; }
    ~Test() { std::cout << "Test析构完成" << std::endl; }
};

void Delete(Test* t)
{
    delete t;
}

int main()
{
    std::unique_ptr<TimerWheel> tw(new TimerWheel);
    Test* t = new Test;
    tw->add_timertask(1, 5, std::bind(Delete, t));

    for(int i = 1; i <= 3; ++i)
    {
        sleep(1);
        tw->refresh_timertask(1);
        std::cout << "该任务被刷新了" << i << "次,需要在最新的5秒后才执行超时任务" << std::endl;
    }
    
    tw->cancel_timertask(1);
    while(true)
    {
        sleep(1);
        std::cout << "定时器执行中" << std::endl;
        tw->run_timer();
    }
    return 0;
}
相关推荐
心灵宝贝11 小时前
libopenssl-1_0_0-devel-1.0.2p RPM 包安装教程(openSUSE/SLES x86_64)
linux·服务器·数据库
emma羊羊13 小时前
【文件读写】图片木马
linux·运维·服务器·网络安全·靶场
迎風吹頭髮14 小时前
UNIX下C语言编程与实践32-UNIX 僵死进程:成因、危害与检测方法
服务器·c语言·unix
爱奥尼欧15 小时前
【Linux】网络部分——Socket编程 UDP实现网络云服务器与本地虚拟机的基本通信
linux·服务器·网络
liu****16 小时前
基于websocket的多用户网页五子棋(九)
服务器·网络·数据库·c++·websocket·网络协议·个人开发
liu****16 小时前
基于websocket的多用户网页五子棋(八)
服务器·前端·javascript·数据库·c++·websocket·个人开发
馨谙16 小时前
Linux中权限系统
linux·运维·服务器
云动雨颤18 小时前
Linux卡在emergency mode怎么办?xfs_repair 命令轻松解决
linux·运维·服务器
零基础的修炼19 小时前
Linux---进程信号
运维·服务器