4

你真的了解 time.Now() 吗?

 1 year ago
source link: https://articles.singee.me/golang-time
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

你真的了解 time.Now() 吗?

May 6, 2023#Go#linux153
AI-generated summary
This article analyzes the Go1.20.4 source code for the Time struct, which represents an instant in time with nanosecond precision. It includes both a wall clock reading and an optional monotonic clock reading for additional precision. The wall clock is divided into three parts, with the first part determining how the wall and monotonic clocks are stored. The article discusses the differences between wall and monotonic clocks and their importance in calculating elapsed time. The article also includes some extreme analyses of the Time struct's limitations. Finally, the article includes code for obtaining the current local time.

本文基于 Go1.20.4 源码进行分析,更高或更低版本可能有所差异

概览:time.Time

话不多说,先上源码

// 为了减少文章长度突出重点,注释部分有所删改

// A Time represents an instant in time with nanosecond precision.
// 
// The zero value of type Time is January 1, year 1, 00:00:00.000000000 UTC.
//
// In addition to the required “wall clock” reading, a Time may contain an optional
// reading of the current process's monotonic clock, to provide additional precision
// for comparison or subtraction.
type Time struct {
	// wall and ext encode the wall time seconds, wall time nanoseconds,
	// and optional monotonic clock reading in nanoseconds.
	//
	// From high to low bit position, wall encodes a 1-bit flag (hasMonotonic),
	// a 33-bit seconds field, and a 30-bit wall time nanoseconds field.
	// The nanoseconds field is in the range [0, 999999999].
	// If the hasMonotonic bit is 0, then the 33-bit field must be zero
	// and the full signed 64-bit wall seconds since Jan 1 year 1 is stored in ext.
	// If the hasMonotonic bit is 1, then the 33-bit field holds a 33-bit
	// unsigned wall seconds since Jan 1 year 1885, and ext holds a
	// signed 64-bit monotonic clock reading, nanoseconds since process start.
	wall uint64
	ext  int64

	// 本文重点不在时区,或另写一篇文章讨论相关话题
	loc *Location
}

