定时器的实现方式有几种?

发布者:闪耀的星空最新更新时间:2025-09-16 来源: elecfans关键字:定时器  实现方式 手机看文章 扫描二维码
随时随地手机看文章

1 前言

在开始正题之前,先闲聊几句。有人说,计算机科学这个学科,软件方向研究到头就是数学,硬件方向研究到头就是物理,最轻松的是中间这批使用者,可以不太懂物理,不太懂数学,依旧可以使用计算机作为自己谋生的工具。这个规律具有普适应,再看看“定时器”这个例子,往应用层研究,有 Quartz,Spring Schedule 等框架;往分布式研究,又有 SchedulerX,ElasticJob 等分布式任务调度;往底层实现研究,又有不同的定时器实现原理,工作效率,数据结构…简单上手使用一个框架,并不能体现出个人的水平,如何与他人构成区分度?我觉得至少要在某一个方向有所建树:

  1. 深入研究某个现有框架的实现原理,例如:读源码

  2. 将一个传统技术在分布式领域很好地延伸,很多成熟的传统技术可能在单机 work well,但分布式场景需要很多额外的考虑。

  3. 站在设计者的角度,如果从零开始设计一个轮子,怎么利用合适的算法、数据结构,去实现它。

回到这篇文章的主题,我首先会围绕第三个话题讨论:设计实现一个定时器,可以使用什么算法,采用什么数据结构。接着再聊聊第一个话题:探讨一些优秀的定时器实现方案。

2 理解定时器

很多场景会用到定时器,例如

  1. 使用 TCP 长连接时,客户端需要定时向服务端发送心跳请求。

  2. 财务系统每个月的月末定时生成对账单。

  3. 双 11 的 0 点,定时开启秒杀开关。

定时器像水和空气一般,普遍存在于各个场景中,一般定时任务的形式表现为:经过固定时间后触发、按照固定频率周期性触发、在某个时刻触发。定时器是什么?可以理解为这样一个数据结构:

存储一系列的任务集合,并且 Deadline 越接近的任务,拥有越高的执行优先级

在用户视角支持以下几种操作:

NewTask:将新任务加入任务集合

Cancel:取消某个任务

在任务调度的视角还要支持:

Run:执行一个到底的定时任务

判断一个任务是否到期,基本会采用轮询的方式,每隔一个时间片 去检查 最近的任务 是否到期,并且,在 NewTask 和 Cancel 的行为发生之后,任务调度策略也会出现调整。

说到底,定时器还是靠线程轮询实现的。

3 数据结构

我们主要衡量 NewTask(新增任务),Cancel(取消任务),Run(执行到期的定时任务)这三个指标,分析他们使用不同数据结构的时间/空间复杂度。

3.1 双向有序链表

在 Java 中, LinkedList 是一个天然的双向链表

NewTask:O(N)

Cancel:O(1)

Run:O(1)

N:任务数

NewTask O(N) 很容易理解,按照 expireTime 查找合适的位置即可;Cancel O(1) ,任务在 Cancel 时,会持有自己节点的引用,所以不需要查找其在链表中所在的位置,即可实现当前节点的删除,这也是为什么我们使用双向链表而不是普通链表的原因是 ;Run O(1),由于整个双向链表是基于 expireTime 有序的,所以调度器只需要轮询第一个任务即可。

3.2 堆

在 Java 中, PriorityQueue 是一个天然的堆,可以利用传入的 Comparator 来决定其中元素的优先级。

NewTask:O(logN)

Cancel:O(logN)

Run:O(1)

N:任务数

expireTime 是 Comparator 的对比参数。NewTask O(logN) 和 Cancel O(logN) 分别对应堆插入和删除元素的时间复杂度 ;Run O(1),由 expireTime 形成的小根堆,我们总能在堆顶找到最快的即将过期的任务。

堆与双向有序链表相比,NewTask 和 Cancel 形成了 trade off,但考虑到现实中,定时任务取消的场景并不是很多,所以堆实现的定时器要比双向有序链表优秀。

3.3 时间轮

Netty 针对 I/O 超时调度的场景进行了优化,实现了 HashedWheelTimer 时间轮算法。

图片

HashedWheelTimer 是一个环形结构,可以用时钟来类比,钟面上有很多 bucket ,每一个 bucket 上可以存放多个任务,使用一个 List 保存该时刻到期的所有任务,同时一个指针随着时间流逝一格一格转动,并执行对应 bucket 上所有到期的任务。任务通过 取模决定应该放入哪个 bucket 。和 HashMap 的原理类似,newTask 对应 put,使用 List 来解决 Hash 冲突。

