优化time.After,项目功能提高34%,内存削减67%
咱们好,今日给咱们带来一篇如何优化time.After
函数。
最近我在做调度中心2.0的重构。本次重构运用的GO言语开发。
在项目中,根本都离不开需要休眠等待必定时刻后再履行下一步逻辑的操作,再搭配select,用起来是真的舒畅。
func waitWorking() {
select {
case <-time.After(5 * time.Second): // 每隔5秒,主意向客户端询问使命状况
_ = receiver.CheckWorkingEventBus.Publish(receiver)
case <-receiver.updated:
}
}
在这个示例中,5秒后会履行Publish
函数,或许<-receiver.updated
有数据时退出,这是咱们比较常用的办法。
但有一点要注意的是:time.After假如没有被履行到,会导致无法第一时刻GC回收内存。
从内存剖析中,会看到内存在持续增长,到了必定时刻后,才会下降。这个增长幅度随着你的项目请求量而决议。
这是因为当<-receiver.updated
被触发履行时,导致time.After(5 * time.Second)
在5秒后才会有数据进来,在这5秒内,time.After创立的NewTimer(d)是无法回收的。
func After(d Duration) <-chan Time {
return NewTimer(d).C
}
明白了这一点之后,咱们能够简单的做一个改善
改善1:
func waitWorking() {
timer := time.NewTimer(5 * time.Second)
select {
case <-timer.C: // 每隔5秒,主意向客户端询问使命状况
_ = receiver.CheckWorkingEventBus.Publish(receiver)
case <-receiver.updated:
timer.Stop()
}
}
当<-receiver.updated
被触发履行时,咱们主动调用Stop
办法,来奉告GC,此timer方针不再运用。
这样就不至于等到5秒后,GC才知道这个方针不再运用。
这就完了吗?显示没有,假如waitWorking函数会在并发中被调用:
type TaskGroupMonitor struct {
updated chan struct{} // 数据有更新,让流程重置
name string // 使命称号
}
func (receiver *TaskGroupMonitor) waitWorking() {
timer := time.NewTimer(5 * time.Second)
select {
case <-timer.C: // 每隔5秒,主意向客户端询问使命状况
_ = receiver.CheckWorkingEventBus.Publish(receiver)
case <-receiver.updated:
timer.Stop()
}
}
func init() {
// 模仿数据库读到了100条使命
for i := 0; i < 100; i++ {
taskGroup:= TaskGroupMonitor{}
go taskGroup.waitWorking()
}
}
这里假如从数据库中读到了100条使命数据,每条数据都在独立的协程中运转。
这就会导致在这100条使命在运转的过程中,创立了100个time.Timer方针,事实上除了waitWorking
,还会有waitStart
,waitScheduler
,taskFinish
等函数也运用了time.Timer方针。
能够想到,项目在运转过程中time.Timer在不断的创立,直到GC后才被回收。这将导致咱们的内存一向占用着。
而且time.After
或time.NewTicker
并不是高精度的时刻控制。有时候会慢那么0-3ms,协程数量越多越繁忙,则越不精准。
这对于调度中心而言是无法接收的,我的方针是支撑几千个使命同时监控调度。意味着协程数量会十分高。
而在GO的time.Timer中是运用64个timersBucket
,并运用四叉堆
来办理各个timer,虽然在1.17版别有所改善。
但时刻上仍然没有那么精确,对于调度这种场景来说,对ms级别的推迟也是没办法承受的。
time.Timer原理不在本篇的范围内,现在有许多大神有这方面的剖析,感兴趣能够去搜搜。
剖析问题
经过简单的剖析,咱们现已知道运用time.Timer会有如下缺陷:
- 每个协程需要创立time.Timer(导致内存占用上升)
- time.Timer会有推迟(对于ms敏感的场景不适用)
即如此,咱们是否能够经过统一的时刻办理器
来办理一切的时刻触发器呢?
答案是显而易见的,那便是时刻轮。
时刻轮
时刻轮是一种完成推迟功能的算法, 它在Linux内核中运用广泛, 是Linux内核定时器的完成办法和基础之一. 时刻轮是一种高效来运用线程资源来进行批量化调度的一种调度模型, 把很多的调度使命悉数绑定到同一个调度器上, 运用这个调度器来进行一切使命的办理, 触发以及运转.
简单来说,时刻轮便是一个模仿时钟的原理。 完成办法有:单层、双层、多层三种办法。
而在双层、多层时刻轮中,又有两种算法:一种是不管几层,时刻周期是相同的。另一种是低层一圈 = 上层一格(像秒针、分针相同)
在时钟里,秒针走完一圈,分针走一格。分针走完一圈,时针走一格。以此类推。
当秒针走到第X格,会到第X格的队列中找到是否有待履行使命列表,假如有则取出并通知到C变量。
而我在完成这个时刻轮便是彻底模仿时钟的这种算法来完成的。我与其它开源的时刻轮不相同的地方是,我是高精度算法的。
时刻轮的原理大约就讲这么多,究竟不是一个什么新鲜的算法,网上有许多讲的比我更透彻的大神,在这里我主要讲运用时刻轮的前后对比。
咱们来看看如何运用:
// 在项目中,界说一个全局变量tw,并规则第0层,走一格=100ms,一圈有120格
import "github.com/farseer-go/fs/timingWheel"
var tw = timingWheel.New(100*time.Millisecond, 120)
tw.Start()
接着在项目中咱们改成时刻轮来控制时刻:
func (receiver *TaskGroupMonitor) waitWorking() {
select {
case <-tw.AddPrecision(60 * time.Second).C:
_ = receiver.CheckWorkingEventBus.Publish(receiver)
case <-receiver.updated:
}
}
至此,咱们运用了全局tw变量来控制时刻的推迟办理。
咱们来看下,优化前的情况:
100个并发下调度:均匀推迟:10ms
、CPU:31.4%
、内存:115m
优化后:
100个并发下调度:均匀推迟:1ms
、CPU:21.7%
、内存:34.5m
为此,整体功能提高:34%
,内存削减:67%
相关资料:
- farseer-go开源地址:github.com/farseer-go/…
- 时刻轮开源地址:github.com/farseer-go/…
- 调度中心开源地址:github.com/FSchedule/F…