Skip to content

Latest commit

 

History

History
89 lines (59 loc) · 7.74 KB

File metadata and controls

89 lines (59 loc) · 7.74 KB

timer实现原理

timerkafka层级时间轮的go实现. 插入, 删除, 扫描超时的时间复杂度接近O(1), 使用层级时间轮和延迟队列的实现, 通过延迟队列来推进时间轮, 从而避免了空推进问题. 支持单机百万级超时任务. 精度方面非常优秀.

有几个概念:

  • timingWheel: 是一个存储定时任务的环形队列, 底层采用数组实现. 每一个元素都是一个时间格(对应着一个spoke).
  • spoke: 桶或轮幅, 以下均称为轮幅, 轮幅有一个双向链表, 链表中的每一项都是taskEntry, 每一个taskEntry都封装了真正的定时任务task.
  • wheelSize: 每层时间轮的时间格的个数, 每个时间格对应一个spoke
  • taskEntry: 任务项都封装着真正的定时任务
  • task: 定时任务.

单层时间轮整体结构

在每一层时间轮中, 几个重要的概念

  • tickMs: 时间轮的基本时间跨度, 每个时间格代表一个时间跨度.
  • interval: 时间轮的总体时间跨度. 可以通过公式计算: tickMs * wheelSize
  • currentTime: 表盘指针, 绝对时间, 表示当前时间轮所处的时间. 对时间做了调整, 表示小于当前时间的最大时间跨度的整数倍. 假设当前时间戳为212ms, 当前时间轮基本时间跨度为20ms, 那么currentTime 就是小于212ms且20的整数倍, 即200ms. currentTime将整个时间轮划分为到期部分和未到期部分, currentTime 当前执行的时间格也属于到期部分, 表示刚好到期, 且需要处理当前时间格的spoke上所有任务.
  • overflowWheel: 指向更高级别的时间轮

下层时间轮的tickMs就是上层时间轮的interval. 例如, wheelSize为16, 则:

  • 第一层的tickMs为1ms, 所以第一层的 interval = 1ms * 16 = 16ms
  • 第二层的tickMs = 第一层的interval, 即16ms, 而第二层的 interval = 16ms * 16 = 256ms.
  • 第三层的tickMs = 第二层的interval, 即256ms, 而第二层的 interval = 256ms * 16 = 4096ms, 以此类推.

时间轮工作机制

NOTE: wheelSize采用16, tickMs采用1ms, 进行示例.

初始情况下, 表盘指针currentTime指向时间格0, 此时有一个定时为3ms的任务添加到时间轮, 会被放到时间格为3的spoke上.

p1

随着时间的推移, 表盘指针currentTime不断推进, 过了3ms之后, 当currentTime指向时间格3时, 就需要将对应的spoke上的任务进行到期操作.

p2

等会, 如何推进, 是每tickMs进行推进么? 这很有问题, 当推进1ms时, 到达时间格1, 这时提取对应spoke上的任务进行到期操作, 发现上面并没有任务, 存在空推进问题, 造成性能损耗, 这时借助DelayQueue来跳跃式推进时间轮, 跳过无任务的空时间格. DelayQueue是一个无界的阻塞队列, 用于存放实现了Delayed接口的对象, 它会根据对象的延迟时间来决定是否可以从中取出元素. 只有当对象的延迟时间到期后, 才能从队列中取出该对象.

现在我们引入DelayQueue来解决空推进问题, 同前面一样, 初始情况下, 表盘指针currentTime指向时间格0, 此时有一个定时为3ms的任务添加到时间轮, 会被放到时间格为3的spoke上. spoke持有一个未来到期的绝对时间expiration, 这时时间格3上的spoke会被加DelayQueue. 然后唤醒推进器从DelayQueue查看第一个元素, 根据spoke的延迟时间, 一直等到时间到期后, 推进器立即将currentTime推进并指向时间格3, 然后将对应spoke上的任务进行到期操作.

p3

假如此时有一个定时为5ms的任务插入, 会被放到时间格8中; 此时又有一个定时为15ms的任务插入, 那么会复用原来的时间格, 会被放到时间格2中. 此时时间格8, 时间格2的spoke添加到DelayQueue, DelayQueue会根据最近延迟到期的时间进行延迟等待, 一直等到时间到期, 才进行推进.