以上图为例,假设一个 bucket 是 1 秒,则指针转动一轮表示的时间段为 8s,假设当前指针指向 0,此时需要调度一个 3s 后执行的任务,显然应该加入到 (0+3=3) 的方格中,指针再走 3 次就可以执行了;如果任务要在 10s 后执行,应该等指针走完一轮零 2 格再执行,因此应放入 2,同时将 round(1)保存到任务中。检查到期任务时只执行 round 为 0 的, bucket 上其他任务的 round 减 1。

再看图中的 bucket5,我们可以知道在 $18+5=13s** 后,有两个任务需要执行,在 $28+5=21s** 后有一个任务需要执行。

NewTask:O(1)

Cancel:O(1)

Run:O(M)

Tick:O(1)

M:bucket ,M ~ N/C ,其中 C 为单轮 bucket 数,Netty 中默认为 512

时间轮算法的复杂度可能表达有误,我个人觉得比较难算,仅供参考。另外,其复杂度还受到多个任务分配到同一个 bucket 的影响。并且多了一个转动指针的开销。

传统定时器是面向任务的,时间轮定时器是面向 bucket 的。

构造 Netty 的 HashedWheelTimer 时有两个重要的参数:tickDuration 和 ticksPerWheel。

  1. tickDuration:即一个 bucket 代表的时间,默认为 100ms,Netty 认为大多数场景下不需要修改这个参数;

  2. ticksPerWheel:一轮含有多少个 bucket ,默认为 512 个,如果任务较多可以增大这个参数,降低任务分配到同一个 bucket 的概率。

3.4 层级时间轮

Kafka 针对时间轮算法进行了优化,实现了层级时间轮 TimingWheel

如果任务的时间跨度很大,数量也多,传统的 HashedWheelTimer 会造成任务的 round 很大,单个 bucket 的任务 List 很长,并会维持很长一段时间。这时可将轮盘按时间粒度分级:

图片

现在,每个任务除了要维护在当前轮盘的 round,还要计算在所有下级轮盘的 round。当本层的 round为0时,任务按下级 round 值被下放到下级轮子,最终在最底层的轮盘得到执行。

NewTask:O(H)

Cancel:O(H)

Run:O(M)

Tick:O(1)

H:层级数量

设想一下一个定时了 3 天,10 小时,50 分,30 秒的定时任务,在 tickDuration = 1s 的单层时间轮中,需要经过:$3246060+106060+5060+30** 次指针的拨动才能被执行。但在 wheel1 tickDuration = 1 天,wheel2 tickDuration = 1 小时,wheel3 tickDuration = 1 分,wheel4 tickDuration = 1 秒 的四层时间轮中,只需要经过 $3+10+50+30** 次指针的拨动!

相比单层时间轮,层级时间轮在时间跨度较大时存在明显的优势。

4 常见实现

4.1 Timer

JDK 中的 Timer 是非常早期的实现,在现在看来,它并不是一个好的设计。


  1. // 运行一个一秒后执行的定时任务

  2. Timer timer = newTimer();

  3. timer.schedule(newTimerTask() {

  4. @Override

  5. publicvoid run() {

  6. // do sth

  7.    }

  8. }, 1000);

使用 Timer 实现任务调度的核心是 Timer 和 TimerTask。其中 Timer 负责设定 TimerTask的起始与间隔执行时间。使用者只需要创建一个 TimerTask 的继承类,实现自己的 run 方法,然后将其丢给 Timer 去执行即可。


  1. publicclassTimer {

  2. privatefinalTaskQueue queue = newTaskQueue();

  3. privatefinalTimerThread thread = newTimerThread(queue);

  4. }

其中 TaskQueue 是使用数组实现的一个简易的堆,前面我们已经介绍过了堆这个数据结构的特点。另外一个值得注意的属性便是 TimerThread,一个 Timer 使用了唯一的线程负责了轮询和任务的执行。Timer 的优点在于简单易用,但也因为所有任务都是由同一个线程来调度,因此整个过程是串行执行的,同一时间只能有一个任务在执行,前一个任务的延迟或异常都将会影响到之后的任务。

轮询时如果发现 currentTime < heapFirst.executionTime,可以 wait(executionTime - currentTime) 来减少不必要的轮询时间。这是普遍被使用的一个优化。

  1. Timer 只能被单线程调度

  2. TimerTask 中出现的异常会影响到 Timer 的执行。

出于这两个缺陷,JDK 1.5 支持了新的定时器方案 ScheduledExecutorService。

