一、定时器原理

1. 定时器是什么?

  • 一个用于组织、管理大量延时 / 定时任务的模块。它负责记录任务的“触发时间”,并在到期时触发相应回调。

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

  • 在不浪费 CPU / 线程的前提下,高效管理并触发大量定时任务

  • 具体来说,它需要解决以下三个关键问题:

    1. 高效检索:如何从成千上万的任务中,快速地找出下一个即将要触发的任务
    2. 高效操作:如何高效地添加新任务和删除(取消)已有任务。
    3. 高效等待:在等待下一个任务到期的时间段内,当前线程**不能通过“忙等待” 来空耗 CPU 资源,必须合理地让出 CPU。

3. 整体解决思路(两个要素)

  • 容器(数据结构):负责组织和索引所有定时任务(按触发时间排序或按执行顺序组织)。常见选择有红黑树(map/multimap)、最小堆(priority_queue)和时间轮(time wheel)。

  • 检测/触发机制:定期检查并触发到期任务。常见实现:把等待时间交给 IO 多路复用(epoll_wait 的 timeout 参数)或把定时器变成一个 fd(timerfd),由内核在事件就绪时通知。

4. 定时任务的异步性

  • 定时任务应异步执行,不应该在触发检测线程中做耗时/阻塞操作。通常的做法是:在检测到到期后把回调派发到工作线程池或让主循环仅做轻量操作,避免阻塞事件循环。

  • 触发时间的计算通常是 触发时间 = 当前时间 + 间隔(对于周期任务,触发后重新计算下一次触发时间)。

二、常用容器与特点

2.1 概述

1. 按触发时间排序的结构(利于快速获得最早到期项)

  • 红黑树(例如 C++ STL 的 map / multimap / set / multiset)

    • 优点:按时间完全有序;查找最小(begin())是 O(1);插入/删除是 O(log n);multimap 支持相同时间戳的任务。

    • 适合需要频繁任意删除、支持重复时间戳、且需要有序遍历的场景。

  • 最小堆(min-heap / priority_queue)

    • 优点:获取最小值 O(1),插入/弹出 O(log n);内存局部性好(数组实现)。

    • 缺点:任意位置删除(非根)最差 O(n),除非额外维护索引表(增复杂度)。适合只需“取最小并弹出”场景,不常做任意删除时好用。

2. 按执行顺序组织的结构

  • 时间轮(Time Wheel)

    • 思路:把时间划分为轮盘上若干槽(bucket),把延时按距离当前指针映射到对应槽里,指针按时间推进到某槽就触发槽内任务。

    • 优点:对于大量短周期、重复、或间隔固定的任务有极好的性能(接近 O(1) 插入与删除)。

    • 缺点:分辨率和最大可表达时间长度受限,复杂性较高,长时间跨度需分层时间轮。

3. 如何选?

  • 需要频繁任意删除或支持重复时间戳:multimap(红黑树)
    - Nginx 使用红黑树管理定时器
  • 只关心“最小并弹出”,不常删除中间元素:最小堆
    - libeventRedis 定时任务 都用最小堆管理。
  • 大量短周期、海量定时任务,比如数万/百万短时任务:时间轮

2.2 红黑树

在这里插入图片描述
(1) 基本概念

  • 容器结构

    • 一种 自平衡二叉查找树
    • 每个节点有颜色(红/黑),通过颜色规则保证近似平衡。
    • 根到叶子路径最长不会超过最短路径的两倍,因此查找性能稳定在 O(log n)。
  • 核心规则

    1. 每个节点要么是红色,要么是黑色。
    2. 根节点必须是黑色。
    3. 叶子节点(NIL,空节点)是黑色。
    4. 红色节点的子节点必须是黑色(不能有两个连续的红色)。
    5. 从任一节点到其所有叶子节点的路径上,黑色节点数相同。

    这些规则保证了树的“平衡性”,即树的高度不会超过 2*log(n+1)

  • 任务分配

    • 插入定时器时,以 超时时间为键 插入红黑树。
    • 红黑树始终保持有序,因此 最左节点 = 最早到期的定时器

(2) 分类与结构

  • 红黑树是 单一层次的平衡树,不像时间轮需要分层。
  • 每个节点存储:到期时间、回调任务、指向左右子树的指针。

(3) 检测触发

  • 触发逻辑
    • 每次取 最小键值节点(最左节点),检查是否到期。
    • 若到期则执行并删除;若未到期则等待(通常结合 epoll_wait 等 IO 复用机制一起使用)。

(4) 特点与复杂度

  • 插入任务:O(log n),树需要保持平衡。
  • 删除任务:O(log n),需要调整平衡。
  • 触发任务:O(1) 获取最小节点(树最左侧),删除时 O(log n)。

2.3 最小堆

在这里插入图片描述

