time.After和select搭配使用时存在的”坑“

简介: time.After和select搭配使用时存在的”坑“

昨夜西风凋碧树

Golang中select的四大用法/#超时控制中,提到select搭配time.After实现超时控制。其实这样写是有问题的。

由于这种写法每次都会初始化新的time.After,当等待时间较长,比如1分钟,会发生内存泄露(当然问题并不仅限于此,继续看下去)

不知道是谁带的,在Go中用select和time.After做超时控制,近乎于成了事实上的标准,像

Golang time.After()用法及代码示例这样的例子网上比比皆是

在许多大公司代码仓库里,一搜<- time.After关键字有一大堆,而且后面的时间不少都是几分钟。

用pprof看下不难发现,这是教科书级的错误…每次都初始化,但执行前不会被回收,造成内存暴涨。

建议有空去搜下,看看是不是代码里这种用法有一大把…

可以参考

峰云-分析golang time.After引起内存暴增OOM问题

慎用time.After会造成内存泄漏(golang)

这几篇分析,实际验证一下

after.go:

package main
import (
  "fmt"
  "net/http"
  _ "net/http/pprof"
  "time"
)
/**
  time.After oom 验证demo
*/
func main() {
  go func() {
    // 开启pprof,监听请求
    if err := http.ListenAndServe(":6060", nil); err != nil { // 也可以写成 127.0.0.1:6060
      fmt.Printf("start pprof failed on %s,err%v \n", "6060", err)
    }
  }()
  ch := make(chan string, 100)
  go func() {
    for {
      ch <- "向管道塞入数据"
    }
  }()
  for {
    select {
    case <-ch:
    case <-time.After(time.Minute * 3):
    }
  }
}

运行这段程序,然后执行

微信截图_20230626171924.png这行命令可以分成三部分:

  • -http=:8081是指定以web形式,在本地8081端口启动
    (如果不加-http=:8081参数,则会进入命令行交互,在命令行中再输入web与直接使用-http=:8081参数效果等效)
  • http://localhost:6060/debug/pprof/heap 是指定获取profile文件的地址。本地在实时运行的程序可以用这种方式,更多情况下(如在服务器上,没有对外开放用于pprof的端口),可以先去机器上,用curl http://127.0.0.1:6060/debug/pprof/heap -o heap_cui.out拿到profile文件,再想办法弄到本地,使用go tool pprof --http :9091 heap_cui.out进行分析

微信截图_20230626172009.png

而且随着时间推移,程序占用的内存会继续增加

微信截图_20230626172049.png

从调用图可发现, 程序不断调用time.After,进而导致计时器 time.NerTimer 不断创建和内存申请

// After waits for the duration to elapse and then sends the current time
// on the returned channel.
// It is equivalent to NewTimer(d).C.
// The underlying Timer is not recovered by the garbage collector
// until the timer fires. If efficiency is a concern, use NewTimer
// instead and call Timer.Stop if the timer is no longer needed.
//After 等待持续时间过去,然后在返回的通道上发送当前时间。
//它相当于 NewTimer(d).C。
//在定时器触发之前,垃圾收集器不会恢复底层定时器。 如果效率是一个问题,请改用 NewTimer 并在不再需要计时器时调用 Timer.Stop。
func After(d Duration) <-chan Time {
  return NewTimer(d).C
}

在select里面虽然没有执行到time.After,但每次都会初始化,会在时间堆里面,定时任务未到期之前,是不会被gc清理的

  • 在计时器触发之前,垃圾收集器不会回收Timer
  • 如果考虑效率,需要使用NewTimer替代

衣带渐宽终不悔

使用NewTimer 或NewTicker替代:

package main
import (
  "fmt"
  "net/http"
  _ "net/http/pprof"
  "time"
)
/**
  time.After oom 验证demo
*/
func main() {
  go func() {
    // 开启pprof,监听请求
    if err := http.ListenAndServe(":6060", nil); err != nil { // 也可以写成 127.0.0.1:6060
      fmt.Printf("start pprof failed on %s,err%v \n", "6060", err)
    }
  }()
  ticker := time.NewTicker(time.Minute * 3)
    // 或
    //timer := time.NewTimer(3 * time.Minute)
    //defer timer.Stop()
    // 下方的case <-ticker.C:相应改为case <-timer.C:
  ch := make(chan string, 100)
  go func() {
    for {
      ch <- "向管道塞入数据"
    }
  }()
  for {
    select {
    case <-ch:
    case <-ticker.C:
      print("结束执行")
    }
  }
}

