无时钟时间追踪:两种代码模式解析

本文探讨了在可靠系统中处理时间的复杂性,介绍了两种实用的代码模式:通过传递即时时间戳和固定频率的tick方法,避免直接使用系统时钟,简化测试并提高系统可靠性。

无时钟时间追踪

时间处理的挑战

时间看似简单(只需一个系统调用),但在可靠系统中正确处理实际上很棘手!我们不得不撰写博客文章、进行演讲并记录实现细节来解释所有复杂性。由于这种复杂性,通常不应直接使用语言标准库提供的时间。

全局统一时钟

最直接的模式是引入Clock接口,并要求代码传递时钟实例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
pub const Clock = struct {
    context: *anyopaque,
    vtable: *const VTable,

    const VTable = struct {
        monotonic: *const fn (*anyopaque) u64,
        realtime: *const fn (*anyopaque) i64,
    };

    pub fn monotonic(self: Clock) Instant {
        return .{
            .ns = self.vtable.monotonic(self.context)
        };
    }

    pub fn realtime(self: Clock) i64 {
        return self.vtable.realtime(self.context);
    }
};

完整接口可以工作,但有些复杂,因为需要安排时钟的存储。对于较小的组件,有两种技巧可以绕过这种复杂性。

当前时刻模式

通常,你只需要单个时间点,即此时此地。在这种情况下,不需要在整个组件上参数化Clock,而是可以向需要时间的方法传递now: Instant参数(调用站点依赖注入)。我们在自适应复制路由中使用此模式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
pub fn op_prepare(
    routing: *Routing,
    now: Instant,
    op: u64,
) void {
   // ...
}

pub fn op_prepare_ok(
    routing: *Routing,
    now: Instant,
    op: u64,
    replica: u8,
) void {
    // ...
}

副本传递操作准备时和备份确认时的now时间:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
self.routing.op_prepare(
    self.clock.monotonic(),
    prepare.header.op,
);

// 稍后

self.routing.op_prepare_ok(
    self.clock.monotonic(),
    prepare_ok.header.op,
    prepare_ok.header.replica,
);

ARR组件使用这两个实例来了解复制准备操作所需的时间,而无需显式时钟。这使得测试边缘情况行为(如时间溢出)变得容易,因为你甚至不需要想出假的溢出时钟,可以直接构造一个错误的Instant!

固定频率Tick模式

第二种模式与未来有关。你可能想用时间做的另一件事是安排稍后执行某些工作。这听起来很简单,但在测试中通常会变成小噩梦,因为"未来"的控制流很容易超出相关系统的生命周期。

这里的技巧是以固定节奏将时间推入系统。不需要传递时钟实例,而是在组件上定义tick方法:

1
2
3
pub fn tick(self: *Replica) void {
    ...
}

调用者需要定期调用它:

1
2
3
4
while (true) {
    replica.tick();
    try io.run_for_ns(constants.tick_ms * std.time.ns_per_ms);
}

当然,这只能提供非常粗粒度的时间分辨率,但对于安排未来工作的用例,通常不需要精确的时间!而且这里的控制流简化是巨大的!

总结

记住,时间是本质复杂性的核心,你需要认真对待它!虽然虚拟化整个时钟是一种通用方法,但通常nowtick模式就是你所需要的全部!

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计