常见使用场景
在 Go 编程中,我们经常会遇到需要处理超时的场景,比如,在网络编程中,我们会通过一条连接复用发送多个请求,然后,对于每个请求,我们都会有一个超时时间,表示在多久时间之内如果没有收到这个请求的响应,那么我们就将返回超时的错误,这个时候,我们很自然就会想到 Go 标准库中的 time.After
这个函数,然后写出这样的代码:
[root@liqiang.io]# cat main.go
for req := range channel {
conn.Write(req)
go func(){
select {
case res := <-req.ch:
// success get the response
req.Ok()
case <-time.After(req.Timeout):
// timeout
req.Err = timeout
req.Cancel()
}
}()
}
常踩的坑
上面这段代码看上去逻辑没啥问题,但是,当我们实际跑起来之后有时会发现内存占用会不断的增高,然后 pprof 一番之后发现是 time.After 引起了来的,然后我们回来 review 代码,就会发现,这里有个问题,就是,既然当我们的 req 被正常响应之后,time.After 对应的内存并没有及时回收,而是会等待时间到了之后才会回收,所以,当这个 timeout 时间比较大的时候(分钟级),并且并发上来之后,那么内存就会明显地膨胀了,坑也就自然来了。
解决思路
那么,既然知道了问题所在,解决方法也就显而易见了,从 time.After 的实现中我们可以看到它实际上是创建了一个 time.Timer
,而 time.Timer
的方法有:
[root@liqiang.io]# cat sleep.go
func (t *Timer) Stop() bool
func (t *Timer) Reset(d Duration) bool
所以,我们可以直接自己创建一个 time.Timer
,然后在成功的场景下自己 Stop 这个 timer,然后对应的 goroutine 回收,这样就保证了资源的合理使用了。
[root@liqiang.io]# cat main.go
for req := range channel {
conn.Write(req)
go func(){
timeoutTimer := time.NewTimer(req.Timeout)
select {
case res := <-req.ch:
// success get the response
timeoutTimer.Stop()
req.Ok()
case <-timeoutTimer.C:
// timeout
req.Err = timeout
req.Cancel()
}
}()
}
扩展知识
timer 实现
在查看 time.Timer
的源码的时候,我们发现 startTimer 的实现并不在 sleep.go
中,而是直接声明了一个函数,按照阅读 Go 源码的管理,这个实现一般和底层是关联的,所以我翻了一下 Go 的源码仓库,了解一下实现,但是当我看 1.22 源码的时候,发现和很多网络上的资料不一致。然后再查了一下之后发现 Go 也在不断地迭代 timer 的实现,主要在 go 1.13 和 go 1.14 这两个版本之间差异比较大:
- go 1.13
- 实现以固定的 64 个 timer bucket 来做负载均衡
- 问题就是当 timer 数量比较多的时候会增加 P 和 M 的绑定和解绑频率,拉低效率
- go 1.14
- 实现为在 P 上增加 timers 的 4 叉树
- 通过调度循环或系统监控调度的时候进行触发执行,同时增加 netpoll 阻塞唤醒的机制让 timer 执行更及时(这一点我没有很清楚场景);
所以 go 1.22 的实现和 go 1.14 的基本一致,都是往 P 的 timer 树中加入 timer 对象,然后再调度的时候获取可执行的 timer 并且执行。
timer 的精度
看了 Go 的这个实现机制之后,我就有了另外一个疑问,那么就是如果按照这个 timer 调度策略的话,Go 能保证的 timer 精度是怎样的?我的理解这里至少有两个地方会影响精度:
- 从调用实现上来看,需要在 P 进行 goroutine 调度的时候才会进行调度,那么可能会收到运行中的 goroutine 的执行时间影响;
- timer 的 callback 是同步的,如果某个周期有多个 timer 需要执行,那么较后的 timer 执行会不会收到先执行 timer 的周期影响;
从引用文章 2 中,我学习到一个新的影响点,不过这个是要求比较高的情况下的需要考虑的,那就是:
- timer 的执行精度在时间计量上是精确到什么精度,是 ms/ns 还是 μs
从 Go 的实现上来看:
- 从 timer 的调度时机上来看,因为有两个调度时机,分别是 goroutine 的调度和系统守护 goroutine 调度,那么这里的调度精度就是 +10 ms;
- 这个我认为延迟时间应该是没有保证的,所以可能是个比较大的隐藏风险;
- timer 的执行精度是通过和系统的时间戳进行比较的,所以这里获取的系统时间精度是精确到 ns;
所以综上,如果我们对 timer 的精度有要求的话,可能将定时的任务拉出来单跑一个进程是一个比较稳妥的方法,而不是和业务逻辑一起混跑。
验证代码
我尝试写了一个测试的代码:https://github.com/liuliqiang/blog_codes/tree/master/golang/timer,使用这段代码:
[root@liqiang.io]# cat main.go
for {
time.Sleep(time.Millisecond * 10)
go func() {
t := time.NewTimer(time.Minute * 3)
select {
case res := <-ch1:
t.Stop()
fmt.Println(res)
case <-t.C:
fmt.Println("timeout")
}
}()
}
我们通过 top 查看内存会发现无论代码运行多久,那么内存占用都是稳定的。但是,如果我们注释一下这段代码,并且换成另外一段代码:
[root@liqiang.io]# cat main.go
for {
time.Sleep(time.Millisecond * 10)
go func() {
select {
case res := <-ch1:
fmt.Println(res)
case <-time.After(time.Minute * 3):
fmt.Println("timeout")
}
}()
}
然后会发现程序的占用代码会不断地增高,但是会有个限度。