当讨论「时间」这一概念时,或者更精确的说,是时点(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 日起的秒数(有符号)

在这里,我们进行一些极限分析

  1. 纳秒部分,十进制的最大值 10^9-1 对应的二进制为 30 位,保证了 wall 的第三部分不会越界
  2. wall 的第二部分(33 位)对应秒,最大值为 8589934591 秒,约 272.4 年,自 1885.1.1 起可用到 2157 年
  3. 64 位有符号纳秒的最大值约 292.47 年(应该不至于有程序一次性运行那么久吧)
    • 实际上,根据 Golang 内部实现,最大界限受限于系统返回的 monotonic,对 Linux 而言是整个系统 uptime 最大达 292.47 年
  4. 64 位有符号自 1.1.1 起的秒数最大值达 2924.7 亿年,我们有生之年是见不到溢出了

time.Now()

// Now returns the current local time.
func Now() Time {
	sec, nsec, mono := now()
	mono -= startNano
	sec += unixToInternal - minWall
	if uint64(sec)>>33 != 0 {
		// Seconds field overflowed the 33 bits available when
		// storing a monotonic time. This will be true after
		// March 16, 2157.
		return Time{uint64(nsec), sec + minWall, Local}
	}
	return Time{hasMonotonic | uint64(sec)<<nsecShift | uint64(nsec), mono, Local}
}

// Provided by package runtime.
func now() (sec int64, nsec int32, mono int64)

在对 time.Time 有了足够的了解以后,我们就很容易能读懂这段代码设计的逻辑了 —— 尽可能保留 monotonic

now 方法是用来返回当前的时间的,具体实现和系统有关,其三个返回值分别是

  • sec - Unix 时间戳(自 1970.1.1 起的秒数)
  • nsec - 纳秒偏移量 [0, 10^9-1]
  • mono - 系统级单调钟的值

首先是两个调整

  • mono -= startNano

    • 前面说了,存储在 Time 中的单调钟的值并非系统返回的,而是自进程启动起的纳秒数,因此这里进行了一次减法操作(与系统启动时的系统单调钟的值做减法)
    // 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() - 1
    
  • sec += unixToInternal - minWall

    • 在 hasMonotonic=1 的情况下,Time 中存储的是自 1885.1.1 的秒数,而系统返回的是自 1970.1.1 的秒数,因此这里要将 sec 减去这 85 年的差
    const (
    	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()

// Unix returns the local Time corresponding to the given Unix time,
// sec seconds and nsec nanoseconds since January 1, 1970 UTC.
// It is valid to pass nsec outside the range [0, 999999999].
// Not all sec values have a corresponding time value. One such
// value is 1<<63-1 (the largest int64 value).
func Unix(sec int64, nsec int64) Time {
	if nsec < 0 || nsec >= 1e9 {
		n := nsec / 1e9
		sec += n
		nsec -= n * 1e9
		if nsec < 0 {
			nsec += 1e9
			sec--
		}
	}
	return unixTime(sec, int32(nsec))
}

func unixTime(sec int64, nsec int32) Time {
	return Time{uint64(nsec), sec + unixToInternal, Local}
}

const (
	unixToInternal int64 = (1969*365 + 1969/4 - 1969/100 + 1969/400) * secondsPerDay
)

Unix 主要做的一件事情就是利用 Unix 时间戳(epoch)来生成 hasMonotonic=0 的时间

unixTime 调用前的 if 逻辑是为了简化业务代码的编写使用的 —— 如果 nsec 部分不在 [0, 10^9-1] 的限制区间内,则替我们修正它。

哦对,除了最常用的秒级时间戳,我们还可能遇到毫秒级时间戳和微秒级时间戳,Go 也为我们提供了简化的调用:

// UnixMilli returns the local Time corresponding to the given Unix time,
// msec milliseconds since January 1, 1970 UTC.
func UnixMilli(msec int64) Time {
	return Unix(msec/1e3, (msec%1e3)*1e6)
}

// UnixMicro returns the local Time corresponding to the given Unix time,
// usec microseconds since January 1, 1970 UTC.
func UnixMicro(usec int64) Time {
	return Unix(usec/1e6, (usec%1e6)*1e3)
}

嗯…… 如果你要是问我纳秒级时间戳呢…… 直接调 time.Unix(0, nanoseconds) 就好了呀😂

time.Date()

// 为了减少文章长度突出重点,代码和注释部分有所删改

// Date returns the Time corresponding to
//
//	yyyy-mm-dd hh:mm:ss + nsec nanoseconds
//
// The month, day, hour, min, sec, and nsec values may be outside
// their usual ranges and will be normalized during the conversion.
// For example, October 32 converts to November 1.
func Date(year int, month Month, day, hour, min, sec, nsec int, loc *Location) Time {
	...

	// Normalize overflow
	...

	// Compute days since the absolute epoch.
	d := daysSinceEpoch(year)

	// Add in days before this month.
	d += uint64(daysBefore[month-1])
	if isLeap(year) && month >= March {
		d++ // February 29
	}

	// Add in days before today.
	d += uint64(day - 1)

	// Add in time elapsed today.
	abs := d * secondsPerDay
	abs += uint64(hour*secondsPerHour + min*secondsPerMinute + sec)

	unix := int64(abs) + (absoluteToInternal + internalToUnix)

	// Look for zone offset for expected time, so we can adjust to UTC.
	...

	t := unixTime(unix, int32(nsec))
	t.setLoc(loc)
	return t
}

大部分的逻辑都被我直接省略掉了,简单来说,Date 做了以下几件事情

  1. 处理「溢出」:我们可以提供如 10 月 32 日这种日期,Golang 会帮我们进行正确的修正(为 11 月 1 日)—— 你可以看看 AddDate 的源码,其就是简单的直接操作了相关值
  2. 利用 Unix 时间戳来生成 hasMonotonic=0 的时间

time.Parse()

// 为了减少文章长度突出重点,代码和注释部分有所删改

func Parse(layout, value string) (Time, error) {
	return parse(layout, value, UTC, Local)
}

func parse(layout, value string, defaultLocation, local *Location) (Time, error) {
	...
	return Date(year, Month(month), day, hour, min, sec, nsec, defaultLocation), nil
}

这个删的更彻底了,其本质就是解析完格式后进行了 Date 调用,所以其生成的也是 hasMonotonic=0 的时间

可以发现,我们最常用的四种构造时间的方式(Now、Date、Unix、Parse)中,只有 Now 存储了单调钟的信息(hasMonotonic=1)

这种存储,最重要的优势就是我们可以利用 time.Now() - time.Now() 来计算两次执行中的经过时间而不需要考虑出现时光倒流的

时间的减法

其实一切难点在我们了解了 time.Time 的结构体之后都解决了,设计好结构体后,让你自己去写 Sub 你也会这么写。

话不多说,让我们直接来看 Sub 的代码

// Sub returns the duration t-u. If the result exceeds the maximum (or minimum)
// value that can be stored in a Duration, the maximum (or minimum) duration
// will be returned.
// To compute t-d for a duration d, use t.Add(-d).
func (t Time) Sub(u Time) Duration {
	if t.wall&u.wall&hasMonotonic != 0 {
		te := t.ext
		ue := u.ext
		d := Duration(te - ue)
		if d < 0 && te > ue {
			return maxDuration // t - u is positive out of range
		}
		if d > 0 && te < ue {
			return minDuration // t - u is negative out of range
		}
		return d
	}
	d := Duration(t.sec()-u.sec())*Second + Duration(t.nsec()-u.nsec())
	// Check for overflow or underflow.
	switch {
	case u.Add(d).Equal(t):
		return d // d is correct
	case t.Before(u):
		return minDuration // t - u is negative out of range
	default:
		return maxDuration // t - u is positive out of range
	}
}

核心逻辑想必和你想的一样:如果两个时间都是 hasMonotonic=1 的,就计算两个时间的单调钟差值即可 —— 两个 ext 的减法;否则,计算日历时钟的差值。

还记得我们之前的极限分析吗?在极端情况下,无论走到了 hasMonotonic 的哪个分支,都会有一个溢出的可能性 —— 两个时间间隔太大了,以至于超出了我们 int64 纳秒最大可容纳的 292.47 年。

我推荐你好好阅读下上面的代码,特别是思考下为什么两种情况检测溢出的方法不一样

零值 —— IsZero

当我们直接初始化一个 Time 时,其是 0 值

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 方法来检测

// 为了减少文章长度突出重点,增加了部分注释

// IsZero reports whether t represents the zero time instant,
// January 1, year 1, 00:00:00 UTC.
func (t Time) IsZero() bool {
	return t.sec() == 0 && t.nsec() == 0
}

// sec returns the time's seconds since Jan 1 year 1.
func (t *Time) sec() int64 {
	if t.wall&hasMonotonic != 0 { // 0 值时不会走到这个分支
		return wallToInternal + int64(t.wall<<1>>(nsecShift+1))
	}
	return t.ext
}

// nsec returns the time's nanoseconds.
// 就是简单的返回 wall 的第三部分(30 位纳秒时间偏移)
func (t *Time) nsec() int32 {
	return int32(t.wall & nsecMask)
}

const nsecMask = 1<<30 - 1

它的实现看上去稍微复杂了点,但实际上,考虑到 hasMonotonic=1 时值不可能为 0,因此只要这个 Time 不是我们利用指针进行了一些强行破坏,其 wall 的第一第三部分为 0 + ext 为 0,就是整体为 0。

相等 —— Equal

首先明确一点:如果直接利用结构体进行比较,那么结构体相等一定时间相等、结构体不等却并不一定时间不等。因此,在 Go 种进行时间比较时,我们应尽量避免利用 == 来比较,而是使用 Equal 方法。

造成结构体不等而时间相等的原因包括:可能有两种表示方法(hasMonotonic=0/1)对应着相同的值、可能有两个时区对应着相同的值。

本文重点不在时区,时区不会影响 Time 中的 wall 和 ext(其都是用 UTC 值存储的),只会影响其中的 loc 字段

// Equal reports whether t and u represent the same time instant.
// Two times can be equal even if they are in different locations.
// For example, 6:00 +0200 and 4:00 UTC are Equal.
// See the documentation on the Time type for the pitfalls of using == with
// Time values; most code should use Equal instead.
func (t Time) Equal(u Time) bool {
	if t.wall&u.wall&hasMonotonic != 0 {
		return t.ext == u.ext
	}
	return t.sec() == u.sec() && t.nsec() == u.nsec()
}

可以发现,Go 中 Equal 的实现也是本着「单调钟优先的原则」。

上面其实已经把本文的核心 —— time.Time 都说明白了(嗯,除了时区),这一节我想写点使用的时候用不到但是追求技术的话可以了解的

ᕦ(・́~・̀) 加餐内容量其实比正文还多

time.Now 到底如何实现

上面已经说过了 time.Now 的实现,但是有没有发现……

// Provided by package runtime.
func now() (sec int64, nsec int32, mono int64)

这个 now 直接被省略了😂只留下了一行由 runtime 实现。

是的,它确实是由 runtime 实现的,真实的实现在 runtime 中的 time_now 函数,并且更进一步的,在 Linux Amd64 和其他系统的实现还不太一样

//go:build !faketime && (windows || (linux && amd64))

//go:linkname time_now time.now
func time_now() (sec int64, nsec int32, mono int64)
//go:build !faketime && !windows && !(linux && amd64)

//go:linkname time_now time.now
func time_now() (sec int64, nsec int32, mono int64) {
	sec, nsec = walltime()
	return sec, nsec, nanotime()
}

在 Linux Amd64 上,整个 time_now 都是用汇编实现的,下面的代码是我加了注释的版本,你可从此链接阅读原始版本

// 为了减少文章长度突出重点,代码和注释部分有所删改

// func time.now() (sec int64, nsec int32, mono int64)
// $16-24 表示函数需要 16 字节的栈空间和 24 字节的返回值空间
TEXT time·now<ABIInternal>(SB),NOSPLIT,$16-24
	// 准备 vDSO
    ...

	// (如果不是 g0)切换到 g0
	...

noswitch: // 获取时间的逻辑(优先利用 vDSO)
	SUBQ	$32, SP		// Space for two time results
	ANDQ	$~15, SP	// Align for C code

	MOVL	$0, DI // CLOCK_REALTIME(获取日历时钟)
	LEAQ	16(SP), SI
	MOVQ	runtime·vdsoClockgettimeSym(SB), AX
	CMPQ	AX, $0
	JEQ	fallback // 失败跳转
	CALL	AX

	MOVL	$1, DI // CLOCK_MONOTONIC(获取单调钟)
	LEAQ	0(SP), SI
	MOVQ	runtime·vdsoClockgettimeSym(SB), AX
	CALL	AX

ret: // 获取成功,结果存在了栈中
	MOVQ	16(SP), AX	// realtime sec
	MOVQ	24(SP), DI	// realtime nsec (moved to BX below)
	MOVQ	0(SP), CX	// monotonic sec
	IMULQ	$1000000000, CX
	MOVQ	8(SP), DX	// monotonic nsec

	// 恢复现场
    ...

	// 返回结果
	// set result registers; AX is already correct
	MOVQ	DI, BX
	ADDQ	DX, CX // 计算出 monotonic nanoseconds
	RET

fallback: // CLOCK_REALTIME 获取失败时,证明 vdso 失败,利用系统调用获取
	MOVQ	$SYS_clock_gettime, AX
	SYSCALL

	MOVL	$1, DI // CLOCK_MONOTONIC
	LEAQ	0(SP), SI
	MOVQ	$SYS_clock_gettime, AX
	SYSCALL

	JMP	ret

简单来说,在 Linux Amd64 下,Go 会尽可能利用 vDSO 获取时间信息(包括日历时钟和单调钟的时间),如果出现错误才会 fallback 到系统调用。vDSO 的主要目的是为了降低系统调用的时间,具体你可阅读 Linux 手册获得更多信息。在 Go 中,实际上只有和时间有关的系统调用才用到了 vDSO:vdso_linux_amd64.go

而非 Linux Amd64 下,使用了 walltime()nanotime() 来分别获取日历时钟和单调钟。

以下代码以 Linux Arm64 为例

func walltime() (sec int64, nsec int32)

//go:nosplit
func nanotime() int64 {
	return nanotime1()
}

func nanotime1() int64

看这个没有 body 的样子就知道肯定是要走汇编了😂读懂了上面 Linux Amd64 的其实 Arm64 的大差不差(都是 vDSO + syscall fallback),只不过是没有将 walltime 和 nanotime 在一个函数中获取罢了,你可点此阅读相关源码。

如何获取系统单调钟?

time 包下没有提供获取系统单调钟的手段,但 runtime 中的 nanotime 其实就是系统单调钟。你可以将以下内容包装成一个帮助函数来使用

import (
	_ "unsafe"
)

//go:noescape
//go:linkname Monotonic runtime.nanotime
func Monotonic() int64

或是…… 使用我的 extime.Monotonic 👀

Round —— 四舍五入 + 去除单调钟信息

Round 是用来四舍五入的

// Round returns the result of rounding t to the nearest multiple of d (since the zero time).
// The rounding behavior for halfway values is to round up.
// If d <= 0, Round returns t stripped of any monotonic clock reading but otherwise unchanged.
//
// Round operates on the time as an absolute duration since the
// zero time; it does not operate on the presentation form of the
// time. Thus, Round(Hour) may return a time with a non-zero
// minute, depending on the time's Location.
func (t Time) Round(d Duration) Time {
	t.stripMono()
	if d <= 0 {
		return t
	}
	_, r := div(t, d)
	if lessThanHalf(r, d) {
		return t.Add(-r)
	}
	return t.Add(d - r)
}

// stripMono strips the monotonic clock reading in t.
func (t *Time) stripMono() {
	if t.wall&hasMonotonic != 0 {
		t.ext = t.sec()
		t.wall &= nsecMask
	}
}

你可以利用 Duration 来提供精度(例如 t.Round(time.Second))。

另外,它还有一个「副作用」,就是会将我们前面说过的 Time 中的单调钟的信息删除(第一行的 stripMono)。因此,除了四舍五入,如果你想让他删除单调钟的内容,可以使用 t.Round(0)

如果你不知道闰秒,请先移步维基百科。闰秒是一个一直存在、造成了无数事故、并且即将取消的东东。因为前辈踩了坑我们可能已经不会再那么关注它,但是,你至少应该知道它。

闰秒在一定程度上超出了本文的讨论范畴 —— 因为 Go 不考虑闰秒。在 Go 中,你不会得到「23:59:60」这种时间,而计算时间差时因为现在已经引入了单调钟所以也不会再出事故。

等下,再?敬请移步 2017 年 Cloudflare 的事故,当时 Go 的 time.Now 还没有使用单调钟。

go/time.go at go1.20.4 · golang/go

https://github.com/golang/go/issues/12914


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK