缘起
最近想尝试在 Golang 里面实现 clock_gettime
的 CLOCK_REALTIME_COARSE
和 CLOCK_MONOTONIC_COARSE
,正好深入研究了下 time.Now
的实现,还机缘巧合下顺便优化了一把 time.Now
(虽然最终提交的是 Ian 大佬的版本)。
在这里记录下来整个过程,以供查阅。
time.Now 实现原理
首先我们来看看 time.Now
的实现原理,从代码(以下代码基于 Go <= 1.16 版本)入手:
// Provided by package runtime.
func now() (sec int64, nsec int32, mono int64)
// 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}
}
复制代码
可以看到,time.Now
里面实际上是调用了 now
来获得对应的时间数值,然后进行了一系列的处理。这部分处理就不说了,网上有较多资料,也不是本文重点。我们接着去 runtime
包里面找找 now
是怎么实现的:
//go:linkname time_now time.now
func time_now() (sec int64, nsec int32, mono int64) {
sec, nsec = walltime()
return sec, nsec, nanotime()
}
复制代码
根据关键字搜索,很快能搜到在 runtime
的 timestub.go
文件中的以上代码,可以看到实际上调用了两个方法:walltime
和 nanotime
,这两个方法又调用了 walltime1
和 nanotime1
,并且是以汇编实现的,让我们继续深入看下这两个方法的汇编实现,因为代码基本相同,这边以 walltime1
作为例子:
// 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
复制代码
这段代码的注释非常的清晰,根据这段代码,可以看到,实际上是使用的 vdso call
来获取到当前的时间信息。只不过,由于 Go 是自己维护的协程的栈,而这个栈在某些内核上调用 vdso
会出问题,所以需要先切换到 g0
(也就是系统线程的栈)上才行。所以这里在开头和结尾有很多额外的操作,需要制造和清理作案现场。
有同学可能对 vdso
不了解,这里简单介绍下,实际上一开始获取时间信息是需要通过系统调用的,也就是要 syscall 才行,但是众所周知,syscall 的性能较差,同时获取时间戳又是个高频操作,所以大家也想办法优化了几版,最终是现在采用的 vdso
的方案。vdso
全称是 virtual dynamic shared object
,简单来说就是把这段原本需要系统调用的方法,像动态链接库(so
库)一样加载到用户内存空间里面,这样用户的进程就可以像调用一个普通方法一样调用这个方法了,可以避免系统调用的额外开销。具体可以参考一下:man7.org/linux/man-p…
看完 walltime1
之后我们来看下 nanotime1
,由于开头的切换到 g0
的代码都是一样的,所以这里只截取后续部分的代码:
noswitch:
SUBQ $16, SP // Space for results
ANDQ $~15, SP // Align for C code
MOVL $1, DI // CLOCK_MONOTONIC
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)
// sec is in AX, nsec in DX
// return nsec in AX
IMULQ $1000000000, AX
ADDQ DX, AX
MOVQ AX, ret+0(FP)
RET
复制代码
可以看到,唯二修改的就是调用的 clockid
——CLOCK_MONOTONIC
和 RET
之前的处理逻辑 —— 将返回结果转换成纳秒。
time.Now 优化
说到这里,大家应该就能发现问题所在了 ——time.Now
调用了一次 walltime
和一次 nanotime
,这两次调用都有几乎一样的切换到 g0
栈再恢复的代码,而且这段代码量还比较多。如果我们把这两次调用给合并到一起,就可以节省一次切换栈和准备工作导致的额外开销了!
Go 官方团队的 Ian 大佬和我(几乎)同时提了对应的 pr 来优化这部分的逻辑,最终 Ian 大佬实现的性能更好(-20%,我的版本是 -17%),于是最终采用的是 Ian 大佬的版本:go-review.googlesource.com/c/go/+/3142…
在 runtime
外调用 vdso
?
回到开头,我是想自己实现 clock_gettime
的 CLOCK_REALTIME_COARSE
和 CLOCK_MONOTONIC_COARSE
,这就需要我在 runtime
包外部实现以上的一系列操作。但是如果要这么干,就需要把所有 runtime
包里面的结构体定义全部复制一份(这样在汇编代码里面 include 的 go_asm.h
才有对应的偏移量),这样可维护性太差了,而且如果某个版本调整了结构体的顺序,行为就不可定义,太危险了,要不就得每个版本单独复制一份出来。
针对这个问题,也和 Go 官方进行了讨论,最终确实没有什么太好的思路,Go 目前不支持在 runtime
外部安全地调用 vdso
。
不过不管怎么样,在这个讨论的过程中,促成了 time.Now
的优化,还是不枉此行。