首页   

一次 Golang 的 time.Now 优化之旅

PureWhite  ·  · 3 年前
阅读 8

一次 Golang 的 time.Now 优化之旅

缘起

最近想尝试在 Golang 里面实现 clock_gettimeCLOCK_REALTIME_COARSECLOCK_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()
}
复制代码

根据关键字搜索,很快能搜到在 runtimetimestub.go 文件中的以上代码,可以看到实际上调用了两个方法:walltimenanotime,这两个方法又调用了 walltime1nanotime1,并且是以汇编实现的,让我们继续深入看下这两个方法的汇编实现,因为代码基本相同,这边以 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_MONOTONICRET 之前的处理逻辑 —— 将返回结果转换成纳秒。

time.Now 优化

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

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

runtime 外调用 vdso

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

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

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

推荐文章
Foodaily每日食品  ·  榴莲自由?别想了  ·  1 周前  
Interpreterdiary  ·  经济学人中有哪些地道的英文写作表达?  ·  1 年前  
台海军事热点  ·  借钱给别人、一定要记住7个字!  ·  6 年前  
© 2022 51好读
删除内容请联系邮箱 2879853325@qq.com