你真的了解 time.Now() 吗?
本文首发于 xlog,可跳转获得更佳阅读体验
本文基于 Go1.20.4 源码进行分析,更高或更低版本可能有所差异
概览:time.Time
话不多说,先上源码
1 | // 为了减少文章长度突出重点,注释部分有所删改 |
当讨论「时间」这一概念时,或者更精确的说,是时点(instant),我们通常不会有什么疑惑。但与我们生活中时间点是唯一的不同,在现代计算机中,实际上存在着两种时钟:日历时钟(time-of-day clock / wall clock) 和 单调钟(monotonic clock)。
我们通常所看到的时间(包括时间戳、年月日时分秒的展示等)一般都是日历时钟,但是,当我们想要去计算时间间隔时,日历时钟之差可能是负数 —— 在两次计算之间,是可以「调时间」的。因此,我们需要一种方法来稳定的获取两个时间点之间的经过时间(elapsed time),这也就是单调钟的来历。单调钟的名字便来源于其单调递增的特性,它通常不是真实的时间值,且考虑单调钟的值是无意义的其唯一的目的便是用于稳定计算时间差。
进一步的讨论日历时钟和单调钟超过了本文的范围,我推荐你阅读《数据密集型应用系统设计》第八章中不可靠的时钟部分。但我们回过头来看开篇 Time 的源码 —— 其同时包括了日历时钟和单调钟。
wall 的 64 位被分成了 1+33+30 三个部分,其中第一个部分(最高位)名称为 hasMonotonic,它用来决定日历钟和单调钟怎么存储
- 当 hasMonotonic = 1
- wall 的第二部分(33 位)存储了自 1885.1.1 起的秒数(无符号)
- wall 的第三部分(30 位)存储了纳秒部分(范围
[0, 10^9-1
]) - ext 存储了自进程启动起的纳秒数(有符号)
- 当 hasMonotonic = 0
- wall 的第二部分(33 位)为全 0
- wall 的第三部分(30 位)存储了纳秒部分(范围
[0, 10^9-1
]) - ext 存储了自 1 年 1 月 1 日起的秒数(有符号)
在这里,我们进行一些极限分析
- 纳秒部分,十进制的最大值
10^9-1
对应的二进制为 30 位,保证了 wall 的第三部分不会越界 - wall 的第二部分(33 位)对应秒,最大值为 8589934591 秒,约 272.4 年,自 1885.1.1 起可用到 2157 年
- 64 位有符号纳秒的最大值约 292.47 年(应该不至于有程序一次性运行那么久吧)
- 实际上,根据 Golang 内部实现,最大界限受限于系统返回的 monotonic,对 Linux 而言是整个系统 uptime 最大达 292.47 年
- 64 位有符号自 1.1.1 起的秒数最大值达 2924.7 亿年,我们有生之年是见不到溢出了
获取时间
time.Now()
1 | // Now returns the current local time. |
在对 time.Time
有了足够的了解以后,我们就很容易能读懂这段代码设计的逻辑了 —— 尽可能保留 monotonic
now
方法是用来返回当前的时间的,具体实现和系统有关,其三个返回值分别是
- sec - Unix 时间戳(自 1970.1.1 起的秒数)
- nsec - 纳秒偏移量
[0, 10^9-1]
- mono - 系统级单调钟的值
首先是两个调整
mono -= startNano
- 前面说了,存储在 Time 中的单调钟的值并非系统返回的,而是自进程启动起的纳秒数,因此这里进行了一次减法操作(与系统启动时的系统单调钟的值做减法)
1
2
3
4
5
6
7// Monotonic times are reported as offsets from startNano.
// We initialize startNano to runtimeNano() - 1 so that on systems where
// monotonic time resolution is fairly low (e.g. Windows 2008
// which appears to have a default resolution of 15ms),
// we avoid ever reporting a monotonic time of 0.
// (Callers may want to use 0 as "time not set".)
var startNano int64 = runtimeNano() - 1sec += unixToInternal - minWall
- 在 hasMonotonic=1 的情况下,Time 中存储的是自 1885.1.1 的秒数,而系统返回的是自 1970.1.1 的秒数,因此这里要将 sec 减去这 85 年的差
1
2
3
4
5
6
7
8const (
unixToInternal int64 = (1969*365 + 1969/4 - 1969/100 + 1969/400) * secondsPerDay
)
const (
wallToInternal int64 = (1884*365 + 1884/4 - 1884/100 + 1884/400) * secondsPerDay
minWall = wallToInternal // year 1885
)
后面的判断逻辑就很简单了:如果可能就采用 hasMonotonic=1 进行存储,否则(当时间不满足 1885-2157 年的区间中)则采用 hasMonotonic=0 存储。其中后面的判断分支…… 能看到这篇文章的人估计都遇不到了。
time.Unix()
1 | // Unix returns the local Time corresponding to the given Unix time, |
Unix 主要做的一件事情就是利用 Unix 时间戳(epoch)来生成 hasMonotonic=0 的时间。
unixTime 调用前的 if 逻辑是为了简化业务代码的编写使用的 —— 如果 nsec 部分不在 [0, 10^9-1]
的限制区间内,则替我们修正它。
哦对,除了最常用的秒级时间戳,我们还可能遇到毫秒级时间戳和微秒级时间戳,Go 也为我们提供了简化的调用:
1 | // UnixMilli returns the local Time corresponding to the given Unix time, |
嗯…… 如果你要是问我纳秒级时间戳呢……直接调 time.Unix(0, nanoseconds)
就好了呀😂
time.Date()
1 | // 为了减少文章长度突出重点,代码和注释部分有所删改 |
大部分的逻辑都被我直接省略掉了,简单来说,Date 做了以下几件事情
- 处理「溢出」:我们可以提供如 10 月 32 日这种日期,Golang 会帮我们进行正确的修正(为 11 月 1 日)—— 你可以看看 AddDate 的源码,其就是简单的直接操作了相关值
- 处理闰年
- 处理时区
- 利用 Unix 时间戳来生成 hasMonotonic=0 的时间
time.Parse()
1 | // 为了减少文章长度突出重点,代码和注释部分有所删改 |
这个删的更彻底了,其本质就是解析完格式后进行了 Date 调用,所以其生成的也是 hasMonotonic=0 的时间
小结
可以发现,我们最常用的四种构造时间的方式(Now、Date、Unix、Parse)中,只有 Now 存储了单调钟的信息(hasMonotonic=1)
这种存储,最重要的优势就是我们可以利用 time.Now() - time.Now()
来计算两次执行中的经过时间而不需要考虑出现时光倒流的
时间差
时间的减法
其实一切难点在我们了解了 time.Time 的结构体之后都解决了,设计好结构体后,让你自己去写 Sub 你也会这么写。
话不多说,让我们直接来看 Sub 的代码
1 | // Sub returns the duration t-u. If the result exceeds the maximum (or minimum) |
核心逻辑想必和你想的一样:如果两个时间都是 hasMonotonic=1 的,就计算两个时间的单调钟差值即可 —— 两个 ext 的减法;否则,计算日历时钟的差值。
还记得我们之前的极限分析吗?在极端情况下,无论走到了 hasMonotonic 的哪个分支,都会有一个溢出的可能性 —— 两个时间间隔太大了,以至于超出了我们 int64 纳秒最大可容纳的 292.47 年。
我推荐你好好阅读下上面的代码,特别是思考下为什么两种情况检测溢出的方法不一样。
比较
零值 —— IsZero
当我们直接初始化一个 Time 时,其是 0 值
1 | var t time.Time |
Time 的初始化没有魔法,和 Go 中其他结构体的初始化 0 值相同 —— 其所有字段都被赋予了 0。那么,根据规则,其 hasMonotonic=0,因此使用 ext 存储秒、wall 的第三部分存储纳秒,这俩也是 0,所以,0 值的时间就是 January 1, year 1, 00:00:00.000000000 UTC
因为这个时间在实际情况下是不常见的,所以零值通常被认为是「未初始化的时间」。Go 提供了 IsZero
方法来检测
1 | // 为了减少文章长度突出重点,增加了部分注释 |
它的实现看上去稍微复杂了点,但实际上,考虑到 hasMonotonic=1 时值不可能为 0,因此只要这个 Time 不是我们利用指针进行了一些强行破坏,其 wall 的第一第三部分为 0 + ext 为 0,就是整体为 0。
相等 —— Equal
首先明确一点:如果直接利用结构体进行比较,那么结构体相等一定时间相等、结构体不等却并不一定时间不等。因此,在 Go 种进行时间比较时,我们应尽量避免利用 ==
来比较,而是使用 Equal
方法。
造成结构体不等而时间相等的原因包括:可能有两种表示方法(hasMonotonic=0/1)对应着相同的值、可能有两个时区对应着相同的值。
本文重点不在时区,时区不会影响 Time 中的 wall 和 ext(其都是用 UTC 值存储的),只会影响其中的 loc 字段
1 | // Equal reports whether t and u represent the same time instant. |
可以发现,Go 中 Equal 的实现也是本着「单调钟优先的原则」。
加餐
上面其实已经把本文的核心 —— time.Time 都说明白了(嗯,除了时区),这一节我想写点使用的时候用不到但是追求技术的话可以了解的
ᕦ(•́~•̀ ) 加餐内容量其实比正文还多
time.Now 到底如何实现
上面已经说过了 time.Now 的实现,但是有没有发现……
1 | // Provided by package runtime. |
这个 now 直接被省略了😂只留下了一行由 runtime 实现。
是的,它确实是由 runtime 实现的,真实的实现在 runtime 中的 time_now 函数,并且更进一步的,在 Linux Amd64 和其他系统的实现还不太一样
1 | //go:build !faketime && (windows || (linux && amd64)) |
1 | //go:build !faketime && !windows && !(linux && amd64) |
在 Linux Amd64 上,整个 time_now 都是用汇编实现的,下面的代码是我加了注释的版本,你可从此链接阅读原始版本
1 | // 为了减少文章长度突出重点,代码和注释部分有所删改 |
简单来说,在 Linux Amd64 下,Go 会尽可能利用 vDSO 获取时间信息(包括日历时钟和单调钟的时间),如果出现错误才会 fallback 到系统调用。vDSO 的主要目的是为了降低系统调用的时间,具体你可阅读 Linux 手册获得更多信息。在 Go 中,实际上只有和时间有关的系统调用才用到了 vDSO:vdso_linux_amd64.go。
而非 Linux Amd64 下,使用了 walltime()
和 nanotime()
来分别获取日历时钟和单调钟。
以下代码以 Linux Arm64 为例
1 | func walltime() (sec int64, nsec int32) |
看这个没有 body 的样子就知道肯定是要走汇编了😂读懂了上面 Linux Amd64 的其实 Arm64 的大差不差(都是 vDSO + syscall fallback),只不过是没有将 walltime 和 nanotime 在一个函数中获取罢了,你可点此阅读相关源码。
如何获取系统单调钟?
time 包下没有提供获取系统单调钟的手段,但 runtime 中的 nanotime 其实就是系统单调钟。你可以将以下内容包装成一个帮助函数来使用
1 | import ( |
或是…… 使用我的 extime.Monotonic 👀
Round —— 四舍五入 + 去除单调钟信息
Round 是用来四舍五入的
1 | // Round returns the result of rounding t to the nearest multiple of d (since the zero time). |
你可以利用 Duration 来提供精度(例如 t.Round(time.Second)
)。
另外,它还有一个「副作用」,就是会将我们前面说过的 Time 中的单调钟的信息删除(第一行的 stripMono)。因此,除了四舍五入,如果你想让他删除单调钟的内容,可以使用 t.Round(0)
。
闰秒
如果你不知道闰秒,请先移步维基百科。闰秒是一个一直存在、造成了无数事故、并且即将取消的东东。因为前辈踩了坑我们可能已经不会再那么关注它,但是,你至少应该知道它。
闰秒在一定程度上超出了本文的讨论范畴 —— 因为 Go 不考虑闰秒。在 Go 中,你不会得到「23:59:60」这种时间,而计算时间差时因为现在已经引入了单调钟所以也不会再出事故。
等下,再?敬请移步 2017 年 Cloudflare 的事故,当时 Go 的 time.Now 还没有使用单调钟。