Background
对于一些有多国家多时区差异的项目而言,模拟当地时间进行测试应该是比较头疼的事。最近的项目就遇到了冬夏令时切换的场景,想要进行测试就需要模拟切换前后的时间。
作为系统中最基本的元素--时间,如果只是粗暴的修改物理机时间,修改k8s的pod时间等操作,对于简单的单体服务可能可以实现目的;但在分布式系统中,这样的操作可能会导致服务被踢出集群,pod被k8s杀死或一系列意外。
这篇文章会先简单梳理一下golang的时间获取逻辑,并介绍一种mock系统时间的解决办法,只想找mock时间方法的可以直接跳到后面
step 1 time.Now是怎么获取时间的
在golang中,我们通常都是调用标准库的time.Now获取当前时间
2022-11-07 23:00:20.974412 +0800 CST m=+0.000049335
这串数字的意义分别是年月日以及时间(可以精确到ns)和时区,注意,最后这个m时golang时间里非常重要的部分,单调时间,表示了从项目启动到现在经过了多长时间(单位s)。
墙上时间(Wall Clocks)和单调时间(Monotonic Clocks)
wall Clocks,和大部分语言一样,表示我们平时理解的时间(墙上挂的钟的时间),存储的形式是自 1970 年 1 月 1 日 0 时 0 分 0 秒以来的时间戳,当系统和授时服务器进行校准时间时间操作时,有可能造成这一秒是2018-1-1 00:00:00,而下一秒变成了2017-12-31 23:59:59的情况。
Monotonic Clocks,意思是单调时间的,只会不停的往前增长,不受校时操作的影响,这个时间是自进程启动以来的秒数。
在标准库Time中,时间的结构如下
type Time struct {
// wall and ext encode the wall time seconds, wall time nanoseconds,
// and optional monotonic clock reading in nanoseconds.
//
// From high to low bit position, wall encodes a 1-bit flag (hasMonotonic),
// a 33-bit seconds field, and a 30-bit wall time nanoseconds field.
// The nanoseconds field is in the range [0, 999999999].
// If the hasMonotonic bit is 0, then the 33-bit field must be zero
// and the full signed 64-bit wall seconds since Jan 1 year 1 is stored in ext.
// If the hasMonotonic bit is 1, then the 33-bit field holds a 33-bit
// unsigned wall seconds since Jan 1 year 1885, and ext holds a
// signed 64-bit monotonic clock reading, nanoseconds since process start.
wall uint64
ext int64
// loc specifies the Location that should be used to
// determine the minute, hour, month, day, and year
// that correspond to this Time.
// The nil location means UTC.
// All UTC times are represented with loc==nil, never loc==&utcLoc.
loc *Location
}
wall和ext两个变量编码了当前的墙上时钟(秒和纳秒),单调时钟是可选的,如果有会以纳秒为单位编码。这个可选的单调时钟可以避免表达错误的时段,比如,夏令时(Daylight Saving time,DST)。
简单来说
如果这个时间不包含单调时间,那么wall的第一位为0,剩下的部分用来表示墙上时间的纳秒部分(2-34为0,35-64位纳秒部分);ext 为墙上时间的秒部分(从公元1年1月1日开始算的秒数)
如果这个时间包含单调时间,那么wall的第一位为1,2-34位存储墙上时间的秒部分(从1885年1月1号开始),35-64位纳秒部分;ext为进程启动后的单调时间(启动到现在的纳秒数)
我们从时间戳转成的时间是不包含单调时间的
time.Unix(1,0)
1970-01-01 08:00:01 +0800 CST
time.Unix(1667834689, 0)
2022-11-07 23:24:49 +0800 CST
从unix的实现上也可以看出来(Time.Unix()->unixTime())
unix转换而来的时间,wall保留当前时间戳纳秒部分,ext保留了当前时间戳的秒数部分(公元1年1月1号以来)加上了一个偏移,这个偏移就是1970年的秒数。
Ps. 这里需要注意,之前在尝试一些Mock方式来Mock time.Now()时,都是通过一些其他路径(其他语言的time应该都是保留的时间戳)拿到时间戳,然后转换成Time格式,这种转换会丢失单调时间,会导致time里的定时器和很多其他部件失效,无法使用
Ps.在数据库中存储的dateTime信息通过go获取到后也会失去单调时钟
time.Now()的流程
下面来看看时间包是怎么拿到当前时间的
now() 是go的runtime标准包实现的获取时间的方法,通过 go:linkname 链接过去,后面会讲这个黑科技,现在只要知道这个函数获取的当前时间秒数部分,纳秒部分和单调时间。
startNano 是init的时候就保存的一个时间,单位纳秒;mono - startNano可以获得程序运行到现在的单调时间。
因为time.Now获取的是有单调时间的,根据上面的分析,它的秒数部分应该是自1885年1月1日开始的秒数,而now拿到的时间,是自1970年以来的年秒数,所以需要sec = sec + 1970年的秒数 - 1885年的秒数来获得正确的秒数。
如果sec右移33位不为0,则表示时间溢出了,需要修改为用不含单调时间的结构来表示(wall为纳秒,ext为自1年1月1日来的秒数)
否则就正常用单调时间结构表示。
下面再看看now()的实现
我们在time包中是找不到now和nanotime的正式实现的,它们实际上是在runtime包中实现。
这里用了一个go编译的黑科技,go:linkname ,利用这个工具我们可以调用到其他包中的未导出函数,具体的使用方式可以参考参考文章
在now()函数中,walltime()和nanotime()也只是虚拟实现,在往下就是利用汇编进行系统调用了(也有人说go获取时间实际上用了一些手段不需要进行系统调用),这里就不继续分析了(太菜了看不懂)。可以参考这篇文章了解一下。文章
简而言之,walltime()可以获取的系统时间的秒数部分和纳秒部分--自1970年以来,nanotime()获取了系统的单调时间--纳秒单位。
step2 Mock时间
现在我们已经知道了time.Now是怎么获取时间,那么接下来就要想办法将这个时间替换成我们希望测试的时间,在成功之前我分别做了以下的尝试:
使用libfaketime库:github.com/wolfcw/libf…
这个库相当于将操作系统的Time函数进行一个重载,对于一些弱类型的语言如python,shell等,会在运行时进行调用系统的Time函数,进而调用到libfaketime重载的Time,实现mock时间;这个库的操作方式github上写的比较清楚,主要就是修改系统的动态链接环境变量。
libfaketime库+cgo
由于go并不会在运行时调用操作系统的Time函数,所以按照教程进行并不能成功mock,为了解决这个问题,引入了go的另一个黑科技cgo。
cgo可以让go在运行时调用c代码,通过这种方式,使libfaketime库能够生效
思路和这个repo类似:github.com/asppj/gofak…
/*
#include <time.h>
*/
import "C"
import (
"fmt"
"github.com/agiledragon/gomonkey"
"time"
)
func fakeTime() time.Time {
return time.Unix(int64(C.time(nil))+10, 0)
}
func main() {
cur := time.Now()
fmt.Println(cur)
patch := gomonkey.ApplyFunc(time.Now, func() time.Time {
fmt.Println("mock")
return fakeTime()
})
defer patch.Reset()
fmt.Println(time.Now())
}
Ps.goMonkey 打桩框架有可能报peimission denied问题,之前写过一篇文章介绍常见问题解决方法文章
直接使用系统的time和cgo
后面想想,既然操作系统基本都提供了time.h那为什么还要用libfaketime,直接使用上面的代码就能生效,不需要使用libfaketime和设置环境变量。
使用golinkname消除外部依赖
利用golinkname 重新实现一遍time.Now并加上偏移
利用gomonkey进行打桩
利用反射获取到time的时间结构并赋值
//go:linkname runtimeNano runtime.nanotime
func runtimeNano() int64
//go:linkname now time.now
func now() (sec int64, nsec int32, mono int64)
var startNano int64 = runtimeNano() - 1
func fakeTime() time.Time {
// mock的时间,自1970以来的时间戳,精度为s
sec, nsec, mono := sysNow()
// sec := int64(C.time(nil))
mono -= startNano
sec += 3600 * 24 * 365
sec += 62135596800 - 59453308800
source := time.Time{}
// 转换成go 的Time格式,有单调时钟,
wall := reflect.ValueOf(&source).Elem().FieldByName("wall")
ext := reflect.ValueOf(&source).Elem().FieldByName("ext")
local := reflect.ValueOf(&source).Elem().FieldByName("loc")
// 构建指向该字段的可寻址(addressable)反射对象
wall = reflect.NewAt(wall.Type(), unsafe.Pointer(wall.UnsafeAddr())).Elem()
ext = reflect.NewAt(ext.Type(), unsafe.Pointer(ext.UnsafeAddr())).Elem()
local = reflect.NewAt(local.Type(), unsafe.Pointer(local.UnsafeAddr())).Elem()
// 修改时间
newWall := reflect.ValueOf(1<<63 | uint64(sec)<<30 | uint64(nsec))
wall.Set(newWall)
newExt := reflect.ValueOf(int64(mono))
ext.Set(newExt)
newLocal := reflect.ValueOf(time.Local)
local.Set(newLocal)
return source
通过这种方式结合前面的打桩方法对 time 进行mock,可以复杂time的全部功能,测试没有出现什么问题
当然这种打桩mock时间的方式可以用于测试,但最好不要放到线上生产环境,因此可以通过条件编译,来根据环境决定要不要把相关内容编译进去。
上面的方式可能带来的问题
上面的几种方式实际上都是可以mock时间,使time.Now返回你期望的时间的。
但是,如果你用到了timer等东西,你会发现系统莫名其妙卡住,跑飞。
我在测试的时候就经历的被etcd踢出,调度任务无法执行等。利用goland或一些调试工具也找不到问题出现在哪里,只知道是大概是协程调度的时候出现了问题,导致定时器无法正常执行。
后面查阅各种资料加看源码,大概定位到了出现问题的地方。
- 什么情况下会出导致timer失效:
- mock的time.Now函数内使用了涉及到堆等用户态资源的情况
- 比如 使用fmt.Println;io.Buffer等,去掉就能正常执行
- mock的time.Now函数内使用了涉及到堆等用户态资源的情况
- 原因:
1、创建定时器时都会设置时间到期回调函数
2、调用回调函数时会调用time.Now,我们mock掉了这个函数,则会调用我们重新写的这个函数
3、golang在做系统调度时(runtime.runOneTimer,每当有timer到期,就会执行这个回调函数)
4、猜测问题出现在这个地方,系统在进行协程调度时陷入了内核态,而调用time.Now时,又从用户态进行了调用内核态的操作,感觉这里会出现冲突。
错误提示
fatal error: semacquire not on the G stack
-- 系统调度到了其他G协程,却是用了这个G协程栈上的东西,导致出错?
5、另外,go1.14使用这种方式进行简单测试可能也会莫名卡死(用简单的for+定时器的话),换1.18没问题