仓颉 std.sync 定时器追平策略 CatchupStyle 详解:Burst、Delay、Skip 的语义与选型实战
仓颉 std.sync 定时器追平策略 CatchupStyle 详解:Burst、Delay、Skip 的语义与选型实战
本篇技术指南围绕仓颉标准库 std.sync 中的 CatchupStyle 枚举展开,深入讲解重复性定时器在任务执行超时场景下的三种"追平"策略(Burst、Delay、Skip)各自的调度语义、适用场景与源码级参数约束。读完本文,你将能够结合 Timer 的 repeat / repeatDuring / repeatTimes 接口,为不同业务形态的周期任务选择正确的追平策略,并理解底层定时器在线程调度与生命周期方面的行为边界。
追平问题的由来:重复定时任务为什么会"错过时间点"
要理解 CatchupStyle,首先要理解重复性定时任务的调度模型。在 std.sync 中,Timer 用于"在指定时间点或指定时间间隔后,执行指定任务一次或多次",其中重复性定时器会按照固定的 interval 周期反复调度关联的 Task。
但定时器的触发并不总是准时,原因有两方面:
- 调度延迟:官方文档明确说明,"系统繁忙时,Task 的触发时间可能会被影响。Timer 不保证 Task 的触发时间一定准时。Timer 保证 Task 的触发时间小于等于当前时间"。也就是说,任务可能被推迟触发,但绝不会提前触发。
- 任务自身超时:如果某一次 Task 的执行时间大于设定的触发间隔
interval,那么它在执行期间"错过"了后续若干个本应触发的时间点。
当这些错过的时间点积压下来时,定时器就面临一个必须回答的问题:如何处理这些已经被错过的时间点?是依次补跑、压缩间隔,还是直接丢弃? CatchupStyle 正是用来回答这个问题的枚举。
CatchupStyle 枚举定义与定位
CatchupStyle 定义于 std.sync 包中,官方给出的声明如下(见 std/sync/sync_package_api/sync_package_enums.md):
public enum CatchupStyle {
| Delay
| Burst
| Skip
}
其官方定位为"表示不同的重复性任务定时器需要使用的追平策略",它只在重复性定时任务的创建接口(repeat、repeatDuring、repeatTimes)中作为可选参数出现。在 std/sync 包的功能划分 中,它属于"定时器"能力组,与 Timer 配套使用。
原文档给出的三个枚举值及其核心语义如下:
| 名称 | 原型 | 说明 |
|---|---|---|
| Burst | Burst |
该策略下,每个任务的开始时间间隔固定,当任务执行时间大于设定的任务触发间隔时间时,依次执行错过的时间点上的任务,直到追平。 |
| Delay | Delay |
该策略下,上一次任务结束与下一次任务开始的时间间隔总是固定的,即下一次任务的开始时间 = 上一次任务的结束时间 + 设定的任务触发间隔时间。 |
| Skip | Skip |
该策略下,每个任务的开始时间间隔固定,当任务执行时间大于设定的任务触发间隔时间时,将跳过后面错过的时间点,以尽快追平。 |
三种追平策略逐一解析
Burst:依次补跑,直到追平
Burst 策略下,每个任务的开始时间间隔是固定的,即调度仍然按照理想的 interval 时间轴推进。当某次任务执行时间大于 interval 时,定时器不会丢弃错过的时间点,而是把错过的时间点上的任务依次执行一遍,直到追平。
用时间线来理解:假设 interval = 10s,理想触发点为 T0、T0+10s、T0+20s……若某次任务从 T0 执行了 25 秒(跨越了 T0+10s、T0+20s 两个时间点),则追平时会依次补跑这两个时间点对应的任务,之后再回到正常的 interval 节奏。
Burst 适合"定时任务之间有先后关系,需要依次执行任务"的场景(官方文档原话)。例如一系列顺序依赖的数据快照或批次处理,宁可追赶执行,也不能漏掉中间环节。
Delay:固定"结束到开始"的间隔
Delay 策略的语义与 Burst 完全不同:它不维护固定的开始时间点,而是保证"上一次任务结束与下一次任务开始的时间间隔总是固定的",即:
下一次任务的开始时间 = 上一次任务的结束时间 + 设定的任务触发间隔时间
因此,只要上一次任务执行完毕,间隔 interval 后下一次任务就会开始,任务之间永远不会互相挤压。代价是:任务的开始时间会随任务耗时不断向后漂移,不再落在固定的时间刻度上。
Delay 适合"不关心任务开始的时间点,需要两次任务执行之间的时间间隔固定"的场景。典型例子是健康检查、心跳上报或资源轮询:只要保证两次探测之间稳定间隔 interval,具体的开始时刻并不重要。
Skip:跳过错过的时间点,尽快追平
Skip 与 Burst 一样保持每个任务的开始时间间隔固定,但当任务执行时间大于 interval 时,它不会补跑,而是跳过后面错过的时间点,以尽快追平。
同样用上面的例子:interval = 10s,某次任务执行了 25 秒,那么 T0+10s、T0+20s 这两个时间点将被直接跳过,下一次任务直接安排在 T0+30s 这个时间点(或当前时间之后的最近理想时间点)执行,尽快回到正常的周期节奏。
Skip 适合"定时任务之间没有先后关系,可以忽略中间任务"的场景。例如周期性刷新缓存、更新指标等幂等性操作:中间漏掉几次无所谓,重要的是尽快恢复固定节奏。
策略对比速查表
| 维度 | Burst | Delay | Skip |
|---|---|---|---|
| 开始时间间隔 | 固定(按 interval 时间轴) |
不固定(随任务耗时漂移) | 固定(按 interval 时间轴) |
| 错过时间点的处理 | 依次补跑,直到追平 | 不产生错过时间点(结束后才计时) | 直接跳过,尽快追平 |
| 两次执行之间的间隔 | 可能被任务耗时拉长 | 固定等于 interval |
可能被任务耗时拉长 |
| 官方推荐场景 | 任务间有先后关系,需依次执行 | 不关心开始时间点,需固定执行间隔 | 任务间无先后关系,可丢弃中间任务 |
在 Timer 中配置追平策略:API 签名与参数约束
CatchupStyle 通过重复性定时器创建的三个静态方法传入,三者均以命名参数 style! 形式接受该枚举,默认值均为 Burst(详见 std/sync/sync_package_api/sync_package_classes.md 及同文件 repeatDuring、repeatTimes 小节):
public static func repeat(delay: Duration, interval: Duration, task: ()->Unit, style!: CatchupStyle = Burst): Timer
public static func repeatDuring(period: Duration, delay: Duration, interval: Duration, task: () -> Unit, style!: CatchupStyle = Burst): Timer
public static func repeatTimes(count: Int64, delay: Duration, interval: Duration, task: () -> Unit, style!: CatchupStyle = Burst): Timer
各参数取值约束与异常行为(以当前仓库文档为准):
- delay:从现在开始到 Task 首次执行的时间间隔,取值范围
[Duration.Min, Duration.Max];小于或等于Duration.Zero时 Task 将立即被执行。 - interval:两次 Task 之间的时间间隔,取值范围
(Duration.Zero, Duration.Max];小于或等于Duration.Zero时会抛出IllegalArgumentException。 - period(仅
repeatDuring):重复周期的最大持续时间,从 delay 之后开始计时,取值范围(Duration.Zero, Duration.Max];非法取值会抛出IllegalArgumentException。 - count(仅
repeatTimes):Task 最大执行次数,取值范围(0, Int64.Max];count <= 0或interval <= Duration.Zero时抛出IllegalArgumentException。 - style!:追平策略,默认
Burst。官方参数说明指出:"当 Task 执行时间过长时,后续任务执行时间点可能发生延迟,不同的追平策略适用于不同的场景"。
从文档示例看,显式指定策略时使用命名参数写法,例如 style: Delay。此外,重复性任务会一直执行,直到调用 cancel() 主动取消,或达到创建时指定的结束条件(repeatTimes 的 count、repeatDuring 的 period)。
实战示例:用 Delay 策略创建 3 次重复任务
std/sync/sync_package_api/sync_package_enums.md 给出了一个使用 Delay 追平策略的完整示例:创建一个 Timer,从创建开始 1 秒后开始执行任务,每间隔 1 秒执行下一次,共执行 3 次,并打印每次执行相对起点的时刻:
import std.sync.{Timer, CatchupStyle}
import std.time.{MonoTime}
main() {
let start = MonoTime.now()
Timer.repeatTimes(3, Duration.second, Duration.second, {=>
println("Tick at: ${MonoTime.now() - start}")
}, style: Delay)
sleep(Duration.second * 4)
0
}
对照 sync_package_classes.md 中参数相同、但未显式指定策略(即使用默认 Burst)的示例及其运行结果:
Tick at: 1s2ms855us251ns
Tick at: 2s5ms390us18ns
Tick at: 3s7ms935us552ns
可以推断:当 Task 本身执行耗时远小于 interval(即不触发"超时追平")时,Burst、Delay、Skip 三种策略的表现几乎一致,任务都会近似落在 1s、2s、3s 的时间点上;三者的差异只有在任务执行时间超过 interval 时才真正显现。这也是为什么选择策略前需要先评估任务的最坏执行耗时。
追平策略背后的 Timer 行为边界
理解了枚举本身,还需要知道它作用在什么样的定时器模型上,这决定了追平策略生效的边界。根据 Timer 类文档 及 sync_package_classes.md 的注意区块,以下几点与追平策略直接相关:
- Timer 隐式包含 spawn 操作:每个 Timer 会创建一个线程用于执行其关联的 Task,因此重复任务的调度与追平都发生在独立线程中,不阻塞创建者。
- 触发时间只晚不早:Timer 保证 Task 的触发时间小于等于当前时间(不会提前触发),系统繁忙时可能延迟——这正是错过时间点的来源。
- 异常会使 Timer 失效:Timer 不会主动捕获关联 Task 抛出的异常,只要 Task 存在未被捕获的异常,Timer 就会失效,后续调度(包括追平)停止。
- 生命周期与 GC:只有关联 Task 执行完毕或调用
cancel()后,Timer 生命周期才结束并可被 GC 回收;在重复任务持续执行期间,Timer 实例始终存活。
此外,Timer 实现了 Equatable<Timer> 与 Hashable 接口,提供 ==/!= 运算符与 hashCode(),可用于在集合中比较与存放定时器实例。
追平策略选型决策指南
结合官方文档对三种策略的适用场景说明,可以归纳出如下决策思路:
- 先评估任务最坏执行耗时与
interval的关系:如果任务几乎不可能超过interval,三种策略行为近似,使用默认的Burst即可。 - 如果任务可能超时,且中间任务不可丢弃(如顺序有依赖的批量处理),选择
Burst:依次补跑错过的时间点,保证不遗漏。 - 如果任务可能超时,且要求两次执行之间的间隔严格固定(如心跳、轮询、探测),选择
Delay:任务的开始时间随耗时漂移,但"结束到开始"的间隔恒为interval。 - 如果任务可能超时,且中间任务可以安全丢弃(如周期性刷新缓存、幂等更新),选择
Skip:跳过错过的时间点,尽快恢复固定周期。
更多定时器综合用法(一次性任务、重复任务配合 cancel() 停止)可参考 sync 包示例 中的 "Timer 的使用" 一节,其中演示了 Timer.once 与 Timer.repeat 的组合使用及完整运行输出。
参考与深入阅读
- CatchupStyle 枚举 API 文档:枚举声明、三种策略语义与 Delay 示例
- Timer 类索引文档:Timer 的成员函数与静态函数一览
- sync 包类文档(Timer 章节):
repeat/repeatDuring/repeatTimes完整签名、参数范围、异常与运行示例 - sync 包总览:
std.sync提供的原子操作、线程同步机制与定时器能力全景 - sync 包 README:包内各类 API 的速查索引
- sync 包示例:Timer 的一次性/重复性任务实战示例及运行结果