4.2 ScheduledExecutorService


  1. // 运行一个一秒后执行的定时任务

  2. ScheduledExecutorService service = Executors.newScheduledThreadPool(10);

  3. service.scheduleA(newRunnable() {

  4. @Override

  5. publicvoid run() {

  6. //do sth

  7.    }

  8. }, 1, TimeUnit.SECONDS);

相比 Timer, ScheduledExecutorService 解决了同一个定时器调度多个任务的阻塞问题,并且任务的异常不会中断 ScheduledExecutorService。

ScheduledExecutorService 提供了两种常用的周期调度方法 ScheduleAtFixedRate 和 ScheduleWithFixedDelay。

ScheduleAtFixedRate 每次执行时间为上一次任务开始起向后推一个时间间隔,即每次执行时间为 : initialDelay, initialDelay+period, initialDelay+2*period, …

ScheduleWithFixedDelay 每次执行时间为上一次任务结束起向后推一个时间间隔,即每次执行时间为:initialDelay, initialDelay+executeTime+delay, initialDelay+2executeTime+2delay, ...

由此可见,ScheduleAtFixedRate 是基于固定时间间隔进行任务调度,ScheduleWithFixedDelay 取决于每次任务执行的时间长短,是基于不固定时间间隔的任务调度。

ScheduledExecutorService 底层使用的数据结构为 PriorityQueue,任务调度方式较为常规,不做特别介绍了。

4.3 HashedWheelTimer


  1. Timer timer = newHashedWheelTimer();

  2. //等价于 Timer timer = new HashedWheelTimer(100, TimeUnit.MILLISECONDS, 512);

  3. timer.newTimeout(newTimerTask() {

  4. @Override

  5. publicvoid run(Timeout timeout) throwsException {

  6. //do sth

  7.    }

  8. }, 1, TimeUnit.SECONDS);

前面已经介绍过了 Netty 中 HashedWheelTimer 内部的数据结构,默认构造器会配置轮询周期为 100ms,bucket 数量为 512。其使用方法和 JDK 的使用方式也十分相同。


  1. privatefinalWorker worker = newWorker();// Runnable

  2. privatefinalThread workerThread;// Thread

由于篇幅限制,我并不打算做详细的源码分析,但上述两行来自 HashedWheelTimer 的代码告诉了我们一个事实:HashedWheelTimer 内部也同样是使用了单个线程来进行任务调度。他跟 JDK 的 Timer 一样,存在”前一个任务执行时间过长,影响后续定时任务执行的问题“。

理解 HashedWheelTimer 中的 ticksPerWheel,tickDuration,对二者进行合理的配置,可以使得用户在合适的场景得到最佳的性能。

5 最佳实践

5.1 选择合适的定时器

毋庸置疑,JDK 的 Timer 使用的场景是最窄的,完全可以被后两者取代。如何在 ScheduledExecutorService 和 HashedWheelTimer 之间如何做选择,还是要区分场景来看待。

  1. ScheduledExecutorService 是面向任务的,当任务数非常大时,使用堆(PriorityQueue)维护任务的新增、删除会造成性能的下降,而 HashedWheelTimer 是面向 bucket 的,设置合理的 ticksPerWheel,tickDuration ,可以不受任务量的限制。所以在任务量非常大时, HashedWheelTimer 可以表现出它的优势。

  2. 相反,如果任务量少, HashedWheelTimer 内部的 Worker 线程依旧会不停的拨动指针,虽然不是特别消耗性能,但至少不能说: HashedWheelTimer 一定比 ScheduledExecutorService 优秀。

  3. HashedWheelTimer 由于开辟了一个 bucket 数组,占用的内存也会稍大。

上述的对比,让我们得到了一个最佳实践:在任务量非常大时,使用 HashedWheelTimer 可以获得性能的提升。例如服务治理框架中的心跳定时任务,当服务实例非常多时,每一个客户端都需要定时发送心跳,每一个服务端都需要定时检测连接状态,这是一个非常适合使用 HashedWheelTimer 的场景。

5.2 单线程与业务线程池

我们需要注意 HashedWheelTimer 使用的是单线程调度任务,如果任务比较耗时,应当设置一个业务线程池,将 HashedWheelTimer 当做一个定时触发器,任务的实际执行,交给业务线程池。

确保 taskNStartTime - taskN-1StartTime > taskN-1CostTime,则无需担心这个问题。

5.3 全局定时器