假如此时有一个定时为40ms的任务插入, 当前时间轮无法容纳, 应该如何处理? 这时采用层级时间轮的概念, 当任务的到期时间超过了当前时间轮所表示的时间范围时, 就会尝试添加到更高级的时间轮中. 按这情况, 这个任务会被插到第二层时间轮的时间格2中并将对应spoke添加到DelayQueue, 此时如何还有一个300ms的定时任务, 这个任务将被插到第三层时间格1中并将对应spoke添加到DelayQueue.

p4

时间轮降级

随着时间的推移, 当第三层时间轮时间格1对应的时间到期时, 原本设定为300ms的定时任务还剩下300-256=46ms, 此时还不能执行这个任务的到期操作, 这里就会进行一个时间轮的降级, 会将这个剩余时间为46ms的定时任务重新提交到层级时间轮. 此时第一层时间跨度仍然不够, 但是第二层时间跨度已经足够插入这个定时任务, 所以会将该任务添加到第二层时间轮的时间格2中([32,48)). 再过32ms之后, 当第二层时间格5对应的时间到期后, 由于还剩8ms, 该任务还无法执行到期操作, 会再次被降级放到第一层时间轮的时间格8中. 再经过8ms, 此任务真正到期, 最终执行相应的到期操作

TickMs/WheelSize默认参数选择考虑

wheelSize 1层
(tickMs/interval)
2层
(tickMs/interval)
3层
(tickMs/interval)
4层
(tickMs/interval)
5层
(tickMs/interval)
6层
(tickMs/interval)
7层
(tickMs/interval)
32 1ms(32ms) 32ms(1024ms ≈ 1.024s) 1024ms(32768ms ≈ 32.768s) 32768ms(1048576ms ≈ 17.48m) 1048576ms(33554432ms ≈ 9.32h) 33554432ms(1073741824ms ≈ 12.42d) 1073741824ms(34359738368ms ≈ 1.10y)
64 1ms(64ms) 64ms(4096ms ≈ 4.096s) 4096ms(262144ms ≈ 4.37m) 262144ms(16777216ms ≈ 4.66h) 16777216ms(33554432ms ≈ 12.42d) 33554432ms(1073741824ms ≈ 2.18y) -
128 1ms(128ms) 128ms(16384ms ≈ 16.384s) 16384ms(2097152ms ≈ 34.95m) 2097152ms(268435456ms ≈ 3.11d) 268435456ms(34359738368ms ≈ 1.10y) - -
256 1ms(256ms) 256ms(65536ms ≈ 65.536s) 65536ms(16777216ms ≈ 4.66h) 16777216ms(4294967296ms ≈ 49.71d) 4294967296ms(1099511627776ms ≈ 34.87y) - -
512 1ms(512ms) 512ms(262144ms ≈ 4.37m) 262144ms(134217728ms ≈ 1.55d ) 134217728ms(68719476736ms ≈ 2.19y) 68719476736ms(35184372088832ms ≈ 1115.7y) - -

计算给定最大时间t, 计算所需最小层级n和时间格m:

$$ n \geq \log_{\text{wheelSize}}\left(\frac{t}{\text{tickMs}}\right) $$

$$m = n * wheelSize$$

tickMs/wheelSize 30s
(最小层级/总时间格)
1天
(最小层级/总时间格)
30天
(最小层级/总时间格)
1年
(最小层级/总时间格)
1ms/32 3/96 6/192 7/224 7/224
1ms/64 3/192 5/320 6/384 6/384
1ms/128 3/384 4/512 5/640 5/640
1ms/256 2/512 4/1024 4/1024 5/1280
1ms/512 2/1024 3/1536 4/2048 4/2048

综合来看, wheelSize = 128占用较少的内存, 任务在高级别轮中分布得更加稀疏.

绝大多数任务为秒级, 这样,大多数落在层级2或层级3, 128ms~16.384s均落在层级2. 层级3的tickMs为16.384s, 这样更容易的将任务拆分放在相应的时间格. 在推进任务时, 将任务从高级轮向低级轮进行迁移, 任务数会更少. 任务更稀疏.