无时钟时间追踪
时间处理的挑战
时间看似简单(只需一个系统调用),但在可靠系统中正确处理实际上很棘手!我们不得不撰写博客文章、进行演讲并记录实现细节来解释所有复杂性。由于这种复杂性,通常不应直接使用语言标准库提供的时间。
全局统一时钟
最直接的模式是引入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);
}
|
当然,这只能提供非常粗粒度的时间分辨率,但对于安排未来工作的用例,通常不需要精确的时间!而且这里的控制流简化是巨大的!
总结
记住,时间是本质复杂性的核心,你需要认真对待它!虽然虚拟化整个时钟是一种通用方法,但通常now或tick模式就是你所需要的全部!