TT Lab
开始
学习 学习路径 课程

日志来自未来 — 时钟制造的五起事故

耗时算出来是负数

在 TT Lab 中继续学习

一句话总结

“现在几点”和“过了多久”应该去问不同的时钟。如果想用同一个时钟同时回答两者,在校准时钟的那天,经过的时间就会变成负数。

为什么需要它

计算经过时间的代码,通常长这样:开始时记下当前时间,结束时用当前时间减去它。这段代码几乎总能给出正确的值。出错的时刻,只有期间有人调整了时钟的时候。

而时钟是会被调整的。发现某台机器误差变大时,运维人员会强制同步;守护进程遇到较大的误差,会让时间一次性跳变。虚拟机从快照恢复,或从休眠中唤醒时,也会发生同样的事。横跨那一刻的任务,经过时间会变成负数,或者反过来膨胀好几分钟。

负数本身很显眼,这还算好的。真正的问题是使用这个值的代码。如果把锁的到期时间定为“现在 + 30 秒”,而时钟向前跳了 5 分钟,到期时间就变成已经过去,别人的锁会被释放。反过来,如果时钟向后跳了 5 分钟,30 秒的锁就会 5 分半钟都不释放。两种情况下,锁要保护的东西都被破坏了,而日志中却都不会留下“时钟”这个词。

工作原理

操作系统从一开始就把这两者分成了不同的时钟。clock_gettime(2) 的说明准确地写出了这种差别。

CLOCK_REALTIME    설정 가능한 시계. 1970-01-01 UTC 부터의 초를 센다.
                  관리자가 시각을 바꾸면 **불연속적으로 점프** 하고,
                  NTP 의 주파수 조정에도 영향을 받는다.
                  → "지금 몇 시인가" 에 답한다.

CLOCK_MONOTONIC   설정할 수 없는 시계. 리눅스에서는 부팅 이후의 초다.
                  시각을 바꿔도 **점프하지 않는다**(주파수 조정에는 영향을 받는다).
                  연속한 두 번의 호출이 뒤로 가는 일은 없다.
                  시스템이 잠들어 있던 시간은 세지 않는다.
                  → "얼마나 지났는가" 에 답한다.

CLOCK_BOOTTIME    CLOCK_MONOTONIC 과 같은데 절전 시간까지 포함한다.

该代码块中的韩文注释依次说明:CLOCK_REALTIME 是可以设置的时钟,从 1970-01-01 UTC 起计算秒数,管理员修改时间时会不连续地跳变,也受 NTP 频率调整的影响,回答的是“现在几点”;CLOCK_MONOTONIC 是不可设置的时钟,在 Linux 上是开机之后的秒数,修改时间时不会跳变(但受频率调整影响),连续两次调用不会倒退,不计入系统休眠的时间,回答的是“过了多久”;CLOCK_BOOTTIME 与 CLOCK_MONOTONIC 相同,但包含休眠时间。

最后一行其实很重要。在笔记本电脑或使用休眠的机器上测量经过时间时,CLOCK_MONOTONIC 不计入睡眠的时间,所以按挂钟时间已经过了三个小时,测得的经过时间却可能只有 10 分钟。要根据想测量的是什么来选择。

编程语言也原样暴露了同样的区分。Python 的 time 模块把 time.time() 和 time.monotonic() 分开,Go 的 time 包更是在 time.Now() 返回的值中一并带上单调时钟读数,两个 Time 相减时就使用它。可以说,语言替我们处理了需要选择的情形。

于是代码规则可以归结为:要显示或存储的时间用挂钟时间,要计算两个时间点之间间隔的值用单调时钟。留下记录时,把两者都写下来更好。挂钟时间是给人读的,单调时钟是给计算读的。

设置超时时也适用同一条原则。数据库的锁超时、HTTP 请求的超时、重试之间的等待,全都是“过了多久”,所以必须运行在单调时钟之上。反过来,证书有效区间或令牌过期,是要把“现在几点”与对方核对的问题,只能使用挂钟时间。那方面的内容在下一课。

在现场相遇的样子

分布式锁是这个问题代价最高的地方。锁服务用挂钟时间回答“这把锁 30 秒后过期”,持锁的一方也用挂钟时间来确认这个时间。如果两台机器的时钟不同,就会出现一段时间:一方认为锁仍然有效,另一方认为它已经过期。在这段时间里,两个进程会同时操作同一个资源。所以锁协议通常不传递绝对时间,而是传递剩余时间。

在观测指标中也会发生同样的事。用挂钟时间相减来测量响应时间的服务,在校准时钟的那天,直方图里会出现负数或大得离谱的值。大多数指标库会丢弃负数,或者把它们全部塞进最后一个桶里,无论哪种做法,当天的 p99 都会变成不可信的值。

最后,在代码评审中有一个可以识别的信号:代码完全没有考虑保存经过时间的变量里可能出现负数。像 if elapsed > timeout 这样的比较,遇到负数会悄悄通过。时钟倒退的那天,这段代码就成了没有超时限制的代码。

下一项测验要确认什么

确认两个时钟各自回答什么问题,单调时钟受什么影响、不受什么影响,以及锁和超时应该使用哪一个。