github.com/benz9527/toy-box/algo@v0.0.0-20240221120937-66c0c6bd5abd/timer/README.md (about)

     1  # Why Timing Wheels
     2  
     3  ![时间轮动态图](./assets/1.gif)
     4  
     5  常规(传统)的任务队列调度,需要不断的轮询任务队列,然后进行调度。这种方式会存在两个问题:
     6  
     7  - 轮询的开销
     8  - 调度的精度
     9  
    10  尽可能不损失调度精度的前提下,减少调度的开销。 比如使用全局唯一的时间轮来进行批量化调度,充分利用协程/多线程资源,避免存在多个不同的调度中心,导致资源浪费。
    11  主要的表现是,时间轮可以大批量地管理各种延时任务和周期任务。
    12  
    13  # Timing Wheels Implementation vs Traditional Implementation
    14  
    15  ## Traditional Implementation
    16  
    17  - JDK Timer + DelayQueue(小根堆-优先级队列)
    18  - Java ScheduledThreadPoolExecutor(DelayQueue + thread pool)
    19  
    20  ## Timing Wheels Implementation
    21  
    22  hash table + linked list + ticker(心跳信号、节拍器、555 信号发生器)
    23  
    24  hash table + linked list + DelayQueue + channel + timer (轮询 DelayQueue, 在第一个快到期元素的差值大于 0 的时候,进行休眠,避免空转)
    25  
    26  - Kafka
    27  
    28  # 单层级时间轮
    29  
    30  这种实现可能会存在调度时间不精确的现象。
    31  
    32  # 多层级时间轮
    33  
    34  通过对时间进行层级划分的方式来提高精确度。
    35  
    36  # 实现细节讨论
    37  
    38  ## slot/bucket 如何划分时间范围?
    39  
    40  按照 kafka 的实现,timing wheel 的时间包括 startMs, tickMs, wheelSize, interval 和 currentTimeMs,到 slot 上就是 expirationMs。
    41  
    42  但是 kafka 的时间是靠 DelayQueue 在 timeoutMs 时间内把所有过期的 buckets 取出来,再取出内部的 tasks 进行调度和 reinsert 操作。
    43  
    44  在 golang 中,有 ticker 作为高精度的时间信号发生器来驱动时间轮的转动,所以不需要 DelayQueue 来进行过期的 bucket 的取出和 reinsert 操作。
    45  
    46  ticker 的精度就是至关重要了,如果精度不够,那么就会导致调度时间不准确,比如跨度大,会导致在 slot 里面比较远离 expirationMs 的任务被延后调度。
    47  
    48  例子:
    49  假设 tickMs = 10ms,它相当于 kafka 中的 interval,那么 slot 的划分就是 [0, 10), [10, 20), [20, 30) 这样的。
    50  
    51  如果 task 被设置的任务间隔是 2ms,那么它第一次应该是在 [0, 10) 的 slot 里面,但是触发调度的时间点却是在 10ms,这样就导致了调度时间不准确。
    52  
    53  虽然可以通过限制提交进入时间轮的最小时间间隔来避免这个问题,但这样会导致时间轮可面向的场景变少,比如无法支持 1ms 的时间间隔。
    54  
    55  ## slot/bucket 为什么要使用多层级?
    56  多层级的时间轮是为了方便存放更多的不同时间跨度的任务且减少 slot 的数量,跨度大的任务放在时间间隔大的时间轮上面,这操作是逐级向上找的,直到找到合适的时间轮。
    57  
    58  ## 时间轮的转动
    59  时间在向前流逝,就会让最底层时间轮的 slot 不断向前移动,同时也在不断更新所有时间轮的基准时间点,形成时间和时间轮同时向前流逝的效果。
    60  
    61  执行任务的时候,实际上只有最底层的 slot 里面的任务才会被执行,其他层级的 slot 里面的任务只是在时间轮转动的时候,把任务移动到下一层级合适时间间隔的 slot 里面。
    62  
    63  ## 为什么只有最底层的 slot 里面的任务才会被执行?
    64  因为最底层的 slot 里面的任务的时间间隔是最小的,所以它的时间间隔是最精确的,其他层级的 slot 里面的任务的时间间隔都是最底层的 slot 里面的任务的时间间隔的整数倍。
    65  
    66  ## 为什么每一层的时间轮可以不断地承载任务?
    67  
    68  使用(逻辑上/物理上)的环境数据结构,可以在 slot 移动过程中把已经执行的任务的 slot 变更为下一个周期可以使用的 slot(复用)。
    69  
    70  这里有一个闭环逻辑,底层时间轮的实际执行时间是要加上上层时间轮的周期的。
    71  
    72  时间轮就是靠空间换取一定的时间,查的快(时间短)就是要付出一定空间上的代价。
    73  
    74  ![移动](./assets/2.gif)
    75  
    76  ## slot/bucket 如何实现多层级?
    77  
    78  任务的提交涉及到时间跨度大的问题,比如秒级,分钟级,小时级,天级,月级,年级,这样的时间跨度,如果都放在一个时间轮里面,那么就会导致时间轮的大小很大,
    79  比如 365 * 24 * 60 * 60 * 1000 = 31536000000,这样的时间轮就会有 31536000000 个 slot,这样的时间轮是不现实的。
    80  
    81  所以需要对时间轮进行分层,比如秒级的时间轮,分钟级的时间轮,小时级的时间轮,天级的时间轮,月级的时间轮,年级的时间轮。
    82  
    83  但是这样又会导致时间轮的每一次层的 slot 数量不一致。
    84  
    85  但是实际上,设置相同的即可,因为相同的数量,在时间跨度小的任务非常多的时间轮上,可以减少 slot 的数量,虽然会导致在时间跨度大的时间轮上,要增加 slot 的数量。
    86  
    87  ![动态时间轮升级和降级](./assets/3.gif)
    88  
    89  ## task 分类
    90  - 一次性延迟执行任务。
    91    这种必然有一个距离当前时间的延迟间隔,假设为 delayMs,那么它的过期时间就是 currentTimeMs + delayMs
    92  - 小周期性执行任务。
    93    这种任务有一个固定的周期,假设为 periodMs,那么它的过期时间就是 currentTimeMs + periodMs
    94  - 大跨度的周期性执行任务。
    95    这种需要计算它的下一次过期时间 expiredMs,因为大跨度的执行,需要考虑年,月,日的变化(极端的时候
    96  还需要考虑到秒级别时间的补偿之类)
    97  - 周期间隔不连续的,而且次数有限制的任务。
    98    这种任务的周期间隔是不连续的,比如 1s,2s,3s,4s,5s,10s,20s,30s,40s,50s,60s
    99  - 周期间隔不连续,但是循环执行的任务。
   100    这种任务的周期间隔是不连续的,但是是循环执行的,比如 1s,2s,3s,4s,5s,10s,1s, 2s, 3s, 4s, 5s, 10s, 1s, 2s, 3s, 4s, 5s, 10s ...
   101  
   102  ## 参数说明
   103  - tickMs `u ms`
   104  - slotSize `n`
   105  - startMs 基准时间,时间轮被初始化的时间
   106  - interval = tickMs * wheelSize
   107  - slot (circle array,使用取余模拟循环效果)
   108  - task list (linked list,每个 slot 下挂载着对应的任务)
   109  - tasks map
   110    记录任务信息,方便取消任务;全局唯一
   111  - delay queue
   112      用于存放过期的任务,方便调度器调度;全局唯一;主要是存放 slot;不是任务过期,而是 slot 过期要清空或者重新分配它里面的任务
   113  
   114  ## slot 的切分
   115  令 u 为时间单元,一个大小为 n 的时间轮有 n 个桶,能够持有 n * u 个时间间隔的定时任务。
   116  那么每个 slot 都持有进入相应时间范围的定时任务。
   117  
   118  - 第一个 slot 持有 [0, u) 范围的任务
   119  - 第二个 slot 持有 [u, 2u) 范围的任务
   120  - 第 n 个 slot 持有 [u * (n - 1), u * n) 范围的任务
   121    随着时间单元 u 的数量持续增加,也就是 slot 不断地被移动,slot 移动之后其中所有的定时任务都会过期。
   122    由于任务已经过期,此时就不会有任务被添加大当前的 slot 中,调度器应该立刻运行过期的任务。
   123    因为空 slot 在下一轮是可用的,所以如果当前的 slot 对应时间 t,那么它会在 tick 后变成 [t + u * n, t + (n + 1) * u) 的 slot。
   124    简单地说,当前 slot 中的任务会移动到下一个间隔了 t ms 的 slot 中。
   125  
   126  slot 切分的间隔需要小一点,比如 2ms,5ms 这样的,防止间隔跨度大,导致调度时间偏移(不准确)
   127  
   128  # 待续
   129  
   130  ## 使用 SkipList + hash table 实现时间轮的 slot/bucket 的查找 (delay queue)
   131  
   132  ## 是否能使用 RedBlackTree 实现时间轮的 slot/bucket 的查找