定时器设计

小捏哩 2026-03-17 09:03:49

定时器设计

目录

  • 定时器设计
  • 1.什么是定时器?
  • 2.定时器解决了什么问题?如何解决?
  • 3.定时器的应用场景
  • 4.常用数据结构选型
  • 4.1红黑树(std::multimap / std::set)
  • 4.2最小堆(std::priority_queue)
  • 4.3时间轮(Timing Wheel)
  • 4.4 跳表(Skip List)
  • 5.触发机制设计
  • 5.1基于 sleep / usleep
  • 5.2 基于多路复用超时参数
  • 5.3 基于 timerfd(Linux 专有)
  • 6.代码示例:基于 epoll 超时参数的定时器
  • 6.1 代码要点说明:
  • 7.进阶讨论
  • 7.1使用 timerfd 改进
  • 7.2多线程环境中的定时器
  • 7.3高性能定时器:时间轮
  • 7.4 精度与效率的权衡
  • 8.总结

1.什么是定时器?

定时器是一种用于组织和管理大量延时任务的基础模块。它允许程序在未来的某个时间点执行预先定义的操作,而无需让线程通过忙等(busy-wait)来消耗 CPU 资源。无论是操作系统内核还是应用层服务,定时器都是实现超时控制、周期性任务、心跳检测等功能的核心组件。

2.定时器解决了什么问题?如何解决?

在软件开发中,我们经常需要处理这样的场景:

  • 网络连接空闲超时后关闭
  • 技能冷却倒计时结束后可用
  • 延迟 5 秒后执行某操作

如果为每个任务启动一个独立的线程,并调用 sleep()usleep(),会导致大量线程阻塞,且线程切换开销巨大,无法支持大规模并发。定时器模块的核心价值在于:用单线程(或极少的线程)高效管理成千上万个延时任务

它通过两个关键设计实现这一目标:

  1. 合适的数据结构:将任务按触发时间组织起来,能够快速找到最近将要到期的任务。
  2. 精准的触发机制:在恰当时机唤醒线程处理到期任务,避免空转或频繁唤醒。

3.定时器的应用场景

  • 心跳检测:TCP Keep-Alive、应用层心跳包,定期检测对端是否存活。
  • 技能冷却/倒计时:游戏中的技能 CD、UI 倒计时显示。
  • 会话超时管理:HTTP 会话过期、数据库连接空闲超时。
  • 延迟任务:订单 30 分钟未支付自动取消、延迟消息队列。
  • 周期性调度:定时备份、日志轮转。

4.常用数据结构选型

定时器容器需要按时间戳有序存储任务,并支持高效的增、删、查(最近节点)。常见的选择有:

4.1红黑树(std::multimap / std::set)

  • 特点:有序关联容器,插入、删除、查找均为 O(log n)。
  • 优点:标准库直接可用,实现简单;能处理任意时间跨度的任务。
  • 缺点:每次插入和删除都要进行平衡调整,性能不如一些专用结构;获取最小节点需 O(log n)(但 begin()rbegin()是 O(1))。
  • 适用场景:任务数量不大(几千级别),对实时性要求不苛刻。

    请添加图片描述

4.2最小堆(std::priority_queue)

  • 特点:堆顶始终是最小元素(最近超时任务)。
  • 优点:插入 O(log n),获取堆顶 O(1),删除堆顶 O(log n)。
  • 缺点:删除非堆顶任务困难(需要自定义支持延迟删除);无法直接支持按 key 删除特定任务。
  • 适用场景:只需按时间顺序处理,不需要精确删除单个任务(可通过标记忽略)。

    请添加图片描述

4.3时间轮(Timing Wheel)

  • 特点:借鉴时钟刻度,将时间划分为多个槽(slot),每个槽挂载一个任务链表。指针按固定间隔转动,处理当前槽的任务。
  • 优点:插入和删除均为 O(1),性能极高;特别适合大量短时任务(如网络框架超时管理)。
  • 缺点:时间精度受槽间隔限制;跨度过大的任务需用多层时间轮(如 Kafka 中的多层时间轮)。
  • 适用场景:高性能网络服务器、实时系统,任务时间跨度相对集中。

