我在春节期间写了一篇文章有关时间轮的:https://www.luozhiyun.com/archives/444。后来有同学建议我去看看 1.14版本之后的 timer 优化。然后我就自己就时间轮和 timer 也做了个 benchmark:
goos: darwin goarch: amd64 pkg: gin-test/api/main BenchmarkTimingWheel_StartStop/N-1m-12 4582120 254 ns/op 85 B/op 2 allocs/op BenchmarkTimingWheel_StartStop/N-5m-12 3356630 427 ns/op 46 B/op 1 allocs/op BenchmarkTimingWheel_StartStop/N-10m-12 2474842 483 ns/op 60 B/op 1 allocs/op BenchmarkStandardTimer_StartStop/N-1m-12 6777975 179 ns/op 84 B/op 1 allocs/op BenchmarkStandardTimer_StartStop/N-5m-12 6431217 231 ns/op 85 B/op 1 allocs/op BenchmarkStandardTimer_StartStop/N-10m-12 5374492 266 ns/op 83 B/op 1 allocs/op PASS ok gin-test/api/main 60.414s从上面可以直接看出,在添加了一千万个定时器后,时间轮的单次调用时间有明显的上涨,但是 timer 却依然很稳。
从官方的一个数据显示:
name old time/op new time/op delta AfterFunc-12 1.57ms ± 1% 0.07ms ± 1% -95.42% (p=0.000 n=10+8) After-12 1.63ms ± 3% 0.11ms ± 1% -93.54% (p=0.000 n=9+10) Stop-12 78.3µs ± 3% 73.6µs ± 3% -6.01% (p=0.000 n=9+10) SimultaneousAfterFunc-12 138µs ± 1% 111µs ± 1% -19.57% (p=0.000 n=10+9) StartStop-12 28.7µs ± 1% 31.5µs ± 5% +9.64% (p=0.000 n=10+7) Reset-12 6.78µs ± 1% 4.24µs ± 7% -37.45% (p=0.000 n=9+10) Sleep-12 183µs ± 1% 125µs ± 1% -31.67% (p=0.000 n=10+9) Ticker-12 5.40ms ± 2% 0.03ms ± 1% -99.43% (p=0.000 n=10+10) ...在很多项测试中,性能确实得到了很大的增强。下面也就一起看看性能暴涨的原因。
介绍 1.13 版本的 timerGo 在1.14版本之前是使用 64 个最小堆,运行时创建的所有计时器都会加入到最小堆中,每个处理器(P)创建的计时器会由对应的最小堆维护。
下面是1.13版本 runtime.time源码:
const timersLen = 64 var timers [timersLen]struct { timersBucket // padding, 防止false sharing pad [sys.CacheLineSize - unsafe.Sizeof(timersBucket{})%sys.CacheLineSize]byte } // 获取 P 对应的 Bucket func (t *timer) assignBucket() *timersBucket { id := uint8(getg().m.p.ptr().id) % timersLen t.tb = &timers[id].timersBucket return t.tb } type timersBucket struct { lock mutex gp *g created bool sleeping bool rescheduling bool sleepUntil int64 waitnote note // timer 列表 t []*timer }通过上面的 assignBucket 方法可以知道,如果当前机器上的处理器 P 的个数超过了 64,多个处理器上的计时器就可能存储在同一个桶 timersBucket 中。
每个桶负责管理一堆这样有序的 timer,同时每个桶都会有一个对应的 timerproc 异步任务来负责不断调度这些 timer。t
imerproc 会从 timersBucket 不断取堆顶元素,如果堆顶的 timer 已到期则执行,没有任务到期则 sleep,所有任务都消耗完了,那么调用 gopark 挂起,直到有新的 timer 被添加到桶中时,才会被重新唤醒。
timerproc 在 sleep 的时候会调用 notetsleepg ,继而引发entersyscallblock调用,该方法会主动调用 handoffp ,解绑 M 和 P。当下一个定时时间到来时,又会进行 M 和 P 的绑定,处理器 P 和线程 M 之间频繁的上下文切换也是 timer 的首要性能影响因素之一。
1.14 版本后 timer 的变化在Go 在1.14版本之后,移除了timersBucket,所有的计时器都以最小四叉堆的形式存储 P 中。
type p struct { ... // 互斥锁 timersLock mutex // 存储计时器的最小四叉堆 timers []*timer // 计时器数量 numTimers uint32 // 处于 timerModifiedEarlier 状态的计时器数量 adjustTimers uint32 // 处于 timerDeleted 状态的计时器数量 deletedTimers uint32 ... }timer 不再使用 timerproc 异步任务来调度,而是改用调度循环或系统监控调度的时候进行触发执行,减少了线程之间上下文切换带来的性能损失,并且通过使用 netpoll 阻塞唤醒机制可以让 timer 更加及时的得到执行。
timer的使用time.Timer计时器必须通过time.NewTimer、time.AfterFunc或者 time.After 函数创建。
如下time.NewTimer:
通过定时器的字段C,我们可以及时得知定时器到期的这个事件来临,C是一个chan time.Time类型的缓冲通道,一旦触及到期时间,定时器就会向自己的C字段发送一个time.Time类型的元素值
func main() { //初始化定时器 t := time.NewTimer(2 * time.Second) //当前时间 now := time.Now() fmt.Printf("Now time : %v.\n", now) expire := <- t.C fmt.Printf("Expiration time: %v.\n", expire) }