TimeWheel算法探索
2024.09.18 16:21浏览量:22简介:TimeWheel算法
本文从追溯时间轮算法的出现,介绍了时间轮算法未出现前,基于队列的定时任务实现,以及基于队列的定时任务实现所存在的缺陷。接着我们介绍了时间轮算法的算法思想及其数据结构,详细阐述了三种时间轮模型的数据结构和优劣性。
再次,我们介绍时间轮算法在 Dubbo 框架中的应用,并给出了它在 Dubbo 中的主要实现方式。
最后,我们以项目中的某个服务架构优化出发,介绍了目前设计中存在的缺陷,并借助来自中间件团队的,包含时间轮算法实现的延迟 MQ,给出了优化设计方法。
第一章 定时任务及时间轮算法发展
1.1 时间轮算法的出现
在计算程序中,定时器用于指定一个具体的时间点去执行某一个既定的任务。而时间轮算法就是这样一种能够实现延迟功能(定时器)的巧妙算法。时间轮算法首ci出现在 1997 年 George Varghese 和 Anthony Lauck 发表于 IEEE 期刊,名为“Hashed and Hierarchical Timing Wheels: Efficient Data Structures for Implementing a Timer Facility”的论文上。此文章指出,实现操作系统定时器模块的常规算法需要 O(n)的时间复杂度启动和维护计时器,对于更大问题规模 (n),这样的时间开销是巨大的,文中提出并证明了,通过一种环状桶的数据结构,可以做到使用 O(1)的时间复杂度,就可以启动,停止和维护计时器,并介绍了对时间间隔划分的处理,第一种方式是将所有的计时器时间间隔进行散列(Hash),这些时间间隔被散列到时间轮上特定的槽位中(Slot),第二种方式是利用多粒度定时轮组成具有层级结构的组合,以扩展更大的时间范围。这两种结构将在第二章中详细介绍。
1.2 基于队列的定时任务执行模型
在计算机的世界中,只有待解决的问题大规模化以后,算法的价值才能够得到最大化的体现。在介绍时间轮算法之前,我们有必要介绍另一种定时任务的实现,即基于队列的定时任务。队列这种数据结构无论是在操作系统中还是各编程语言如 Java 中都被大量使用,本文不再展开赘述。
下面从线程模型、定时任务种类和任务队列的数据结构三个方面展开详细介绍:
(1)线程模型
用户线程:负责定时任务的注册;轮询线程:负责从任务队列中扫描出符合执行条件的任务,例如任务的待执行时间已经到达,轮询线程将从队列中取出该任务,并交由异步线程池处理该任务。异步线程池:专门负责任务的执行。
(2)定时任务
定时任务主要分为一次性执行的定时任务(Dubbo 中超时判断)以及重复执行的定时任务,这两种定时任务都很好理解,一次性执行的定时任务在规定的未来某一时刻或距离现在的一段固定时长后执行,分别对应绝对值和相对值的概念。
而重复执行的定时任务是在一次性执行任务的基础上多次重复执行,这意味着,在上述线程协调工作中,当重复执行任务执行完成一次后,将被重新放回任务队列中。
(3)任务队列数据结构
从最简单的数据结构出发,假设我们选用最基本的队列,或者考虑到增减任务的方便,选择双向链表做为任务队列,为任务队列中的每个任务提供一个时间戳字段,这种实现的策略会产生哪些问题?
最大的问题是在查询上,假设任务队列中存在一些任务,那么为了找出达到规定时刻的待执行任务,轮询线程需要扫描全部任务,此种数据结构的时间复杂度为 O(n),而且存在大量的空轮询,即大部分的任务都没有达到执行时间,这种效率几乎是不可接受的。
为了提升查询效率,可以尝试从数据结构出发,利用有序队列,在计算机的算法中,有序性可以显著提高遍历的效率,这样一来,定时任务队列轮询线程从头向尾遍历时,在发现任意任务未达到规定执行时间戳后,就可以停止遍历。
但是维护有序性也需要付出代价,普通任务队列入队一个任务的时间复杂度仅仅是 O(1),而有序任务队列入队一个任务的时间复杂度为 O(nlogn)。其次,我们可以借鉴分治的思想,将任务队列分成 n 份,利用多线程遍历,在线程完全并发执行的情况下,问题规模简化到原来的 1/n。但是多线程也会 CPU 执行效率降低。
综上分析,定时任务框架需要具有如下要素:
严格高效的数据结构,并不能基于简单的队列结构来存储任务,否则轮询的执行效率永远无法提高。
简单的并发模型:CPU 的线程非常宝贵,不应占用过多线程资源。
时间轮算法解决了上述基于队列的定时任务执行模型的缺陷,因此时间轮算法思想在后面互联网技术发展中得到了大量应用,我们熟悉的 Linux Crontab,以及 Java 开发中常用的 Dubbo、Netty、Quartz、Akka、ZooKeeper、Kafka 等,几乎所有的时间任务调度都采用了时间轮算法的思想。
值得一提的是,在 Dubbo 中,为了增强系统容错,很多地方需要用到只需一次执行的任务调度,比如消费者需要知道各个 RPC 调用是否超时,而在 Dubbo 最开始的实现中,是采用将所有的返回结果(defaultFuture),都放入一个集合中,并通过一个定时任务,间隔扫描所有的 future,逐个判断是否超时。这样逻辑简单,但是浪费性能,后面 Dubbo 借鉴了 Netty,引入了时间轮。
第二章 时间轮算法思想介绍及应用场景介绍
2.1 时间轮简介
时间轮实质上是一种高效利用线程资源的任务调度模型,将大批量的任务全部整合进一个调度器中,从而对任务进行统一的调度管理,针对定时任务,延时任务等事件的调度效率非常高。
时间轮算法的核心是:第一章中描述的对任务队列进行轮询的线程不再负责遍历所有的任务,而是仅仅遍历时间刻度。时间轮算法好比指针不断在时钟上旋转、遍历,如果发现某一时刻上有任务(任务队列),那么就会将任务队列上的所有任务都执行一遍,这样便大幅度的减少了额外的扫描操作。
第一章中,我们提出了一个高效的定时任务框架需要具备严格高效的数据结构和简单的并发模型两个特点,而时间轮模型正是具备了这样的特点。
基于时间轮算法思想,后续也出现了很多种时间轮模型,目前流行的大致有三种,分别为简单时间轮模型、带有 round 的时间轮模型以及分层时间轮模型,下面将依次介绍这三种时间轮模型。
2.2 时间轮模型
2.2.1 简单时间轮模型
简单时间轮模型不再使用队列作为数据结构,而是使用数组加链表的形式(很经典的组合), 如下图所示,该时间轮通过数组实现,可以很方便地通过下标定位到定时任务链路,因此,添加、删除、执行定时任务的时间复杂度为 O(1)。
显然,这种简单时间轮就解决了任务队列中遍历效率低下的问题,轮询线程遍历到某一个时间刻度后,总是执行对应刻度上任 务队列中的所有任务(通常是将任务扔给异步线程池来处理),而不再需要遍历检查所有任务的时间戳是否达到要求。
通过增加槽(slot)的数量,可以细化的时间粒度以及得到更大的时间跨度,但是这样的实现方式有巨大的缺陷:
当时间粒度小,时间跨度大,而任务又很少的时候,时间槽的轮询效率变低。
当时间粒度小,时间槽数量多,而任务又很少时,很多槽位占用的内存空间是没有意义的。
2.2.2 带有 round 的时间轮模型
类比循环数组的思想,后人设计了带 round 的时间轮,这种时间轮的结构如下图所示:
expire 代表到期时间,round 表示时间轮要在转动几圈之后才执行任务,也就是说当指针转到某个 bucket 时,不能像简单的单时间轮那样直接执行 bucket 下所有的任务。而且要去遍历该 bucket 下的链表,判断时间轮转动的次数是否等于节点中的 round 值,只有当 expire 和 round 都相同的情况下,才能执行任务。
这种结构的时间轮明显减少了所需刻度的个数,即弥补了简单时间轮在时间槽位较多,而任务较少情况下内存空间浪费的问题。
但是这种结构的时间轮并不能减少轮询线程的轮询次数,效率相对较低。
2.2.3 分层时间轮模型
分层时间轮也是一种对简单时间轮的改良方案,它的设计理念可以类比于日常生活中的时钟,分别有时、分、秒三个层级,并且每个轮盘分别具有 24、60、60 个刻度,因此,只需要 144 个刻度,即可表示一天的时间,而这种表示方式的优势在于,倍数级别时间表示的新增,只需要常数级别的刻度增加。例如,在 144 个刻度可表示的一天时间的基础上,新增 30 个刻度,即可精细表示一个月的时间。
分层时间轮的工作方式为低层级的时间轮带动高层级的时间轮转动,图中箭头为任务的“下放”,例如,2 号 8 点 40 分 0 秒执行的任务,当天轮转动到刻度 2 时,会将第 2 天的任务,下放到对应时轮刻度为 8 的槽位中,当时轮转动到 8 时,会将任务继续下放到分轮刻度为 40 的槽位中,直至到最低层次的时间轮,转动到该槽位时,将该槽位中的任务,全部执行。
针对时间复杂度,这种时间轮对比带有 round 的时间轮不再遍历计算对比任务的 round,而是直接全部取出执行。
针对空间复杂度,分层时间轮利用维度上升的思路对时间轮进行分层,每个层级的时间粒度对应一个时间轮,多个时间轮之间进行级联协作。
发表评论
登录后可评论,请前往 登录 或 注册