4.4 跳表(Skip List)

  • 特点:基于概率平衡的有序链表,插入、删除、查找期望 O(log n)。
  • 优点:实现比红黑树简单,无锁编程友好。
  • 缺点:空间开销略大,工程实现较少。
  • 适用场景:需无锁并发的特殊环境。

5.触发机制设计

有了数据容器,还需要一种方式让线程在合适的时间醒来处理任务。常见的触发机制包括:

5.1基于 sleep / usleep

最简单的做法:每次取出最近任务的等待时间,调用 sleep(diff) 后醒来处理到期任务。但这种方式无法处理其他事件(如网络 IO),且 sleep 期间线程完全阻塞。

5.2 基于多路复用超时参数

epoll_waitselectpoll 都支持传入一个超时值。将最近任务的等待时间作为超时参数,当多路复用返回时,先检查是否有 IO 事件,再处理已到期的定时器。这是单线程事件循环(如 Redis、libevent)的经典做法。

5.3 基于 timerfd(Linux 专有)

Linux 提供了 timerfd_create 接口,可以将定时器抽象为一个文件描述符。当定时器到期时,该 fd 变为可读。我们可以把这个 fd 加入到 epoll 中,实现定时器与 IO 事件的统一管理。这种方式更自然,且支持高精度(纳秒级)。

6.代码示例:基于 epoll 超时参数的定时器

下面给出一个基于 std::multimapepoll_wait 超时参数的定时器实现。该示例演示了核心思想,但实际工程中可进一步优化(如使用时间轮、timerfd 等)。

#include <iostream>
#include <sys/epoll.h>
#include <map>
#include <unistd.h>
#include <functional>
#include <memory>
#include <chrono>
#include <errno.h>

class TimerNode {
public:
    friend class Timer;
    TimerNode(std::function<void()> callback, uint64_t time_out)
        : callback_(std::move(callback)), time_out_(time_out) {}
private:
    std::function<void()> callback_;
    uint64_t time_out_; // 绝对超时时间(毫秒)
};

class Timer {
public:
    ~Timer() {
        for (auto& pair : timer_map_) {
            delete pair.second; // 释放所有节点内存
        }
    }

    // 获取当前单调时间(毫秒)
    static uint64_t GetCurrentTime() {
        using namespace std::chrono;
        return duration_cast<milliseconds>(steady_clock::now().time_since_epoch()).count();
    }

    // 添加一个相对超时 diff(毫秒)的任务,返回节点指针以便删除
    TimerNode* AddTimeout(uint64_t diff, std::function<void()> cb) {
        auto node = new TimerNode(std::move(cb), GetCurrentTime() + diff);

        // 插入到 multimap 中,优化插入位置:如果新节点超时时间最大,则使用 hint 插入末尾
        if (timer_map_.empty() || node->time_out_ >= timer_map_.rbegin()->first) {
            // 使用 end() 作为 hint 插入末尾(对于 multitimap,end() 是合法 hint)
            auto it = timer_map_.emplace_hint(timer_map_.end(),
                                              std::make_pair(node->time_out_, node));
            return it->second;
        } else {
            auto it = timer_map_.insert(std::make_pair(node->time_out_, node));
            return it->second;
        }
    }

    // 删除指定的定时器节点(同时释放内存)
    void DelTimeout(TimerNode* node) {
        if (!node) return;
        auto range = timer_map_.equal_range(node->time_out_);
        for (auto it = range.first; it != range.second; ++it) {
            if (it->second == node) {
                timer_map_.erase(it);
                delete node; // 注意:node 被删除后外部不应再使用
                break;
            }
        }
    }

    // 获取距离下一个任务到期的等待时间(毫秒),若无任务返回 -1
    int WaitTime() {
        if (timer_map_.empty()) return -1;
        uint64_t now = GetCurrentTime();
        uint64_t next = timer_map_.begin()->first;
        int64_t diff = next - now;
        return diff > 0 ? diff : 0; // 如果已过期,立即返回 0
    }

    // 处理所有已到期的任务
    void HandleTimeout() {
        uint64_t now = GetCurrentTime();
        auto it = timer_map_.begin();
        while (it != timer_map_.end() && it->first <= now) {
            it->second->callback_(); // 执行回调
            delete it->second;        // 释放节点
            it = timer_map_.erase(it); // 移除并指向下一个
        }
    }

private:
    std::multimap<uint64_t, TimerNode*> timer_map_;
};