实际使用 HashedWheelTimer 时,应当将其当做一个全局的任务调度器,例如设计成 static 。时刻谨记一点:HashedWheelTimer 对应一个线程,如果每次实例化 HashedWheelTimer,首先是线程会很多,其次是时间轮算法将会完全失去意义。

5.4 为 HashedWheelTimer 设置合理的参数

ticksPerWheel,tickDuration 这两个参数尤为重要,ticksPerWheel 控制了时间轮中 bucket 的数量,决定了冲突发生的概率,tickDuration 决定了指针拨动的频率,一方面会影响定时的精度,一方面决定 CPU 的消耗量。当任务数量非常大时,考虑增大 ticksPerWheel;当时间精度要求不高时,可以适当加大 tickDuration,不过大多数情况下,不需要 care 这个参数。

5.5 什么时候使用层级时间轮

当时间跨度很大时,提升单层时间轮的 tickDuration 可以减少空转次数,但会导致时间精度变低,层级时间轮既可以避免精度降低,又避免了指针空转的次数。如果有长时间跨度的定时任务,则可以交给层级时间轮去调度。此外,也可以按照定时精度实例化多个不同作用的单层时间轮,dayHashedWheelTimer、hourHashedWheelTimer、minHashedWheelTimer,配置不同的 tickDuration,此法虽 low,但不失为一个解决方案。


关键字:定时器  实现方式 引用地址:定时器的实现方式有几种?

上一篇:定时器和数码管解析(上)
下一篇:激光器光闸的线是哪里 激光切割机光闸打不开什么原因

推荐阅读最新更新时间:2026-03-23 15:50

