一次 Golang 的 time.Now 优化之旅

缘起

最近想尝试在 Golang 里面实现clock_gettimeCLOCK_REALTIME_COARSECLOCK_MONOTONIC_COARSE,正好深入研究了下 time.Now的实现,还机缘巧合下顺便优化了一把time.Now(虽然最终提交的是 Ian 大佬的版本)。

在这里记录下来整个过程,以供查阅。

time.Now 实现原理

首先我们来看看 time.Now的实现原理,从代码(以下代码基于 Go <= 1.16 版本)入手:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 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是怎么实现的:

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

根据关键字搜索,很快能搜到在runtimetimestub.go文件中的以上代码,可以看到实际上调用了两个方法:walltimenanotime,这两个方法又调用了walltime1nanotime1,并且是以汇编实现的,让我们继续深入看下这两个方法的汇编实现,因为代码基本相同,这边以walltime1作为例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// 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库)一样加载到用户内存空间里面,这样用户的进程就可以像调用一个普通方法一样调用这个方法了,可以避免系统调用的额外开销。具体可以参考一下:https://man7.org/linux/man-pages/man7/vdso.7.html。

看完walltime1之后我们来看下nanotime1,由于开头的切换到g0的代码都是一样的,所以这里只截取后续部分的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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_MONOTONICRET之前的处理逻辑——将返回结果转换成纳秒。

time.Now 优化

说到这里,大家应该就能发现问题所在了——time.Now调用了一次walltime和一次nanotime,这两次调用都有几乎一样的切换到g0栈再恢复的代码,而且这段代码量还比较多。如果我们把这两次调用给合并到一起,就可以节省一次切换栈和准备工作导致的额外开销了!

Go 官方团队的 Ian 大佬和我(几乎)同时提了对应的 pr 来优化这部分的逻辑,最终 Ian 大佬实现的性能更好(-20%,我的版本是 -17%),于是最终采用的是 Ian 大佬的版本:https://go-review.googlesource.com/c/go/+/314277/。

runtime外调用vdso

回到开头,我是想自己实现clock_gettimeCLOCK_REALTIME_COARSECLOCK_MONOTONIC_COARSE,这就需要我在runtime包外部实现以上的一系列操作。但是如果要这么干,就需要把所有runtime包里面的结构体定义全部复制一份(这样在汇编代码里面 include 的 go_asm.h才有对应的偏移量),这样可维护性太差了,而且如果某个版本调整了结构体的顺序,行为就不可定义,太危险了,要不就得每个版本单独复制一份出来。

针对这个问题,也和 Go 官方进行了讨论,最终确实没有什么太好的思路,Go 目前不支持在runtime外部安全地调用vdso

不过不管怎么样,在这个讨论的过程中,促成了time.Now的优化,还是不枉此行。