int main() {
    int epfd = epoll_create1(0);
    if (epfd == -1) {
        std::cerr << "epoll_create error" << std::endl;
        return -1;
    }

    Timer timer;
    int counter = 0;

    // 添加几个定时任务
    timer.AddTimeout(1000, [&]() {
        std::cout << "timeout 1: " << counter++ << std::endl;
    });
    timer.AddTimeout(2000, [&]() {
        std::cout << "timeout 2: " << counter++ << std::endl;
    });
    auto node = timer.AddTimeout(3000, [&]() {
        std::cout << "timeout 3: " << counter++ << std::endl;
    });

    // 删除第三个任务
    timer.DelTimeout(node);

    epoll_event events[512];

    while (true) {
        int n = epoll_wait(epfd, events, 512, timer.WaitTime());
        if (n == -1) {
            if (errno == EINTR) continue; // 被信号中断,继续
            std::cerr << "epoll_wait error" << std::endl;
            break;
        }
        // 这里可以处理 IO 事件(events),本示例无注册 fd
        // 处理到期的定时任务
        timer.HandleTimeout();
    }
    close(epfd);
    return 0;
}

6.1 代码要点说明:

  • **使用 std::multimap**:允许同一时刻有多个任务到期(键值重复)。
  • 绝对时间存储:将相对延迟转换为绝对到期时间,避免每次重新计算。
  • 插入优化:若新任务到期时间大于等于当前最大键,则使用 emplace_hint 插入末尾,提高性能。
  • 删除节点:通过节点指针从 multimap 中移除并 delete,避免内存泄漏。
  • WaitTime() 返回下一个任务剩余毫秒数,作为 epoll_wait 的超时参数。
  • 主循环:epoll 无任何 fd,仅依靠超时驱动定时器,实际项目中可同时监听 socket fd。

7.进阶讨论

7.1使用 timerfd 改进

Linux 下更优雅的做法是使用 timerfd,将每个定时器变为一个 fd,然后统一放入 epoll。这样就不需要手动计算超时时间,而且可以同时处理多个定时器。但注意每个 timerfd 对应一个定时器,若有大量任务则需管理多个 fd。一个折衷是只用一个 timerfd 设置为最近任务的到期时间,到期后再重新设置,类似 epoll 超时参数的思想。

7.2多线程环境中的定时器

当定时器任务可能耗时较长时,通常将任务投递给线程池执行,以免阻塞事件循环。同时需要保护共享容器(如 multimap)的并发访问,可以使用互斥锁或设计为无锁结构。

7.3高性能定时器:时间轮

对于海量连接(如百万级),红黑树 O(log n) 的插入删除开销变得明显。时间轮将时间离散化,插入删除均为 O(1),成为网络框架的首选(如 Netty、Kafka)。多层时间轮可以支持任意时间跨度的任务。

7.4 精度与效率的权衡

  • 高精度定时器(如毫秒级)需要频繁检查当前时间,可能影响性能。通常每轮事件循环只调用一次 GetCurrentTime
  • 如果定时器任务很少,可以使用简单的链表 + 排序,插入 O(n) 但 n 很小。
  • 实时系统可能需要 POSIX 定时器 timer_create,它基于信号或线程通知,精度更高。

8.总结

定时器设计是后端开发和系统编程的基础技能。一个优秀的定时器模块需要在数据结构、触发机制、并发模型之间做出权衡。
https://blog.csdn.net/qq_57951250/article/details/159047643?sharetype=blogdetail&sharerId=159047643&sharerefer=PC&sharesource=qq_57951250&spm=1011.2480.3001.8118

https://github.com/0voice

...全文
22 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

545

社区成员

发帖
与我相关
我的任务
社区描述
零声学院,目前拥有上千名C/C++开发者,我们致力将我们的学员组织起来,打造一个开发者学习交流技术的社区圈子。
nginx中间件后端 企业社区
社区管理员
  • Linux技术狂
  • Yttsam
  • 零声教育-晚晚
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告

请新加入的VIP学员,先将自己参加活动的【所有文章】,同步至社区:

【内容管理】-【同步至社区-【零声开发者社区】

试试用AI创作助手写篇文章吧