Linux驱动入门(五)阻塞方式实现按键驱动
一、阻塞方式实现按键驱动思路 二、内核的等待队列 三、注册中断 四、源码 五、测试 本文目标:实现一个阻塞的按键驱动程序,当应用调用read函数读取时阻塞等待,直到按键按下才放回 一、阻塞方式实现按键驱动思路 上一篇文章讲解非阻塞的方式实现按键驱动,而如果以非阻塞方式实现驱动的话,应用层就需要轮询地读取,这无疑是非常耗费cpu的,本文将修改上一篇文章的驱动程序,实现一个阻塞读取的按键驱动程序,下面开始将阻塞方式的实现思路 如果应用调用read函数要阻塞,那么说明驱动程序的button_read也要阻塞,那么谁来唤醒? 答案是中断程序,通过向内核注册中断,当检测到按键按下时,就触发中断,中断程序读取按键的值,并唤醒button_re
[单片机]
STM32 启动步骤和升级方式以及代码跳转的实现
#!/bin/sh #首先把BOOT0/Boot1 设置为 1 0, 即使用 STM32的ISP升级模式 #按下板子的reset, 硬复位进入 SYS ISP 模式(BOOTLOADER) #sudo stm32flash -w F407ZG_New.bin -v -g 0x0 /dev/ttyUSB0 sudo stm32flash -w F407ZG_Old.bin -v -g 0x0 /dev/ttyUSB0 #烧写以及验证完毕后, 自动加载 Flash的程序运行。 #把把BOOT0/Boot1 设置为 0 0, 即使用 STM32的flash模式,即用户程序模式。 #reset按键, 硬复位后自动启动新烧入的程
[单片机]
基于载波的SVPWM实现方式
1,基于载波SVPWM的理解 1.1 理论分析 三相逆变器拓扑结构如下: Fig1 三相逆变电路 不妨试着用倒推的方法进行理解。已知svpwm的电压利用率可达1。也就是说使用svpwm的调制方式,线电压的幅值可达Udc。 假设:Udc=1;选择载波范围为 见下图所示: 为了防止进入过调制区域,必须保证调制波范围为 。 基于载波的调制方式,画一个简图,如下: 根据上述假设,当调制波幅值不大于载波幅值,且载波频率远大于调制波频率时,理论上,调制输出的端电压波形(Fig1中的端电压为Ua/Ub/Uc)应该和调制波波形相同(幅值及相位均相等)。 因此,为了不进入过调制,端电压的幅值也需要被限制在 。 三相的端电压与相电
[嵌入式]
基于载波的SVPWM<font color='red'>实现</font><font color='red'>方式</font>
简易数字电压表+ADC0809+中断方式实现一路数据转换
1 实验现象 2 实验原理   ADC0809的工作过程:首先输入3位地址,并使ALE=1,将地址输入地址锁存器中。此地址经译码选通8路模拟输入之一到比较器。START上升沿将逐次逼近寄存器复位。下降沿启动ADC转换,之后EOC输出信号变低,指示转换正在进行。直到ADC转换完成,EOC变为高电平,指示ADC转换结束,结果数据已存入锁存器,这个信号可用作中断申请。当OE输入高电平时,输出三态门打开,转换结果的数字量输出到数据总线上。AD转换常用的软件控制方法有: (1)程序查询方式 首先由微处理器向A/D转换器发出启动信号,然后读入转换结束信号,查询转换是否结束,若结束则读取数据,否则继续查询,直到转换结束。该方法简
[单片机]
简易数字电压表+ADC0809+中断<font color='red'>方式</font><font color='red'>实现</font>一路数据转换
关于8051的bootloader实现方式
一,基本硬件需求 要实现IAP功能,需要51单片机可以在程序里修改代码空间的Flash,或者至少可以修改用户程序区的Flash,新出的51大部分都能满足这个要求 二,空间划分 一般bootloader位于单片机代码空间的起始地址,用户程序在后面。这个需要根据实际的需求来决定,bootloader功能简单,就少占用一些,bootloader功能复杂的就多占用一些。除此之外,一般还要根据Flash的页为界线划分。附带的工程模板里,bootloader使用0x0000-0x0fff区间,用户程序使用0x1000以后的空间。 三,中断的处理 51单片机的中断入口一般位于0地址开始的区间,无法修改,但是根据上面的空间划分方式,这个区间位于b
[单片机]
STM32开发板上实现按键驱动(定时扫描去抖方式
在万利STM32学习板的USB摇杆例程中,摇杆的按键处理并没有消抖处理,因此重新修改了摇杆的驱动,顺便还增加了两个按键以及摇杆中键下压的驱动,以方便直接使用。只要定时调用(几ms)KyeScan函数,就会将当前按键的改变情况和按住情况保存在对应的变量中。 当某个键按下时,在KeyDown中对应的位被设置为1;某个键被释放时,KeyUp中对应的位为1;KeyPress中保存的是当前按键的按住情况,某位为1时表示对应的键被按住。 KeyDown和KeyUp中的值使用后要手动清除,表示已经处理了这个事件,而KeyPress不用手动清除,它一直反映按键的按住情况。 万利的板子上有两个按键KEY_2和KEY_3,另外还有一个摇杆:K
[单片机]
PLC的工作方式是怎样的 plc是如何实现控制的 plc的输入和输出原理
  PLC的工作方式是怎样的   PLC的工作方式主要包括以下几个步骤:   输入信号采集:PLC通过输入模块采集输入信号,例如传感器信号、按钮信号、开关信号等等。   信号处理:PLC对输入信号进行处理,例如滤波、放大、反转等操作,以确保信号的准确性和稳定性。   扫描程序:PLC在内部设定的扫描周期内,按照程序的顺序执行各个程序段。程序段包括输入端口采集、运算处理、输出端口控制等等。   逻辑运算:PLC进行逻辑运算,根据程序设计,对输入信号进行处理,确定输出信号的状态。   输出控制:PLC通过输出模块控制输出信号,例如启动电机、控制灯光、输出报警信号等等。   监控和诊断:PLC具有监控和自诊断功能,能够实时监测系统的运
[嵌入式]
STM32F0使用LL库实现DMA方式AD采集
在本次项目中,限于空间要求我们选用了STM32F030F4作为控制芯片。这款MCU不但封装紧凑,而且自带的Flash空间也非常有限,所以我们选择了LL库实现。在本文中我们将介绍基于LL库的ADC的DMA采集方式。 1、概述 这次我们使用DMA方式实现对AD的采集,在遗忘我们使用HAL库和标准库都做过,这次我们使用LL库来实现。接下来我们简单了解一下STM32F030F4中的ADC和DMA。 首先看一看ADC,STM32F030F4是12位的ADC。它有多达19个多路复用通道,允许它测量来自16个外部和2个内部源的信号。各种通道的A/D转换可采用单通道、连续通道、扫描通道或不连续通道进行。ADC的结果存储在左对齐或右对齐的1
[单片机]
小广播
最新嵌入式文章
何立民专栏 单片机及嵌入式宝典

北京航空航天大学教授,20余年来致力于单片机与嵌入式系统推广工作。

厂商技术中心

 
EEWorld订阅号

 
EEWorld服务号

 
汽车开发圈

 
机器人开发圈

电子工程世界版权所有 京ICP证060456号 京ICP备10001474号-1 电信业务审批[2006]字第258号函 京公网安备 11010802033920号 Copyright © 2005-2026 EEWORLD.com.cn, Inc. All rights reserved