Go 防止 goroutine 泄露的方法

概述

Go 的并发模型与其他语言不同,虽说它简化了并发程序的开发难度,但如果不了解使用方法,常常会遇到 goroutine 泄露的问题。虽然 goroutine 是轻量级的线程,占用资源很少,但如果一直得不到释放并且还在不断创建新协程,毫无疑问是有问题的,并且是要在程序运行几天,甚至更长的时间才能发现的问题。

对于上面描述的问题,我觉得可以从两方面入手解决,如下:

一是预防,要做到预防,我们就需要了解什么样的代码会产生泄露,以及了解如何写出正确的代码;

二是监控,虽说预防减少了泄露产生的概率,但没有人敢说自己不犯错,因而,通常我们还需要一些监控手段进一步保证程序的健壮性;

接下来,我将会分两篇文章分别从这两个角度进行介绍,今天先谈第一点。

如何监控泄露

本文主要集中在第一点上,但为了更好的演示效果,可以先介绍一个最简单的监控方式。通过 runtime.NumGoroutine() 获取当前运行中的 goroutine 数量,通过它确认是否发生泄漏。它的使用非常简单,就不为它专门写个例子了。

一个简单的例子

语言级别的并发支持是 Go 的一大优势,但这个优势也很容易被滥用。通常我们在开始 Go 并发学习时,常常听别人说,Go 的并发非常简单,在调用函数前加上 go 关键词便可启动 goroutine,即一个并发单元,但很多人可能只听到了这句话,然后就出现了类似下面的代码:

package main
import (
 "fmt"
 "runtime"
 "time"
)
func sayHello() {
 for {
 fmt.Println("Hello gorotine")
 time.Sleep(time.Second)
 }
}
func main() {
 defer func() {
 fmt.Println("the number of goroutines: ", runtime.NumGoroutine())
 }()
 go sayHello()
 fmt.Println("Hello main")
}

对 Go 比较熟悉的话,很容易发现这段代码的问题,sayHello 是个死循环,没有如何退出机制,因此也就没有任何办法释放创建的 goroutine。我们通过在 main 函数最前面的 defer 实现在函数退出时打印当前运行中的 goroutine 数量,毫无意外,它的输出如下:

the number of goroutines: 2

不过,因为上面的程序并非常驻,有泄露问题也不大,程序退出后系统会自动回收运行时资源。但如果这段代码在常驻服务中执行,比如 http server,每接收到一个请求,便会启动一次 sayHello,时间流逝,每次启动的 goroutine 都得不到释放,你的服务将会离奔溃越来越近。

这个例子比较简单,我相信,对 Go 的并发稍微有点了解的朋友都不会犯这个错。

泄露情况分类

前面介绍的例子由于在 goroutine 运行死循环导致的泄露。接下来,我会按照并发的数据同步方式对泄露的各种情况进行分析。简单可归于两类,即:

  • channel 导致的泄露
  • 传统同步机制导致的泄露

传统同步机制主要指面向共享内存的同步机制,比如排它锁、共享锁等。这两种情况导致的泄露还是比较常见的。go 由于 defer 的存在,第二类情况,一般情况下还是比较容易避免的。

chanel 引起的泄露

先说 channel,如果之前读过官方的那篇并发的文章[1],翻译版[2],你会发现 channel 的使用,一个不小心就泄露了。我们来具体总结下那些情况下可能导致。

发送不接收

我们知道,发送者一般都会配有相应的接收者。理想情况下,我们希望接收者总能接收完所有发送的数据,这样就不会有任何问题。但现实是,一旦接收者发生异常退出,停止继续接收上游数据,发送者就会被阻塞。这个情况在 前面说的文章[3] 中有非常细致的介绍。

示例代码:

package main
import "time"
func gen(nums ...int) <-chan int {
 out := make(chan int)
 go func() {
 for _, n := range nums {
  out <- n
 }
 close(out)
 }()
 return out
}
func main() {
 defer func() {
 fmt.Println("the number of goroutines: ", runtime.NumGoroutine())
 }()
 // Set up the pipeline.
 out := gen(2, 3)
 for n := range out {
 fmt.Println(n)  // 2
 time.Sleep(5 * time.Second) // done thing, 可能异常中断接收
 if true { // if err != nil
  break
 }
 }
}