(1) 基本概念

  • 容器结构

    • 一种 完全二叉树,通常用 数组存储
    • 节点值 ≤ 子节点值,根节点是最小值。
    • 特点:快速获取最小元素。
  • 任务分配

    • 插入定时器时,以 到期时间作为关键字 插入最小堆。
    • 根节点始终是最早到期的任务。

(2) 分类与结构

  • 完全二叉树(不同于红黑树的平衡二叉查找树)。
  • 数组存储更紧凑,空间局部性好。

(3) 检测触发

  • 触发逻辑
    • 检查堆顶元素(最小值),若到期则执行并删除。
    • 删除时需要 将最后一个元素移动到堆顶并下沉调整,保持堆性质。
    • 若未到期,则等待。

(4) 特点与复杂度

  • 插入任务:O(log n),需要上浮调整。
  • 删除任务(堆顶):O(log n),需要下沉调整。
  • 触发任务:O(1) 取最小值,但删除触发需要 O(log n)。
  • 删除任意任务:O(n),需要先定位任务,再调整堆。

2.4 时间轮

时间轮本质上是一个 环形数组(类似钟表),用来近似管理大量定时任务。它牺牲了一定的精度,换取了大规模定时器管理的高效性

在这里插入图片描述

(1) 基本概念

  • 容器结构

    • 一个环形数组(slots/buckets),每个槽对应一段时间。
    • 槽内存放链表,链表里挂的是定时任务。
    • 时间指针按固定步长(最小精度)向前移动,每移动一步表示经过了一个时间刻度。
  • 任务分配

    • 添加任务时,根据 (到期时间 - 当前时间) / 精度 计算出槽位置。
    • 任务被挂载到相应槽的链表里,等待指针走到该槽时触发。

(2) 分类与结构

  • 单层时间轮

    • 容器就是一个环形数组(如 60 个槽,1 槽 = 1 秒,能管理 60 秒内的定时器)。
    • 优点:简单、插入和删除近似 O(1)。
    • 缺点:可表示的时间范围有限(只能 60 秒)。
  • 多层级时间轮(Hierarchical Timing Wheel)
    在这里插入图片描述

    • 类似“分层钟表”。第一层表示秒,第二层表示分钟,第三层表示小时。
    • 超出第一层范围的定时器会“映射”到更高一层。
    • 优点:可以扩展管理大范围时间。
    • 缺点:实现复杂,需要重新映射。

(3) 检测触发

  • 指针移动

    • 按固定间隔(tick)推进。比如 tick=100ms,每 100ms 指针移动一格。
  • 触发逻辑

    • 当指针指向某个槽,就取出该槽的链表,触发所有到期任务。
    • 对于未到期(跨度超过一轮)的任务,指针经过时会进行 重新映射
      • 比如某个任务 70 秒后才触发,但时间轮只有 60 个槽。走到 10 秒时,会把它重新分配到更高层级。

(4) 特点与复杂度

  • 插入任务:O(1),计算槽位后链表头插即可。
  • 删除任务:O(1),需要任务节点保存指针才能快速移除,否则要 O(n)。
  • 触发任务:每次只处理一个槽内链表,复杂度与槽内任务数量成正比。

三、触发机制

定时器的“检测触发”是如何与事件循环(网络 IO)协作的核心。

3.1 使用 IO 多路复用的超时参数

使用 epoll_wait 最后一个参数

  • 方法:在进入 epoll_wait 前,计算“最近到期任务”的剩余时间 timeout = nearest_time - now,将 timeout 传给 epoll_wait(单位 ms)。

    • 如果 timeout < 0 可设置为 0(立即返回)或 -1 表示无限等待(无定时器)。
  • 优点:实现简单(不需要额外 fd);能把网络事件与定时器检测放在同一线程处理。

  • 缺点

    • 当有新的定时器被添加且其触发时间早于当前 timeout 时,必须唤醒正在 epoll_wait 的线程以便重新计算 timeout(通常通过 eventfd / self-pipe / 写入一个管道等方式唤醒 epoll)。否则新近到期的任务会被延迟。

    • 如果回调耗时或者阻塞,会影响事件循环,应把耗时工作交给工作线程。

  • 示例伪代码(单线程网络 + 定时器)

    while (!quit) {
    int timeout = get_nearest_timer() - now(); // ms
    if (timeout < 0) timeout = 0; // 或 -1 视情况
    int nevents = epoll_wait(epfd, events, MAX_EVENTS, timeout);
    for (i=0;i<nevents;i++) handle_event(events[i]);
    // 处理到期定时器
    update_timer(); // 会触发并删除已到期的回调(或派发到线程池)
    }
    

3.2 使用 timerfd

