【C++】 定时器入门:从定时任务到 epoll 驱动

一、定时器到底是干什么的

在程序开发中,经常会遇到一些"过一段时间再执行"的任务,例如:

  • 客户端连接 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 只是定时器的一种实现方式。真正需要管理大量定时任务时,还可以使用:

复制代码
最小堆
红黑树
跳表
时间轮

它们解决的问题其实是一样的:

如何高效地管理大量定时任务,并快速找到即将到期的任务。

区别主要在于插入、删除、查找以及大量定时任务场景下的性能。

理解了本文的基本流程之后,再去学习最小堆、红黑树以及时间轮实现定时器,就会容易很多。

0voice · GitHub

相关推荐
亮_一个嵌入式新手1 小时前
C语言Day21
c语言·开发语言
kcuwu.1 小时前
第 1 课 · Hello, World 与一个 Go 程序的诞生
开发语言·后端·golang
php@king1 小时前
golang入门到精通
开发语言·后端·golang
Leo.yuan1 小时前
Flink + Kafka + Doris 之外的另一条路:FineDataLink 一体化实时数仓方案
开发语言·javascript·ecmascript
只睡四小时1 小时前
端侧推理实战:face-api.js 零后端人脸识别踩坑记
开发语言·javascript·ecmascript
某不知名網友1 小时前
C++ 深浅拷贝:从默认拷贝到 Rule of Five
java·开发语言
君顾11 小时前
外卖CPS小程序开发实战指南:从零到上线的完整流程
java·开发语言·外卖
blue_ice .1 小时前
DDS原理及简易实现
开发语言·经验分享·笔记·嵌入式硬件·fpga开发
励志不掉头发的内向程序员1 小时前
【LibreCAD 2D架构】鼠标点下的坐标为什么会被“吸”走?LibreCAD 对象捕捉系统解析
开发语言·c++·qt·学习·系统架构