【译】Proposal: Monotonic Elapsed Time Measurements in Go
在学习 Golang 定时器这一块时看到了 Russ Cox 大神的这篇 Proposal ,因为觉得这篇 Proposal 和对应的 Issue 讨论 比较有趣,就把这篇 Proposal 翻译学习一下,最后再讲下自己看了有关讨论的自己的一点感想。
整篇 Proposal 介绍了用于测量经过时间的单调时钟(Monotonic Clock),以及为了引入单调时钟对于 Go Time 的改造。
我英语算不上很好,然后在翻译过程中可能为了通顺加了些自己的理解,一些我认为比较关键或者难翻译的术语带上了原文英文并加粗标出。
译文如果您觉得有问题的地方,请及时回复提出。
- Title - Proposal: Monotonic Elapsed Time Measurements in Go
- Author - Russ Cox
- Url - https://golang.org/design/12914-monotonic
- Discussion - https://golang.org/issue/12914
目录
概要(Abstract)
目前,对于通过 time.Now
观察到的时间进行比较和减法运算时,如果在两次观察之间时系统墙钟(system wall clock)被重置,可能会返回不正确的结果。因此,在本篇 Proposal 我们建议扩展 time.Time
的表示,以保存用于这些计算的额外单调时钟读数(additional monotonic clock reading )。除了其他好处之外,这应该使得我们使用 time.Now
和 time.Since
进行基本的时间测量时不可能得到负持续时间(negative duration)或其他不符合现实的结果。
背景(Background)
时钟(Clocks)
一台时钟无法一直保持对显示时间的完美精准。最终,有人会注意到这个时钟累积的误差(与被认为更可靠的参考时钟相比)大到值得修正,便将时钟重置(reset)以匹配参考时钟。在我写这段话时,我手腕上的手表比我电脑上的时钟快了44秒。与电脑相比,我的手表每天快大约五秒。再过几天,我可能会因为误差而不耐烦,便会重新将手表对准电脑的时间。
我的手表可能不适合确定一场会议开始的准确时间,但它在测量经过时间(elapsed time)方面表现得相当好。如果我通过查看时间来开始计时一个事件,然后再次查看并减去两次时间来停止计时,那么手表速度带来的误差将小于 0.01%。
重置时钟会让它更适合报时,但在那一刻,却无法用于测量经过的时间。如果我在计时一个事件的过程中将手表重置以匹配电脑,那么它显示的时间会更准确,但通过减去事件的开始和结束时间来进行测量时,会包含重置的影响。如果我在计时一个 60 秒的事件时将手表回拨 44 秒,我会(除非我纠正这个重置)测量到该事件只用了 16 秒。更糟的是,我可能会测量一个 10 秒的事件为 −34 秒,事件结束的时间比开始的时间还早。
由于我知道手表每天稳定地快 5 秒,我可以通过让钟表匠调节机芯,使其稍微慢一些,从而减少重置的需要。我还可以通过更频繁地进行重置来减少每次重置的幅度。如果我每天定期 5 次让手表停走 1 秒钟,就不需要进行 44 秒的重置,从而减少事件计时中引入的最大可能误差。同样,如果我的手表每天慢 5 秒,我可以每天 5次将手表拨快 1 秒,以避免更大幅度的前进重置。
计算机时钟(Computer Clocks)
所有这些问题同样影响计算机时钟,只不过通常以更小的时间单位出现。
大多数计算机都有某种高精度时钟,并且有将该时钟的计时脉冲(tick)转换为等效秒数的方法。通常,计算机上的软件会将该时钟与通过网络访问的高精度参考时钟进行比较。如果观察到本地时钟稍微快一些,可以通过偶尔丢掉一个计时脉冲来稍微减慢它;如果稍微慢一些,可以通过将某些计时脉冲计数两次来加快它。如果观察到本地时钟相对于参考时钟以稳定的速度运行(例如每天快五秒),软件可以改变转换公式,使这些微小的校正不那么频繁。定期进行这些微调,可以使本地时钟与参考时钟保持一致,而不会有明显的重置,从而给人一种完美同步的外在表现。
不幸的是,许多系统在这方面无法达到上述完美的实现,主要有两个原因。
首先,一些计算机的时钟不可靠或者在计算机关机时完全不运行。这导致时间在一开始就非常不准确。在从网络上获取到正确时间后,唯一的校正选项是重置。
其次,大多数计算机时间表示忽略了闰秒,部分原因是闰秒与闰年不同,没有可预测的模式:国际地球自转服务(IERS)大约提前六个月决定是否在某个特定的日历月末插入(或理论上删除)一个闰秒。在现实中,闰秒 23:59:60 UTC
被插入在 23:59:59 UTC
和 00:00:00 UTC
之间。大多数计算机无法表示 23:59:60
,而是通过时钟重置并重复 23:59:59
来处理。
就像我的手表一样,重置计算机时钟使其更适合报时,但在那一刻却无法用于测量时间。在引入闰秒时,时钟可能会在一个瞬间显示 23:59:59.995
,然后在十毫秒后显示 23:59:59.005
;将这些时间相减来计算经过时间会得到 −990
毫秒,而不是 +10
毫秒。
为了避免在测量经过时间时发生时钟重置带来的问题,操作系统提供了访问两种不同时钟的途径:墙钟(wall clock)和单调时钟(monotonic clock)。两者都被调整为以每秒一个时钟秒的目标速率向前移动,但单调时钟从一个未定义的绝对值开始,从不重置。墙钟用于报时;单调时钟用于测量时间。
C/C++ 程序使用操作系统提供的机制来查询其中一个时钟。Java 的 System.nanoTime 被广泛认为是读取单调时钟(如果有的话),返回一个从任意起点开始计数的 int64
纳秒数。Python 3.3 在 PEP 418 中添加了对单调时钟的支持。新函数 time.monotonic
读取单调时钟,返回一个从任意起点开始计数的 float64 秒数;旧函数 time.time
读取系统墙钟,返回一个自 1970 年以来计数的 float64 秒数。
Go time
Go 语言当前的 time API 是由 Rob Pike 和我在 2011 年设计的,它定义了一个不透明的类型 time.Time
、一个返回当前时间的函数 time.Now
和一个用于减去两个时间的 t.Sub(u)
方法,以及其他将 time.Time
解释为墙钟时间的方法。Go 程序广泛使用这些方法来测量经过时间。然而,这些函数的实现仅读取系统墙钟,从不读取单调时钟,这使得在时钟重置的情况下测量结果不准确。
Go 的初始目标是谷歌的生产服务器,这些服务器上的墙钟从不重置:时间在系统启动的早期设置,在任何 Go 软件运行之前完成,并通过闰秒平滑(leap smear)处理闰秒,将额外的秒数分散到一个 20 小时的窗口中,在此期间时钟以 99.9986% 的速度运行(该时钟上的 20 小时相当于现实世界中的 20 小时 1 秒)。在 2011 年,我希望计算机时钟可靠且无重置的趋势会继续发展下去,并且 Go 程序可以安全地使用系统墙钟来测量经过时间。然而,我错了。尽管 Akamai、亚马逊和微软现在也使用闰秒平滑处理,但许多系统仍然通过时钟重置来实现闰秒。在最近一次闰秒期间,一个 Go 程序在测量负的经过时间时导致了 CloudFlare 的 DNS 中断。维基百科列举了与闰秒相关的问题示例,现在包括了 CloudFlare 的这次中断,并指出 Go 的时间 API 是根本原因。除了闰秒问题外,Go 还扩展到非生产环境的系统,这些系统的时钟可能调控较差,因此会更频繁地重置时钟。基于以上几点,Go 必须优雅地处理时钟重置问题。
Go 运行时和 Go time 包的内部最初使用的是墙钟时间,但已经尽可能地(在不改变导出 API 的情况下)转换为使用单调时钟。例如,如果一个 goroutine 运行 time.Sleep(1*time.Minute)
,然后墙钟向后重置了一小时,在最初的 Go 实现中,该 goroutine 会实际睡眠 61 分钟。而现在,该 goroutine 总是只会睡眠 1 分钟。所有其他使用 time.Duration
的时间 API,例如 time.After
、time.Tick
和 time.NewTimer
,也已类似地转换为使用单调时钟来实现这些时段。
然而,还有三个标准的 Go API 仍然使用系统墙钟,而它们更应该使用单调时钟。由于 针对 Go 1 的兼容性约定,这些 API 中使用的类型和方法名称无法更改。
第一个有问题的 Go API 是测量经过时间(measurement of elapsed times)。许多代码使用如下模式:
1 | start := time.Now() |
或等效地:
1 | start := time.Now() |
由于现在 time.Now
读取的是墙钟,如果墙钟在两次调用之间重置,那么这些测量结果就会出错,就像在 CloudFlare
发生的那样。
第二个有问题的 Go API 是网络连接超时。最初,net.Conn
接口包含一些方法,用于以持续时间的形式设置超时:
1 | type Conn interface { |
这个 API 让用户感到困惑:不清楚持续时间的测量是从设置超时时开始,还是从每次 I/O 操作开始。也就是说,如果你调用 SetReadTimeout(100*time.Millisecond)
,每次 Read 调用是在等待 100 毫秒后超时,还是所有 Read 调用在 SetReadTimeout
调用后的 100 毫秒后就停止工作?为了避免这种困惑,我们对 Go 1 的 API 进行了更改和重命名,使用以 time.Time
表示的截止时间:
1 | type Conn interface { |
这些方法几乎总是通过将持续时间添加到当前时间来调用,例如 c.SetDeadline(time.Now().Add(5time.Second))
,这种方式比 SetTimeout(5time.Second)
更长但更清晰。
在内部,net.Conn
的标准实现通过立即将墙钟时间转换为单调时钟时间来实现截止时间。在调用 c.SetDeadline(time.Now().Add(5*time.Second))
时,截止时间仅在添加当前墙钟时间以准备参数和在 SetDeadline
开始时再次减去之间的几百纳秒内以墙钟形式存在。即便如此,如果系统墙钟在这个微小的时间窗口内重置,截止时间将被重置的量延长或缩短,导致可能的挂起或虚假超时。
第三个有问题的 Go API 是上下文截止时间。context.Context
接口定义了一个返回 time.Time
的方法:
1 | type Context interface { |
Context
使用时间而不是持续时间,原因与 net.Conn
相同:返回的截止时间可能会被存储并偶尔参考,而使用固定的 time.Time
使得这些后续的参考指向固定的时刻,而不是浮动的时刻。
除了这三个标准 API 之外,标准库外的许多 API 也以类似的方式使用 time.Time
。例如,一个常用的指标收集包鼓励用户通过以下方式对函数进行计时:
1 | defer metrics.MeasureSince(description, time.Now()) |
显然,Go 必须更好地支持涉及经过时间(elapsed time)的计算,包括检查截止时间:墙钟会重置,并在 Go 运行的系统上引发问题。
对现有 Go 使用情况的调查表明,大约 30% 的 time.Now
调用(按源代码出现频率计算,而不是动态调用次数)用于测量经过时间(elapsed time),并且理应使用系统单调时钟。识别和修复所有这些问题将是一项庞大的工程,同时还需要对开发人员进行教育,以纠正未来的使用。
提案(Proposal)
为了向后兼容和简化 API,我们建议不要在 time 包中引入任何暴露单调时钟概念的新 API。
相反,我们建议:
- 更改
time.Time
,使其同时存储墙钟读数和可选的附加单调时钟读数(an optional, additional monotonic clock reading
); - 更改
time.Now
以读取两个时钟并返回包含两个读数的time.Time
; - 更改
t.Add(d)
以返回两个读数(如果存在)都已被d
调整的time.Time
; - 并更改
t.Sub(u)
以在t
和u
都有单调时钟读数时使用单调时钟时间进行运算。
通过这种方式,开发者始终使用 time.Now
,并使其实现(implementation)遵循以下规则:使用墙钟报时,使用单调时钟测量时间。
更具体地说,我们建议对 time 包的文档进行这些更改,并对实现进行相应更改。
在 time.Time
文档的末尾添加以下段落:
除了必需的“墙钟”读数外,Time 还可以包含当前进程的单调时钟的可选读数,以提供额外的比较或减法精度。详情请参阅包文档中的“单调时钟”部分。
将以下部分添加到包文档的末尾:
单调时钟
操作系统提供了“墙钟”,它会因时钟同步而重置,以及“单调时钟”,它不会重置。一般规则是,墙钟用于报时,而单调时钟用于测量时间。在本包中,time.Now
返回的 Time
同时包含墙钟读数和单调时钟读数;后续的报时操作使用墙钟读数,但后续的时间测量操作,特别是比较和减法操作,使用单调时钟读数。
例如,即使在计时操作期间墙钟被重置,下面的代码也总是会计算出大约 20 毫秒的正经过时间:
1 | start := time.Now() |
其他惯用法,例如 time.Since(start)
、time.Until(deadline)
和 time.Now().Before(deadline)
也同样能够抵抗墙钟重置的影响。
本节的其余部分详细说明了操作如何使用单调时钟,但了解这些细节并不是使用本包的必要条件。
time.Now
返回的 Time
包含一个单调时钟读数。如果 Time t 包含单调时钟读数,那么 t.Add(d)
、t.Round(d)
或 t.Truncate(d)
会将相同的持续时间添加到墙钟和单调时钟读数中以计算结果。同样,t.In(loc)
、t.Local()
或 t.UTC()
(定义为仅改变 Time 的 Location)会不修改地传递任何单调时钟读数。因为 t.AddDate(y, m, d)
是墙时计算,所以它总是会从其结果中去除任何单调时钟读数。
如果 Times
t 和 u 都包含单调时钟读数,则 t.After(u)
、t.Before(u)
、t.Equal(u)
和 t.Sub(u)
操作仅使用单调时钟读数,而忽略墙钟读数。(如果 t
或 u
之一不包含单调时钟读数,则这些操作使用墙钟读数。)
请注意,Go 的 ==
运算符在比较中包含单调时钟读数。如果 time.Now
返回的时间值和通过其他方式(例如,通过 time.Parse
或 time.Unix
)构造的时间值在用作 map 键时需要比较相等,则必须通过设置 t = t.AddDate(0, 0, 0)
去除 time.Now
返回的时间中的单调时钟读数。通常,优先使用 t.Equal(u)
而不是 t == u
,因为 t.Equal
使用可用的最精确比较,并且正确处理只有其中一个参数具有单调时钟读数的情况。
基本原理(Rationale)
设计(Design)
主要的设计问题是是否重载(overload) time.Time
或提供单独的 API 来访问单调时钟。
大多数其他系统提供单独的 API 来读取墙钟和单调时钟,让开发者在每次使用时做出选择,最好是遵循上述规则:“墙钟用于报时。单调时钟用于测量时间。”
如果开发者使用墙钟来测量时间,该程序几乎总是能正常工作,除了在罕见的时钟重置事件中。提供两个行为相同 99% 的时间的 API 很容易(而且很可能)让开发者写出一个仅在极少数情况下才会出错的程序,并且不会注意到这个问题。
更糟糕的是,这些程序故障不是随机的,比如竞争条件:它们是由外部事件引起的,即时钟重置。在运行良好的生产环境中,最常见的时钟重置是闰秒,这会同时发生在所有系统上。当它发生时,整个分布式系统中的所有程序副本会同时失败,破坏系统可能具有的任何冗余。
因此,提供两个 API 很容易(且很可能)让开发者写出几乎不会出错但通常会同时出错的程序。
这个提案则将单调时钟视为一个实现细节,而不是需要开发者学习的新概念,从而提高现有 API 测量时间的准确性。开发者不需要学习任何新东西,而直观的代码就能正常工作。实现会应用这个规则,开发者不必考虑它。
如前所述,对现有 Go 使用情况的调查(见下文附录)表明,大约 30% 的 time.Now 调用用于测量经过时间,应该使用单调时钟。同样的调查显示,所有这些调用都可以通过这个提案修复,而无需更改程序本身。
简易性(Simplicity)
从实现的角度来看,提供独立的例程(seperate routines)来读取墙钟和单调时钟并将正确的使用留给开发者,无疑是更简单的。在这个提案中的 API 在规范和实现上稍微复杂一些,但对开发者来说使用起来要简单得多。
无论如何,时钟重置的影响,特别是闰秒,可能会违背直觉。
假设一个程序在闰秒前的瞬间启动:
1 | t1 := time.Now() |
在 Go 1.8 中,程序可以打印:
1 | 23:59:59.985 10ms 23:59:59.995 -990ms 23:59:59.005 |
在上述提案的设计中,程序则打印:
1 | 23:59:59.985 10ms 23:59:59.995 10ms 23:59:59.005 |
虽然在两种情况下,第二个经过时间都需要一些解释,但我宁愿解释 10 毫秒而不是 -990 毫秒。最重要的是,在 t2 和 t3 调用 time.Now 之间实际经过的时间确实是 10 毫秒。
在这种情况下,23:59:59.005
减去 23:59:59.995
可以是 10 毫秒,即使打印的时间表明是 -990 毫秒,因为打印的时间是不完整的。
在其他设置中,打印的时间也可能是不完整的。假设一个程序在接近中午时启动,只打印小时和分钟:
1 | t1 := time.Now() |
在 Go 1.8 中,程序可以打印:
1 | 11:59 10ms 11:59 10ms 12:00 |
这很容易理解,尽管打印的时间表明持续时间为 0 和 1 分钟。打印的时间是不完整的:它省略了秒和子秒的分辨率。
假设程序在凌晨 1 点夏令时变化前启动。在 Go 1.8 中,程序可以打印:
1 | 00:59 10ms 00:59 10ms 02:00 |
这也很容易理解,尽管打印的时间表明持续时间为 0 和 61 分钟。打印的时间是不完整的:它省略了时区。
在原始示例中,打印 10 毫秒而不是 -990 毫秒。打印的时间是不完整的:它省略了时钟重置。
Go 1.8 的时间表示通过存储一个不受时区变化影响的时间,以及用于打印时间的附加信息,在时区变化时进行正确的时间计算。类似地,提案中的新时间表示通过存储一个不受时钟重置影响的时间(单调时钟读数),以及用于打印时间的附加信息(墙钟读数),在时钟重置时进行正确的时间计算。
兼容性(Compatibility)
Go 1 的兼容性使我们无法更改上述 API 中的任何类型。特别是,net.Conn
的 SetDeadline
方法必须继续接受 time.Time
类型,context.Context
的 Deadline
方法必须继续返回 time.Time
类型。由于这些兼容性限制,我们提出了当前的方案,但正如上文的基本原理中所解释的,这可能实际上是最好的选择。
如上文所述,大约 30% 的 time.Now
调用用于测量经过时间,并会受到此提案的影响。在我们检查过的每个案例中(见下文附录,本翻译忽略,见 Appendix),该提案的效果是消除由于时钟重置导致的测量结果不正确的可能性。我们没有发现任何现有的 Go 代码因改进的测量而受到破坏。
如果提案被采纳,应该在发布周期的开始阶段实现,以最大限度地增加发现意外兼容性问题的时间。
实现(Implementation)
在 time 包中的实现工作相当简单,因为运行时已经解决了在(几乎)所有支持的操作系统上访问单调时钟的问题。
读取时钟(Read the clocks)
精度(Precision):一般来说,操作系统提供不同的系统操作来读取墙钟和单调时钟,因此 time.Now
的实现必须依次读取两者。在调用之间时间会推进,这样即使没有时钟重置,t.Sub(u)
(使用单调时钟读数)和 t.AddDate(0,0,0).Sub(u)
(使用墙钟读数)也会略有不同。由于这两种情况都是减去通过 time.Now
获得的时间,可以说两者的结果都是正确的:任何差异都必然小于调用 time.Now
的开销。只有当代码同时进行这两种减法或比较时,这种差异才会出现。在现有 Go 代码的调查中(见下文附录 —— 本翻译忽略,见 Appendix),我们没有发现会检测到这种差异的代码。
在 x86 系统上,Linux、macOS 和 Windows 通过发布一页内存给用户进程,其中包含将处理器的时间戳计数器转换为单调时钟和墙钟读数的公式系数。这种情况下可以通过单次读取时间戳计数器并将两个公式应用于相同的输入来获得两个时钟的完美同步读数。如果我们认为在常用系统上消除这种差异很重要,这是一个选项。这将提高精度,但这是假精度,超出了实际调用的准确性。
开销(overhead):显然,让 time.Now
读取两个系统时钟而不是一个会有开销。然而,正如刚才提到的,这些操作的常见实现通常不会进入操作系统内核,因此两次调用的开销仍然相当低。我们可以应用相同的“同时计算(simultaneous computation)”来提高精度,同时也减少开销。
Time 数据结构表示(Time representation)
目前 time.Time
的定义是:
1 | type Time struct { |
为了添加可选的单调时钟读数,我们可以将表示法更改为:
1 | type Time struct { |
wall
字段可以编码墙钟时间,压缩到 33 位的秒数和 30 位的纳秒(将它们分开可以避免耗时的除法)。2^33 秒约为 272 年,因此 wall
字段本身可以将时间精确到纳秒,编码从 1885 年到 2157 年的时间。如果 t.wall
的最高位标志位被设置,那么墙钟秒数将按照上述方式压缩到 t.wall
中,而 t.ext
存储自 Go 进程启动以来以纳秒为单位的单调时钟读数(转换为进程启动时间可以确保我们可以存储单调时钟读数,即使操作系统返回的表示大于 64 位)。另一种情况(最高位标志位清除),t.wall
中的 33 位字段必须为零,t.ext
存储自公元 1 年 1 月 1 日以来的完整 64 位秒数,与原始的 Time 表示法相同。请注意,Time
零值时的含义与之前相同,保持不变。
这意味着单调时钟读数只能与 1885 年到 2157 年之间的墙钟读数一起存储。我们只需要在 time.Now
的结果及其派生的附近时间中存储单调时钟读数,并且我们预计这些时间会在 1885 年到 2157 年之间。范围的低端受限于系统使用无效时钟时的默认启动时间:在这种常见情况下,我们必须能够将单调时钟读数与墙钟读数一起存储。基于 Unix 的系统通常使用 1970 年,而基于 Windows 的系统通常使用 1980 年。我们不知道有任何系统使用更早的默认墙钟时间,但由于 NTP 协议纪元使用 1900 年,选择早于 1900 年的年份似乎更具前瞻性。
在 64 位系统上,当前表示法中 nsec
和 loc
之间有一个 32 位的**填充间隙(padding gap)**,新表示法填补了这一间隙,使整体结构大小保持为 24 字节。在 32 位系统上,没有这样的间隙,整体结构大小从 16 字节增加到 20 字节。
(完,附录对于time.Now
使用的统计见原文)
关于 Monotropic 的讨论
通过这篇 Proposal 可以看到当前 time.Time
目前定义声明的由来(wall
与 ext
),以及在 Golang 中墙钟与单调时钟的一些设计考量。
有趣的是在可以被认为是这篇 Proposal 缘起的讨论(https://github.com/golang/go/issues/12914)中,Ross Cox 起先并不认为需要为 Golang 添加单调时钟的功能,并认为 Google 并不存在闰秒的问题(个人觉得他这段回复不是很友善),因此也遭到很多人包括 Go 语言开发成员的反对。可能也是直到 CloudFlare 发生了闰秒事故之后,Go 社区才真正重视这个问题,随后提出了这个各方都满意的解决方案,也就是这个 Proposal。
可能对于大多数普通的开发者而言,使用单调时钟来计时似乎是理所当然的更优选择,在 Java、Python 都加入了对应特性以落实这个实践。没想到在这个过程中还有这种波折。所谓智者千虑必有一失, Ross Cux 对我而言在技术境界上是可望不可及的存在了,一开始竟也没有看到对于普通开发者可能也足够显然的点,看到这种事情,有时微妙觉得世界确实是个草台班子(x