一次 Golang 的 time.Now 优化之旅
缘起
最近想尝试在 Golang 里面实现clock_gettime的CLOCK_REALTIME_COARSE和CLOCK_MONOTONIC_COARSE,正好深入研究了下 time.Now的实现,还机缘巧合下顺便优化了一把time.Now(虽然最终提交的是 Ian 大佬的版本)。
在这里记录下来整个过程,以供查阅。
time.Now 实现原理
首先我们来看看 time.Now的实现原理,从代码(以下代码基于 Go <= 1.16 版本)入手:
1 | // Provided by package runtime. |
可以看到,time.Now里面实际上是调用了now来获得对应的时间数值,然后进行了一系列的处理。这部分处理就不说了,网上有较多资料,也不是本文重点。我们接着去runtime包里面找找now是怎么实现的:
1 | //go:linkname time_now time.now |
根据关键字搜索,很快能搜到在runtime的timestub.go文件中的以上代码,可以看到实际上调用了两个方法:walltime和nanotime,这两个方法又调用了walltime1和nanotime1,并且是以汇编实现的,让我们继续深入看下这两个方法的汇编实现,因为代码基本相同,这边以walltime1作为例子:
1 | // func walltime1() (sec int64, nsec int32) |
这段代码的注释非常的清晰,根据这段代码,可以看到,实际上是使用的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 | noswitch: |
可以看到,唯二修改的就是调用的clockid——CLOCK_MONOTONIC和RET之前的处理逻辑——将返回结果转换成纳秒。
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_gettime的CLOCK_REALTIME_COARSE和CLOCK_MONOTONIC_COARSE,这就需要我在runtime包外部实现以上的一系列操作。但是如果要这么干,就需要把所有runtime包里面的结构体定义全部复制一份(这样在汇编代码里面 include 的 go_asm.h才有对应的偏移量),这样可维护性太差了,而且如果某个版本调整了结构体的顺序,行为就不可定义,太危险了,要不就得每个版本单独复制一份出来。
针对这个问题,也和 Go 官方进行了讨论,最终确实没有什么太好的思路,Go 目前不支持在runtime外部安全地调用vdso。
不过不管怎么样,在这个讨论的过程中,促成了time.Now的优化,还是不枉此行。