把定时器抽象为一个文件描述符

  • 核心思路:使用 timerfd_create() 创建一个 timerfd,把它加入到 epoll 中;内核在定时器到期时把该 fd 标记为可读,应用在 epoll 返回后读取该 fd(读取会返回一个 64 位的过期计数),从而触发定时器处理逻辑。

  • 优点:把定时器完全交给内核管理,内核负责准确唤醒,易于用在复杂的 epoll 架构中(不用自己计算并处理唤醒)。适合将定时事件也视为 IO 事件来统一处理。

  • 常用 API

    int timerfd_create(int clockid, int flags);
    
    • clockid
      • CLOCK_REALTIME:受系统时间修改影响
      • CLOCK_MONOTONIC:单调时钟,不受系统时钟改动影响,一般推荐使用这个。
    • flagsTFD_NONBLOCK(非阻塞读)、TFD_CLOEXEC 等。
    int timerfd_settime(int fd, int flags, const struct itimerspec *new_value, struct itimerspec *old_value);
    
    • flags=0:new_value.it_value 是相对时间;
    • TFD_TIMER_ABSTIME:绝对时间模式。
    • struct itimerspec 包含 it_value(首次触发时间)和 it_interval(周期间隔,0 表示只触发一次)。
      • 例如 it_value={5,0}it_interval={1,0} 表示:第一次 5 秒后触发,之后每 1 秒触发一次。
  • 读取read(timerfd, &u64, 8) 会得到自上次读取以来发生的触发次数(uint64_t)。在 ET边沿模式下要读尽,否则不会再次触发;非阻塞读返回 EAGAIN 表示无数据。

  • 推荐 CLOCK_MONOTONIC + ET 模式,并在处理完读取后执行或调度到期回调。

四、应用场景

  • 心跳检测 / keepalive:比如连接保活、检测客户端超时。

  • 应用层定时任务:倒计时、技能冷却、延时消息、延迟任务重试。

  • 服务器内部(如 redis / nginx):这些高性能服务使用定时器管理会话超时、定期维护等(实现上常用 epoll + timerfd 或自家高效时间轮)。

  • 为什么用 timerfd? 在高并发网络服务中,把定时事件也作为 IO 事件统一交给 epoll,可以更稳健地协调网络事件和定时事件的调度。

五、常见问题

  1. 有哪些常用触发机制?

    • IO 多路复用接口的 timeout 参数:如 select/poll/epoll_wait 的最后一个超时参数,可以作为“定时器触发”的粗粒度手段。

    • 专门的内核定时器接口:如 Linux 的 timerfd,到期后由内核生成可读事件,天然适合和 epoll 集成。

    • 用户态检测循环:主动 sleep/usleep 或 busy loop 检查(效率低,现代系统较少单独使用)。

    理论上所有带有 timeout 的接口都能用来做定时器检测,但不是所有都适合做高精度、高性能定时器。推荐使用 timerfd(或类似平台 API)结合 epoll,而不是依赖单纯的超时参数。

  2. 如果没有任何网络事件,epoll 会阻塞多长时间?

    • 传入 timeout = -1:无限期阻塞,直到有事件。
    • 传入 timeout = N 毫秒:阻塞到最早的事件或超时发生。
    • 监听了 timerfd:即使没有网络事件,到期时也会由内核唤醒。
  3. 从当前时间到最近要过期任务的时间点,这段时间线程应该做什么?

    1. 阻塞等待:直接在 epoll_wait(或 sleep)里等待,让出 CPU。

    2. 交给内核管理:通过 timerfd 注册定时器,当前线程可以继续处理其他任务,内核负责在到期时唤醒,稳定性和精度更好。

  4. 如何保证多线程环境下定时器线程安全又高性能?

    • 单线程绑定策略:把定时器和事件循环绑定到同一线程,避免加锁。

    • 分区/分域策略:每个工作线程/对象维护自己的定时器,减少共享和锁竞争。

    • 跨线程通知机制:若必须跨线程更新定时器,使用 eventfd 或消息队列通知“事件线程”来完成更新操作,减少直接竞争。

  5. 定时器回调内部又添加/删除定时器,如何避免迭代器失效或死锁?

    • 如果是基于 multimap / std::set:遍历时使用 it = container.erase(it) 这种安全删除方式。

    • 避免在回调中直接对定时器结构大规模修改:

      1. 回调里只记录“待添加/待删除”的操作请求。
      2. 由主循环统一在安全时机处理,避免迭代器失效。
    • 如果必须修改,确保使用线程安全容器或局部复制后再操作。

  6. 为什么不直接用 sleep/usleep 来实现定时器?

    • 这种方式阻塞整个线程,无法并发处理 IO 和定时器事件。
    • 精度较差(尤其是 sleep),并且不利于可扩展性。
    • 在现代高并发网络编程中,几乎都使用 事件循环 + 定时器容器内核定时器接口
  7. epoll 的超时参数和 timerfd 有什么区别?

    • epoll 超时参数:一次性,必须每次计算“最近要过期的定时器”和当前时间的差值传入。

    • timerfd:由内核维护,支持一次性/周期性,到期自动触发,直接当作 FD 用 epoll 管理。

Logo

葡萄城是专业的软件开发技术和低代码平台提供商,聚焦软件开发技术,以“赋能开发者”为使命,致力于通过表格控件、低代码和BI等各类软件开发工具和服务

更多推荐