例子中,发送者通过 out chan 向下游发送数据,main 函数接收数据,接收者通常会依据接收到的数据做一些具体的处理,这里用 Sleep 代替。如果这期间发生异常,导致处理中断,退出循环。gen 函数中启动的 goroutine 并不会退出。

如何解决?

此处的主要问题在于,当接收者停止工作,发送者并不知道,还在傻傻地向下游发送数据。故而,我们需要一种机制去通知发送者。我直接说答案吧,就不循渐进了。Go 可以通过 channel 的关闭向所有的接收者发送广播信息。

修改后的代码:

package main
import "time"
func gen(done chan struct{}, nums ...int) <-chan int {
 out := make(chan int)
 go func() {
 defer close(out)
 for _, n := range nums {
  select {
  case out <- n:
  case <-done:
  return
  }
 }
 }()
 return out
}
func main() {
 defer func() {
 time.Sleep(time.Second)
 fmt.Println("the number of goroutines: ", runtime.NumGoroutine())
 }()
 // Set up the pipeline.
 done := make(chan struct{})
 defer close(done)
 out := gen(done, 2, 3)
 for n := range out {
 fmt.Println(n) // 2
 time.Sleep(5 * time.Second) // done thing, 可能异常中断接收
 if true { // if err != nil
  break
 }
 }
}

函数 gen 中通过 select 实现 2 个 channel 的同时处理。当异常发生时,将进入 <-done 分支,实现 goroutine 退出。这里为了演示效果,保证资源顺利释放,退出时等待了几秒保证释放完成。

执行后的输出如下:

the number of goroutines:  1

现在只有主 goroutine 存在。

接收不发送

发送不接收会导致发送者阻塞,反之,接收不发送也会导致接收者阻塞。直接看示例代码,如下:

package main

func main() {
 defer func() {
 time.Sleep(time.Second)
 fmt.Println("the number of goroutines: ", runtime.NumGoroutine())
 }()

 var ch chan struct{}
 go func() {
 ch <- struct{}{}
 }()
}

运行结果显示:

the number of goroutines:  2

当然,我们正常不会遇到这么傻的情况发生,现实工作中的案例更多可能是发送已完成,但是发送者并没有关闭 channel,接收者自然也无法知道发送完毕,阻塞因此就发生了。

解决方案是什么?那当然就是,发送完成后一定要记得关闭 channel。

nil channel

向 nil channel 发送和接收数据都将会导致阻塞。这种情况可能在我们定义 channel 时忘记初始化的时候发生。

示例代码:

func main() {
 defer func() {
 time.Sleep(time.Second)
 fmt.Println("the number of goroutines: ", runtime.NumGoroutine())
 }()
 var ch chan int
 go func() {
 <-ch
 // ch<-
 }()
}

两种写法:<-ch 和 ch<- 1,分别表示接收与发送,都将会导致阻塞。如果想实现阻塞,通过 nil channel 和 done channel 结合实现阻止 main 函数的退出,这或许是可以一试的方法。

func main() {
 defer func() {
 time.Sleep(time.Second)
 fmt.Println("the number of goroutines: ", runtime.NumGoroutine())
 }()
 done := make(chan struct{})
 var ch chan int
 go func() {
 defer close(done)
 }()
 select {
 case <-ch:
 case <-done:
 return
 }
}

在 goroutine 执行完成,检测到 done 关闭,main 函数退出。

真实的场景

真实的场景肯定不会像案例中的简单,可能涉及多阶段 goroutine 之间的协作,某个 goroutine 可能即使接收者又是发送者。但归根到底,无论什么使用模式。都是把基础知识组织在一起的合理运用。

传统同步机制

虽然,一般推荐 Go 并发数据的传递,但有些场景下,显然还是使用传统同步机制更合适。Go 中提供传统同步机制主要在 sync 和 atomic 两个包。接下来,我主要介绍的是锁和 WaitGroup 可能导致 goroutine 的泄露。