微信截图_20230626173008.png

这篇Go 内存泄露之痛,这篇把 Go timer.After 问题根因讲透了!应该有点问题,不是内存孤儿,gc还是会去回收的,只是要在time.After到期之后

众里寻他千百度

如上是网上大多数技术文章的情况:

  • 昨夜西风凋碧树,独上高楼,望断天涯路: select + time.After实现超时控制
  • 衣带渐宽终不悔,为伊消得人憔悴: 这样写有问题,会内存泄露,要用NewTimer 或NewTicker替代time.After

其实针对本例,这些说法都没有切中肯綮

最初的代码仅仅是有内存泄露的问题吗?

实际上,即便3分钟后,第2个case也得不到执行 (可以把3min改成2s验证下)

只要第一个case能不断从channel中取出数据(在此显然可以),那第二个case就永远得不到执行。这是因为每次time.After都被重新初始化了,而上面那个case一直满足条件,当然就是第二个case一直得不到执行, 除非第一个case超过3min没有从channel中拿到数据

所以其实在此例中NewTimer还是NewTicker,都不是问题本质,这个问题本质,就是个变量作用域的问题

在for循环外定义time.After(time.Minute * 3),如下:

package main
import (
  "fmt"
  "net/http"
  _ "net/http/pprof"
  "time"
)
func main() {
  go func() {
    // 开启pprof,监听请求
    if err := http.ListenAndServe(":6060", nil); err != nil { // 也可以写成 127.0.0.1:6060
      fmt.Printf("start pprof failed on %s,err%v \n", "6060", err)
    }
  }()
  ch := make(chan string, 100)
  go func() {
    for {
      ch <- "向管道塞入数据"
    }
  }()
  timeout := time.After(time.Minute * 3)
  for {
    select {
    case <-ch:
    case <-timeout:
      fmt.Println("到了这里")
    }
  }
}

把time.After放到循环外,可以看到,并没有什么内存泄露,3min(可能多一点点)后,如期执行到了第2个case

微信截图_20230626173142.png

所以在这个场景下,并不是time.After

在计时器触发之前,垃圾收集器不会回收Timer

的问题,而是最起码的最被忽略的变量作用域问题..

(程序员的锅,并不是time.After的问题…用NewTimer还是NewTicker之所以不会内存泄露,只是因为是在for循环外面初始化的…)

之前在for循环里case <-time.After(time.Minute * 3)的写法,效果类似下面:

package main
import "time"
func main() {
  for {
    time.After(2 * time.Second)
  }
}

验证是否会成为所谓的”内存孤儿”

改造程序,验证一下:

在计时器触发之前,垃圾收集器不会回收Timer

但在

计时器触发后,垃圾收集器会回收这些Timer

,并不会造成“内存孤儿”

package main
import (
  "fmt"
  "net/http"
  _ "net/http/pprof"
  "sync/atomic"
  "time"
)
func main() {
  go func() {
    // 开启pprof,监听请求
    if err := http.ListenAndServe(":6060", nil); err != nil { // 也可以写成 127.0.0.1:6060
      fmt.Printf("start pprof failed on %s,err%v \n", "6060", err)
    }
  }()
  after()
  fmt.Println("程序结束")
}
func after() {
  var i int32
  ch := make(chan string, 0)
  done := make(chan string) // 设定的时间已到,通知结束循环,不要再往channel里面写数据
  go func() {
    for {
      select {
      default:
        atomic.AddInt32(&i, 1)
        ch <- fmt.Sprintf("%s%d%s", "向管道第", i, "次塞入数据")
      case exit := <-done:
        fmt.Println("关闭通道", exit)
        return
      }
    }
  }()
  go func() {
    time.Sleep(time.Second)
    done <- "去给我通知不要再往ch这个channel里写数据了!"
  }()
  for {
    select {
    case res := <-ch:
      fmt.Println("res:", res)
    case <-time.After(2 * time.Second):
      fmt.Println("结束接收通道的数据")
      return
    }
  }
}

微信截图_20230626173328.png

去掉打印的信息,替换为当前实时的内存信息:

