Bryan

Bryan

twitter
medium

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

本文基於 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. 處理閏年
  3. 處理時區
  4. 利用 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

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。