Mutex

和其他语言类似,Go 中存在两种锁,排它锁和共享锁,关于它们的使用就不作介绍了。我们以排它锁为例进行分析。

示例如下:

func main() {
 total := 0
 defer func() {
 time.Sleep(time.Second)
 fmt.Println("total: ", total)
 fmt.Println("the number of goroutines: ", runtime.NumGoroutine())
 }()
 var mutex sync.Mutex
 for i := 0; i < 2; i++ {
 go func() {
  mutex.Lock()
  total += 1
 }()
 }
}

执行结果如下:

total: 1
the number of goroutines: 2

这段代码通过启动两个 goroutine 对 total 进行加法操作,为防止出现数据竞争,对计算部分做了加锁保护,但并没有及时的解锁,导致 i = 1 的 goroutine 一直阻塞等待 i = 0 的 goroutine 释放锁。可以看到,退出时有 2 个 goroutine 存在,出现了泄露,total 的值为 1。

怎么解决?因为 Go 有 defer 的存在,这个问题还是非常容易解决的,只要记得在 Lock 的时候,记住 defer Unlock 即可。

示例如下:

mutex.Lock()
defer mutext.Unlock()

其他的锁与这里其实都是类似的。

WaitGroup

WaitGroup 和锁有所差别,它类似 Linux 中的信号量,可以实现一组 goroutine 操作的等待。使用的时候,如果设置了错误的任务数,也可能会导致阻塞,导致泄露发生。

一个例子,我们在开发一个后端接口时需要访问多个数据表,由于数据间没有依赖关系,我们可以并发访问,示例如下:

package main
import (
 "fmt"
 "runtime"
 "sync"
 "time"
)
func handle() {
 var wg sync.WaitGroup
 wg.Add(4)
 go func() {
 fmt.Println("访问表1")
 wg.Done()
 }()
 go func() {
 fmt.Println("访问表2")
 wg.Done()
 }()
 go func() {
 fmt.Println("访问表3")
 wg.Done()
 }()
 wg.Wait()
}
func main() {
 defer func() {
 time.Sleep(time.Second)
 fmt.Println("the number of goroutines: ", runtime.NumGoroutine())
 }()
 go handle()
 time.Sleep(time.Second)
}

执行结果如下:

the number of goroutines: 2

出现了泄露。再看代码,它的开始部分定义了类型为 sync.WaitGroup 的变量 wg,设置并发任务数为 4,但是从例子中可以看出只有 3 个并发任务。故最后的 wg.Wait() 等待退出条件将永远无法满足,handle 将会一直阻塞。

怎么防止这类情况发生?

我个人的建议是,尽量不要一次设置全部任务数,即使数量非常明确的情况。因为在开始多个并发任务之间或许也可能出现被阻断的情况发生。最好是尽量在任务启动时通过 wg.Add(1) 的方式增加。

示例如下:

 ...
 wg.Add(1)
 go func() {
 fmt.Println("访问表1")
 wg.Done()
 }()
 wg.Add(1)
 go func() {
 fmt.Println("访问表2")
 wg.Done()
 }()
 wg.Add(1)
 go func() {
 fmt.Println("访问表3")
 wg.Done()
 }()
 ...

总结

大概介绍完了我认为的所有可能导致 goroutine 泄露的情况。总结下来,其实无论是死循环、channel 阻塞、锁等待,只要是会造成阻塞的写法都可能产生泄露。因而,如何防止 goroutine 泄露就变成了如何防止发生阻塞。为进一步防止泄露,有些实现中会加入超时处理,主动释放处理时间太长的 goroutine。

以上所述是小编给大家介绍的Go 防止 goroutine 泄露的方法,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对我们网站的支持!
如果你觉得本文对你有帮助,欢迎转载,烦请注明出处,谢谢!

(0)