package main
import (
  "fmt"
  "net/http"
  _ "net/http/pprof"
  "runtime"
  "sync/atomic"
  "time"
)
func main() {
  go func() {
    // 开启pprof,监听请求
    if err := http.ListenAndServe(":6060", nil); err != nil { // 也可以写成 127.0.0.1:6060
      fmt.Printf("start pprof failed on %s,err%v \n", "6060", err)
    }
  }()
  after()
  fmt.Println("程序结束")
}
func after() {
  var ms runtime.MemStats
  runtime.ReadMemStats(&ms)
  fmt.Println("before, have", runtime.NumGoroutine(), "goroutines,", ms.Alloc, "bytes allocated", ms.HeapObjects, "heap object")
  var i int32
  ch := make(chan string, 0)
  done := make(chan string) // 设定的时间已到,通知结束循环,不要再往channel里面写数据
  go func() {
    for {
      select {
      default:
        atomic.AddInt32(&i, 1)
        ch <- fmt.Sprintf("%s%d%s", "向管道第", i, "次塞入数据")
      case exit := <-done:
        fmt.Println("关闭通道", exit)
        return
      }
    }
  }()
  go func() {
    time.Sleep(time.Second)
    done <- "去给我通知不要再往ch这个channel里写数据了!"
  }()
  for {
    select {
    case res := <-ch:
      runtime.GC()
      runtime.ReadMemStats(&ms)
      fmt.Printf("%s,now have %d goroutines,%d bytes allocated, %d heap object \n", res, runtime.NumGoroutine(), ms.Alloc, ms.HeapObjects)
    case <-time.After(2 * time.Second):
      runtime.GC()
      fmt.Println("当前结束接收通道的数据,准备返程")
      runtime.ReadMemStats(&ms)
      fmt.Printf("now have %d goroutines,%d bytes allocated, %d heap object \n", runtime.NumGoroutine(), ms.Alloc, ms.HeapObjects)
      return
    }
  }
}

微信截图_20230626173413.png

更多参考:

Go time.NewTicker()与定时器

tech-talk-time.After不断初始化的

目录
相关文章
|
4月前
|
SQL Oracle 关系型数据库
深入解析 NOW() 与 CURRENT_DATE() 的区别
【8月更文挑战第31天】
227 0
|
7月前
|
数据库
count(1)、count(*)、count(column)的含义、区别、执行效率
总之,`count(1)` 和 `count(*)` 通常会更常用,因为它们的执行效率较高,不涉及对具体列值的处理。而 `count(column)` 适用于统计特定列中的非空值数量。在实际使用时,可以根据情况选择适合的方式。 买CN2云服务器,免备案服务器,高防服务器,就选蓝易云。百度搜索:蓝易云
103 0
|
存储 SQL 关系型数据库
count(1)、count(具体字段)和count(*)究竟有什么区别?
count(1)、count(具体字段)和count(*)究竟有什么区别?
142 0
|
关系型数据库 MySQL
MySQL:自动维护create_time和update_time字段
通过建表语句设置,让mysql自动维护这两个字段,那么编程的时候也能少写一部分代码
92 0
Sap Ds Data is not available. Increase the time-out interval values in Debug | Options
Sap Ds Data is not available. Increase the time-out interval values in Debug | Options
139 0
|
SQL 关系型数据库 MySQL
MYSQL创建100万条数据与count(1)、count(*)、count(column)区别
MYSQL创建100万条数据与count(1)、count(*)、count(column)区别.md
|
存储 关系型数据库 MySQL
一文搞清楚 MySQL count(*)、count(1)、count(col) 的区别
一文搞清楚 MySQL count(*)、count(1)、count(col) 的区别
378 0
一文搞清楚 MySQL count(*)、count(1)、count(col) 的区别
SAP MM ME21N 创建PO时报错 - Net price in CNY becomes too large – 之原因分析
SAP MM ME21N 创建PO时报错 - Net price in CNY becomes too large – 之原因分析
SAP MM ME21N 创建PO时报错 - Net price in CNY becomes too large – 之原因分析
|
存储 关系型数据库 MySQL
MySQL下count(*)、count(1)和count(字段)的查询效率比较
COUNT(*)和COUNT(1)都是对所有结果进行计算。如果有WHERE子句,则是对所有符合筛选条件的数据行进行统计;如果没有WHERE子句,则是对数据表的数据行数进行统计。
446 0