缘起
最近想尝试在 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
的优化,还是不枉此行。