相关推荐

  • go语言执行等待直到后台goroutine执行完成实例分析

    本文实例分析了go语言执行等待直到后台goroutine执行完成的用法.分享给大家供大家参考.具体如下: 复制代码 代码如下: var w sync.WaitGroup w.Add(2) go func() {     // do something     w.Done() } go func() {     // do something     w.Done() } w.Wait() 希望本文所述对大家的Go语言程序设计有所帮助.

  • GOLANG使用Context管理关联goroutine的方法

    一般一个业务很少不用到goroutine的,因为很多方法是需要等待的,例如http.Server.ListenAndServe这个就是等待的,除非关闭了Server或Listener,否则是不会返回的.除非是一个API服务器,否则肯定需要另外起goroutine发起其他的服务,而且对于API服务器来说,在http.Handler的处理函数中一般也需要起goroutine,如何管理这些goroutine,在GOLANG1.7提供context.Context. 先看一个简单的,如果启动两个goro

  • Golang 探索对Goroutine的控制方法(详解)

    前言 在golang中,只需要在函数调用前加上关键字go即可创建一个并发任务单元,而这个新建的任务会被放入队列中,等待调度器安排.相比系统的MB级别线程栈,goroutine的自定义栈只有2KB,这使得我们能够轻易创建上万个并发任务,如此对性能提升不少.但随之而来的有以下几个问题: 如何等待所有goroutine的退出 如何限制创建goroutine的数量(信号量实现) 怎么让goroutine主动退出 探索--如何从外部杀死goroutine 本文记录了笔者就以上几个问题进行探究的过程,文中给

  • Go语言轻量级线程Goroutine用法实例

    本文实例讲述了Go语言轻量级线程Goroutine用法.分享给大家供大家参考.具体如下: goroutine 是由 Go 运行时环境管理的轻量级线程. go f(x, y, z) 开启一个新的 goroutine 执行 f(x, y, z) f,x,y 和 z 是当前 goroutine 中定义的,但是在新的 goroutine 中运行 f. goroutine 在相同的地址空间中运行,因此访问共享内存必须进行同步. sync 提供了这种可能,不过在 Go 中并不经常用到,因为有其他的办法.(以

  • 关于Golang中for-loop与goroutine的问题详解

    背景 最近在学习MIT的分布式课程6.824的过程中,使用Go实现Raft协议时遇到了一些问题.分享出来供大家参考学习,下面话不多说了,来一起看看详细的介绍吧. 参见如下代码: for i := 0; i < len(rf.peers); i++ { DPrintf("i = %d", i) if i == rf.me { DPrintf("skipping myself #%d", rf.me) continue } go func() { DPrintf(

  • Go 防止 goroutine 泄露的方法

    概述 Go 的并发模型与其他语言不同,虽说它简化了并发程序的开发难度,但如果不了解使用方法,常常会遇到 goroutine 泄露的问题.虽然 goroutine 是轻量级的线程,占用资源很少,但如果一直得不到释放并且还在不断创建新协程,毫无疑问是有问题的,并且是要在程序运行几天,甚至更长的时间才能发现的问题. 对于上面描述的问题,我觉得可以从两方面入手解决,如下: 一是预防,要做到预防,我们就需要了解什么样的代码会产生泄露,以及了解如何写出正确的代码: 二是监控,虽说预防减少了泄露产生的概率,但

  • Go语言死锁与goroutine泄露问题的解决

    目录 什么时候会导致死锁 发送单个值时的死锁 多个值发送的死锁 解决多值发送死锁 应该先发送还是先接收 goroutine 泄漏 如何发现泄露 小结 什么时候会导致死锁 在计算机组成原理里说过 死锁有三个必要条件他们分别是 循环等待.资源共享.非抢占式,在并发中出现通道死锁只有两种情况: 数据要发送,但是没有人接收 数据要接收,但是没有人发送 发送单个值时的死锁 牢记这两点问题就很清晰了,复习下之前的例子,会死锁 a := make(chan int) a <- 1 //将数据写入channel

  • go中控制goroutine数量的方法

    前言 goroutine被无限制的大量创建,造成的后果就不啰嗦了,主要讨论几种如何控制goroutine的方法 控制goroutine的数量 通过channel+sync var ( // channel长度 poolCount = 5 // 复用的goroutine数量 goroutineCount = 10 ) func pool() { jobsChan := make(chan int, poolCount) // workers var wg sync.WaitGroup for i

  • JQuery Dialog的内存泄露问题解决方法

    对于页面来说,JQuery中的Dialog从效果上来说还可以,而且使用简单,只要短短几行绑定的代码就可以实现弹出效果. 代码 复制代码 代码如下: $('#dialog').dialog({ autoOpen: false, width: 600, buttons: { "Ok": function() { $(this).dialog("close"); }, "Cancel": function() { $(this).dialog(&quo

  • Android编程中避免内存泄露的方法总结

    Android的应用被限制为最多占用16m的内存,至少在T-Mobile G1上是这样的(当然现在已经有几百兆的内存可以用了--译者注).它包括电话本身占用的和开发者可以使用的两部分.即使你没有占用全部内存的打算,你也应该尽量少的使用内存,以免别的应用在运行的时候关闭你的应用.Android能在内存中保持的应用越多,用户在切换应用的时候就越快.作为我的一项工作,我仔细研究了Android应用的内存泄露问题,大多数情况下它们是由同一个错误引起的,那就是对一个上下文(Context)保持了长时间的引

  • Go并发编程之goroutine使用正确方法

    目录 1. 对创建的gorouting负载 1.1 不要创建一个你不知道何时退出的 goroutine 1.2 不要帮别人做选择 1.3 不要作为一个旁观者 1.4 不要创建不知道什么时候退出的 goroutine 1.5 不要创建都无法退出的 goroutine 1.6 确保创建出的goroutine工作已经完成 2. 总结 3. 参考 并发(concurrency): 指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时

  • PHP CURL 内存泄露问题解决方法

    phpcurl使用privoxy代理访问https://www.google.com/search?q=xxx curl配置平淡无奇,长时间运行发现一个严重问题,内存泄露!不论用单线程和多线程都无法避免!是curl访问https站点的时候有bug! 内存泄露可以通过linux的top命令发现,使用php函数memory_get_usage()不会发现. 经过反复调试找到解决办法,curl配置添加如下几项解决问题: 复制代码 代码如下: [CURLOPT_HTTPPROXYTUNNEL] = tr

  • goroutine 泄漏和避免泄漏实战示例

    目录 goroutine 泄漏和避免泄漏的最佳实践 什么是goroutine泄漏? 原因分析 伪代码 有什么方法可以解决这个问题? goroutine 泄漏和避免泄漏的最佳实践 Go的奇妙之处在于,我们可以使用goroutines和channel轻松地执行并发任务.如果在生产环境中使用goroutines和channel,但是不了解它们的行为方式,会造成一些严重的影响. 好吧,我们就面临着这样的影响,我们在goroutines中出现了泄漏,导致应用服务器随着时间的推移而膨胀,消耗了大量的CPU和

  • Nginx隐藏版本号的方法

    Nginx隐藏版本号 在生产环境中,需要隐藏Nginx的版本号,以避免安全漏洞的泄露 查看方法 使用fiddler工具在Windows客户端查看Nginx版本号 在centos系统中使用"curl -I 网址" 命令查看 Nginx隐藏版本号的方法 修改配置文件法 修改源码法 一,安装Nginx 1,在Linux上使用远程共享获取文件并挂载到mnt目录下 [root@localhost ~]# smbclient -L //192.168.100.3/ ##远程共享访问 Enter S

  • 基于ThreadLocal 的用法及内存泄露(内存溢出)

    目录 使用 构造方法 静态方法 公共方法 内存泄露 解决方法 为什么要将ThreadLocal 定义成 static 变量 对ThreadLocal内存泄漏引起的思考 概述 使用场景样例代码 ThreadLocal使用源码 思考问题 ThreadLocal解读 ThreadLocal 看名字 就可以看出一点头绪来,线程本地. 来看一下java对他的描述: 该类提供线程本地变量.这些变量与它们的正常对应变量的不同之处在于,每个线程(通过ThreadLocal的 get 或 set方法)访问自己的.

随机推荐