『每周譯Go』Go 如何知道 time.Now?
幾天前,我在睡前想過(guò)這個(gè)問(wèn)題,而答案比我想象的還要有意思!
這篇博客可能比之前的稍長(zhǎng)一些,所以拿起你的咖啡、你的茶,找一個(gè)安靜的地方,一起來(lái)深入看看我們可以發(fā)現(xiàn)什么。
所有的代碼片段都有完整的參考信息;文中的參考是release-branch.go1.16(https://github.com/golang/go/tree/release-branch.go1.16)。
關(guān)于 time.Time
首先,理解 Go 中_如何_嵌入時(shí)間非常有用。
time.Time 結(jié)構(gòu)體可以表示納秒精度的時(shí)間度量。為了更可信的描述用于對(duì)比、加減的耗時(shí),time.Time 也會(huì)包含一個(gè)可選的、納秒精度的讀取_當(dāng)前進(jìn)程_單調(diào)時(shí)鐘的操作。這么做是為了避免表達(dá)錯(cuò)誤的時(shí)段,比如,夏令時(shí)(Daylight Saving time,DST)。
type Time struct {
wall uint64
ext int64
loc *Location
}
Τime 結(jié)構(gòu)體在2017年早期就是當(dāng)前這個(gè)形式;你可以瀏覽 Russ Cox 提出的相關(guān)issue, 提案和實(shí)現(xiàn)。
因此,首先有一個(gè) wall 值用于提供直接讀取的 “時(shí)鐘”時(shí)間, ext 提供了這種單調(diào)時(shí)鐘形式下的_額外_信息。
分解 wall 參數(shù),它在最高位包含 1 比特的 hasMonotonic 標(biāo)志;接下來(lái)是表示秒的 33 比特;最后 30 個(gè)比特用于表示納秒,范圍在 [0, 999999999] 之間。
mSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn
^ ^ ^
hasMonotonic seconds nanoseconds
在 Go >= 1.9 的版本中,hasMonotonic 標(biāo)志都是開啟的,日期是在 1885 到 2157 之間,但由于兼容性考慮和一些極端情況,Go 可以保證這些時(shí)間內(nèi)的值都可以被正確處理。
更準(zhǔn)確的來(lái)說(shuō),下面是具體的行為差異:
如果 hasMonotonic 比特是 1,那么 33 比特的位置存儲(chǔ)的就是從 1885 年 1 月 1 日開始的無(wú)符號(hào)的秒表示的時(shí)間,ext 表示的是從進(jìn)程開始時(shí)的 64 比特單調(diào)時(shí)鐘的納秒精度的值。在代碼中,大多數(shù)是這種情況。
如果 hasMonotonic 比特是 0,那么 33 比特的位置是 0,從公元 1 月開始的完整 64 比特的有符號(hào)時(shí)鐘的秒值存在 ext 中,直到其單調(diào)性改變。
最后,每個(gè) Time 的值都包含一個(gè) Location,用于計(jì)算_表示形式_的時(shí)間;位置的改變僅改變這個(gè)表示,即打印的值,它不會(huì)影響存儲(chǔ)的實(shí)際時(shí)間。nil 位置(默認(rèn)情況)表示的是 “UTC”。
為了表述更加清楚,再重申一遍;一般 報(bào)時(shí)的操作用的是讀取的時(shí)鐘時(shí)間,但衡量時(shí)間的操作,特別是比較和減法,使用的是單調(diào)時(shí)鐘時(shí)間。
很棒,但_當(dāng)前_時(shí)間是如何計(jì)算的?
下面是 Go 代碼中如何定義 time.Now() 和 startNano 。
// Monotonic times are reported as offsets from startNano.
var startNano int64 = runtimeNano() - 1
// Now returns the current local time.
func Now() Time {
sec, nsec, mono := now()
mono -= startNano
sec += unixToInternal - minWall
if uint64(sec)>>33 != 0 {
return Time{uint64(nsec), sec + minWall, Local}
}
return Time{hasMonotonic | uint64(sec)<<nsecShift | uint64(nsec), mono, Local}
}
如果我們了解了一些常量后,代碼就非常明確易懂
hasMonotonic = 1 << 63
unixToInternal int64 = (1969*365 + 1969/4 - 1969/100 + 1969/400) * secondsPerDay
wallToInternal int64 = (1884*365 + 1884/4 - 1884/100 + 1884/400) * secondsPerDay
minWall = wallToInternal // year 1885
nsecShift = 30
if 分支檢查秒的值是否可以存儲(chǔ)在 33 比特內(nèi),否則就需要設(shè)置 hasMonotonic=off。因?yàn)閱握{(diào)的粗略計(jì)算, 2^33 秒是 272 年,所以我們可以通過(guò)確定是否在 (1885+272=) 2157 年之后就可以高效快速得到結(jié)果。
否則,我們按上面描述的方法設(shè)置 hasMonotonic=on 的情況。
哎呀信息有些多!
我當(dāng)然同意!但即使有了這些信息,還有兩個(gè)未知的情況;
定義的未引出的 now() 和 runtimeNano() 在哪里? 以及
Local 又是從何而來(lái)?
下面就越來(lái)越意思了!
第一個(gè)未解之謎
我們先來(lái)看第一個(gè)問(wèn)題。按約定的邏輯,我們應(yīng)該在相同的包內(nèi)查看,但可能什么也找不到!
這兩個(gè)函數(shù)是從 runtime 包中通過(guò)鏈接名字(https://tpaschalis.github.io/golang-linknames/)的方式獲取的。
// Provided by package runtime.
func now() (sec int64, nsec int32, mono int64)
// runtimeNano returns the current value of the runtime clock in nanoseconds.
//go:linkname runtimeNano runtime.nanotime
func runtimeNano() int64
正如鏈接名字所示,要找到 runtimeNano() ,就必須找到 runtime.nanotime(),而我們會(huì)發(fā)現(xiàn)它出現(xiàn)了兩次。
相似的,如果我們繼續(xù)在 runtime 包中尋找,我們將會(huì)遇到 timestub.go 中包含 time.Now() 定義的鏈接名字使用了 walltime()。
// Declarations for operating systems implementing time.now
// indirectly, in terms of walltime and nanotime assembly.
// +build !windows
...
//go:linkname time_now time.now
func time_now() (sec int64, nsec int32, mono int64) {
sec, nsec = walltime()
return sec, nsec, nanotime()
}
啊哈!現(xiàn)在我們有了一些進(jìn)展!
walltime() 和 nanotime() 表示的是一個(gè) ‘虛擬’ 實(shí)現(xiàn),主要用于在 Go playground 中使用,而‘真正’ 的實(shí)現(xiàn),調(diào)用的是 walltime1 和 nanotime1。
//go:nosplit
func nanotime() int64 {
return nanotime1()
}
func walltime() (sec int64, nsec int32) {
return walltime1()
}
對(duì)應(yīng)的, nanotime1 和 walltime1 按幾種不同的平臺(tái)和架構(gòu)進(jìn)行了定義。
更加深入
我先為任何錯(cuò)誤的表達(dá)道歉;在遇到匯編語(yǔ)言時(shí),我有時(shí)就像一只在車燈前的小鹿一樣迷茫,但我們可以嘗試?yán)斫庠?amd64 Linux 下是如何計(jì)算 walltime。
發(fā)現(xiàn)問(wèn)題請(qǐng)一定要評(píng)論來(lái)修改,不要猶豫!
// func walltime1() (sec int64, nsec int32)
// non-zero frame-size means bp is saved and restored
TEXT runtime·walltime1(SB),NOSPLIT,$16-12
// We don't know how much stack space the VDSO code will need,
// so switch to g0.
// In particular, a kernel configured with CONFIG_OPTIMIZE_INLINING=n
// and hardening can use a full page of stack space in gettime_sym
// due to stack probes inserted to avoid stack/heap collisions.
// See issue #20427.
MOVQ SP, R12 // Save old SP; R12 unchanged by C code.
get_tls(CX)
MOVQ g(CX), AX
MOVQ g_m(AX), BX // BX unchanged by C code.
// Set vdsoPC and vdsoSP for SIGPROF traceback.
// Save the old values on stack and restore them on exit,
// so this function is reentrant.
MOVQ m_vdsoPC(BX), CX
MOVQ m_vdsoSP(BX), DX
MOVQ CX, 0(SP)
MOVQ DX, 8(SP)
LEAQ sec+0(FP), DX
MOVQ -8(DX), CX
MOVQ CX, m_vdsoPC(BX)
MOVQ DX, m_vdsoSP(BX)
CMPQ AX, m_curg(BX) // Only switch if on curg.
JNE noswitch
MOVQ m_g0(BX), DX
MOVQ (g_sched+gobuf_sp)(DX), SP // Set SP to g0 stack
noswitch:
SUBQ $16, SP // Space for results
ANDQ $~15, SP // Align for C code
MOVL $0, DI // CLOCK_REALTIME
LEAQ 0(SP), SI
MOVQ runtime·vdsoClockgettimeSym(SB), AX
CMPQ AX, $0
JEQ fallback
CALL AX
ret:
MOVQ 0(SP), AX // sec
MOVQ 8(SP), DX // nsec
MOVQ R12, SP // Restore real SP
// Restore vdsoPC, vdsoSP
// We don't worry about being signaled between the two stores.
// If we are not in a signal handler, we'll restore vdsoSP to 0,
// and no one will care about vdsoPC. If we are in a signal handler,
// we cannot receive another signal.
MOVQ 8(SP), CX
MOVQ CX, m_vdsoSP(BX)
MOVQ 0(SP), CX
MOVQ CX, m_vdsoPC(BX)
MOVQ AX, sec+0(FP)
MOVL DX, nsec+8(FP)
RET
fallback:
MOVQ $SYS_clock_gettime, AX
SYSCALL
JMP ret
從我的理解來(lái)看,這個(gè)計(jì)算過(guò)程如下。
因?yàn)槲覀儾恢来a需要多少的棧空間,所以需要切換至
g0,它是每個(gè)系統(tǒng)線程創(chuàng)建的第一個(gè) goroutine ,用于調(diào)度其他的 goroutines。我們保持追蹤這個(gè)線程的本地存儲(chǔ),使用get_tls將它載入到CX寄存器,當(dāng)前的 goroutine 使用了幾次MOVQ語(yǔ)句。接下來(lái)代碼存儲(chǔ)
vdsoPC和vdsoSP(程序計(jì)數(shù)器和棧指針 ) 的值,用于在退出前存儲(chǔ)它們,這樣程序就可以 重新進(jìn)入。代碼檢測(cè)它是否已經(jīng)在
g0,是的話就跳轉(zhuǎn)到noswitch,否則使用下面的代碼切換至g0
MOVQ m_g0(BX), DX
MOVQ (g_sched+gobuf_sp)(DX), SP // Set SP to g0 stack
接下來(lái),嘗試載入 runtime·vdsoClockgettimeSym 進(jìn) AX 寄存器;如果它非零就調(diào)用并跳轉(zhuǎn)到 ret 代碼塊,并獲取秒和納秒的值,并存儲(chǔ)真實(shí)的棧指針和 vDSO 程序計(jì)數(shù)器和棧指針并返回
MOVQ 0(SP), AX // sec
MOVQ 8(SP), DX // nsec
MOVQ R12, SP // Restore real SP
// Restore vdsoPC, vdsoSP
// We don't worry about being signaled between the two stores.
// If we are not in a signal handler, we'll restore vdsoSP to 0,
// and no one will care about vdsoPC. If we are in a signal handler,
// we cannot receive another signal.
MOVQ 8(SP), CX
MOVQ CX, m_vdsoSP(BX)
MOVQ 0(SP), CX
MOVQ CX, m_vdsoPC(BX)
MOVQ AX, sec+0(FP)
MOVL DX, nsec+8(FP)
RET
另外,如果 runtime·vdsoClockgettimeSym的地址為零,那么就會(huì)跳轉(zhuǎn)到fallback標(biāo)簽,嘗試使用不同的方法來(lái)獲取系統(tǒng)時(shí)間,即$SYS_clock_gettime
MOVQ runtime·vdsoClockgettimeSym(SB), AX
CMPQ AX, $0
JEQ fallback
...
...
fallback:
MOVQ $SYS_clock_gettime, AX
SYSCALL
JMP ret
同樣的文件定義了$SYS_clock_gettime
#define SYS_clock_gettime 228
它實(shí)際對(duì)應(yīng)的是 __x64_sys_clock_gettime syscall ,在 Linux 源碼中的系統(tǒng)調(diào)用表中可以找到。
兩個(gè)不同的選項(xiàng)有何不同?
“優(yōu)選”的 vdsoClockgettimeSym 模式定義在 vdsoSymbolKeys
var vdsoSymbolKeys = []vdsoSymbolKey{
{"__vdso_gettimeofday", 0x315ca59, 0xb01bca00, &vdsoGettimeofdaySym},
{"__vdso_clock_gettime", 0xd35ec75, 0x6e43a318, &vdsoClockgettimeSym},
}
與從 文檔 中找到 vDSO 符號(hào)匹配。
為什么選擇 __vdso_clock_gettime 而不是 __x64_sys_clock_gettime,它們有什么不同?
vDSO 表示的是 虛擬動(dòng)態(tài)共享對(duì)象 ,它是一種將內(nèi)核空間的子集暴漏到用戶空間應(yīng)用的一種內(nèi)核機(jī)制,這樣內(nèi)核空間就可以在進(jìn)程中調(diào)用,而無(wú)需從用戶態(tài)切換至內(nèi)核態(tài)而產(chǎn)生性能損耗。
vDSO 文檔 包含了 gettimeofday 的相關(guān)例子,解釋了使用它的好處。
引用文檔
有些內(nèi)核提供的系統(tǒng)調(diào)動(dòng),在用戶空間頻繁使用時(shí),會(huì)遇到這些調(diào)用主導(dǎo)整體性能的情況。這不僅是頻繁的系統(tǒng)調(diào)用導(dǎo)致,還是從用戶空間退出并進(jìn)入內(nèi)核的上下文切換的結(jié)果。
系統(tǒng)調(diào)用會(huì)比較慢,但觸發(fā)一次軟件中斷來(lái)告訴內(nèi)核你希望進(jìn)行系統(tǒng)調(diào)用的性能開銷也很大,因?yàn)樗灤┨幚砥鞯奈⒋a和內(nèi)核的整個(gè)終端處理的路徑。
一個(gè)頻繁使用的系統(tǒng)調(diào)用是 gettimeofday(2)。這個(gè)系統(tǒng)調(diào)用是直接由用戶空間的應(yīng)用調(diào)用。這個(gè)信息也不是秘密——任何在任意權(quán)限模式下(root或其他非特權(quán)用戶)應(yīng)用將得到相同的結(jié)果。因此內(nèi)核將這個(gè)問(wèn)題需要的信息放在了進(jìn)程可以獲取的內(nèi)存中。現(xiàn)在調(diào)用 gettimeofday(2) 從一個(gè)系統(tǒng)調(diào)用變?yōu)橐粋€(gè)正常的有幾次內(nèi)存訪問(wèn)的函數(shù)調(diào)用。
因此, vDSO 調(diào)用被優(yōu)先選擇作為獲取時(shí)鐘信息的方法,是因?yàn)樗恍枰灤﹥?nèi)核的中斷處理路徑,但可以更快的調(diào)用。
將它們封裝起來(lái),Linux AMD64 的當(dāng)前時(shí)間最后要么從 __vdso_clock_gettime 或 __x64_sys_clock_gettime 系統(tǒng)調(diào)用獲取。為了“愚弄” time.Now() 你不得不修改其中一個(gè)方法。
Windows 的奇怪之處
有觀察力的讀者可能會(huì)問(wèn), 在 timestub.go 中,我們使用了 // +build !windows。. 這有什么用?
這是因?yàn)椋琖indows 直接在匯編里實(shí)現(xiàn)了 time.Now() ,結(jié)果是 timeasm.go 文件中的鏈接名字。
我們可以在 sys_windows_amd64.s 中看到相關(guān)的匯編代碼。
據(jù)我所知,這里的代碼路徑和 Linux 下的有些相似。time·now 匯編首先做的也是檢查是否使用 QPC 來(lái)獲取 nowQPC 函數(shù)的時(shí)間。
CMPB runtime·useQPCTime(SB), $0
JNE useQPC
useQPC:
JMP runtime·nowQPC(SB)
RET
如果不是這種情況,代碼將會(huì)嘗試使用下面KUSER_SHARED_DATA 結(jié)構(gòu)體中的兩個(gè)地址,也叫做SharedUserData。這個(gè)結(jié)構(gòu)體保存了一些內(nèi)核信息,與用戶態(tài)共享,是為了避免向內(nèi)核多次傳輸,和 vDSO 類似。
#define _INTERRUPT_TIME 0x7ffe0008
#define _SYSTEM_TIME 0x7ffe0014
KSYSTEM_TIME InterruptTime;
KSYSTEM_TIME SystemTime;
使用這兩個(gè)地址的部分如下所示。獲取的信息存在 KSYSTEM_TIME 結(jié)構(gòu)體中。
CMPB runtime·useQPCTime(SB), $0
JNE useQPC
MOVQ $_INTERRUPT_TIME, DI
loop:
MOVL time_hi1(DI), AX
MOVL time_lo(DI), BX
MOVL time_hi2(DI), CX
CMPL AX, CX
JNE loop
SHLQ $32, AX
ORQ BX, AX
IMULQ $100, AX
MOVQ AX, mono+16(FP)
MOVQ $_SYSTEM_TIME, DI
_SYSTEM_TIME 的問(wèn)題是更低的解析度,更新周期為 100 納秒;這也可能是優(yōu)先選擇 QPC 的原因。
在 Windows 部分我花費(fèi)了很長(zhǎng)的時(shí)間,若 你 感興趣,這里 有一些 更詳細(xì)的 信息
第 2 個(gè)未解之謎
這個(gè)問(wèn)題是什么來(lái)著?噢,我們還沒(méi)弄清楚 _ Local 從何而來(lái)?_
導(dǎo)出的 Local *Location 符號(hào)首先指向了 localLoc 的地址。
var Local *Location = &localLoc
如果這個(gè)地址是 nil,那么就如我們所說(shuō),返回的是 UTC 位置。否則,代碼會(huì)在需要位置信息的第一次調(diào)用時(shí),通過(guò)使用 sync.Once 語(yǔ)句來(lái)設(shè)置包級(jí)別的localLoc變量。
// localLoc is separate so that initLocal can initialize
// it even if a client has changed Local.
var localLoc Location
var localOnce sync.Once
func (l *Location) get() *Location {
if l == nil {
return &utcLoc
}
if l == &localLoc {
localOnce.Do(initLocal)
}
return l
}
initLocal() 函數(shù)使用 $TZ 的內(nèi)容來(lái)找到使用的時(shí)區(qū)。
如果 $TZ 變量沒(méi)有設(shè)置,Go 會(huì)使用系統(tǒng)默認(rèn)的文件如 /etc/localtime 來(lái)載入時(shí)區(qū)。如果設(shè)置但為空,Go 將使用 UTC 時(shí)區(qū),而當(dāng)它為無(wú)效的時(shí)區(qū)時(shí),它會(huì)從系統(tǒng)時(shí)區(qū)文件夾中找同名的文件。默認(rèn)的搜索路徑是
var zoneSources = []string{
"/usr/share/zoneinfo/",
"/usr/share/lib/zoneinfo/",
"/usr/lib/locale/TZ/",
runtime.GOROOT() + "/lib/time/zoneinfo.zip",
}
平臺(tái)相關(guān)的 zoneinfo_XYZ.go 文件使用相似的邏輯來(lái)尋找默認(rèn)的時(shí)區(qū),比如Windows 或 WASM。過(guò)去,當(dāng)我在類 Unix 系統(tǒng)下,需要在定制的容器鏡像中使用時(shí)區(qū)時(shí),只需要在 Dockerfile 中添加下面的命令。
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
另外,在無(wú)法控制構(gòu)建環(huán)境的情況下, tzdata 包提供了一個(gè) 嵌入復(fù)制 的時(shí)區(qū)數(shù)據(jù)庫(kù)。若這個(gè)包在任意位置引入或我們使用 -tags timetzdata 構(gòu)建標(biāo)簽,程序文件大小將會(huì)增加約 ~450KB,但將可以在 Go 無(wú)法在宿主系統(tǒng)中無(wú)法找到 tzdata 文件時(shí),提供一個(gè)備用的方式。
最后,我們也可以在代碼中使用LoadLocation 函數(shù)手動(dòng)設(shè)置時(shí)區(qū),比如在測(cè)試的情況下。
結(jié)尾
今天就這么多!我希望你們都可以學(xué)到一些新知識(shí),或者了解一些有趣的知識(shí)點(diǎn),并更加有信心去閱讀 Go 的源碼庫(kù)!
歡迎通過(guò)郵件或者 Twitter 聯(lián)系、提出修改建議。
再見(jiàn),保重!
獎(jiǎng)勵(lì):Go 中的 funcname1是什么?
在 Go 的代碼庫(kù)中,你將會(huì)見(jiàn)到很多 funcname1() 或 funcname2() 的引用,尤其是當(dāng)你看底層的代碼時(shí)。
據(jù)我理解,它們有兩個(gè)目的;它們有助于保持 Go 的兼容性保證,可以更加輕松的切換未導(dǎo)出函數(shù)的內(nèi)部實(shí)現(xiàn),通過(guò)也可以將相似功能“組合”和/或鏈接起來(lái)。
有些人可能嘲笑這種方式,但我認(rèn)為它是保持代碼可讀性和維護(hù)性的一種簡(jiǎn)單有效的方法。
