Goroutine 泄露的 6 种方法
煎鱼 2021-06-11
泄露的原因大多集中在:
- Goroutine 内正在进行 channel/mutex 等读写操作,但由于逻辑问题,某些情况下会被一直阻塞。
- Goroutine 内的业务逻辑进入死循环,资源一直无法释放。
- Goroutine 内的业务逻辑进入长时间等待,有不断新增的 Goroutine 进入等待。
接下来我会引用在网上冲浪收集到的一些 Goroutine 泄露例子(会在文末参考注明出处)。
channel 使用不当
Goroutine+Channel 是最经典的组合,因此不少泄露都出现于此。
最经典的就是上面提到的 channel 进行读写操作时的逻辑问题。
发送不接收
第一个例子:
func main() {
for i := 0; i < 4; i++ {
queryAll()
fmt.Printf("goroutines: %d\n", runtime.NumGoroutine())
}
}
func queryAll() int {
ch := make(chan int)
for i := 0; i < 3; i++ {
go func() { ch <- query() }()
}
return <-ch
}
func query() int {
n := rand.Intn(100)
time.Sleep(time.Duration(n) * time.Millisecond)
return n
}
输出结果:
goroutines: 3
goroutines: 5
goroutines: 7
goroutines: 9
在这个例子中,我们调用了多次 queryAll
方法,并在 for
循环中利用 Goroutine 调用了 query
方法。其重点在于调用 query
方法后的结果会写入 ch
变量中,接收成功后再返回 ch
变量。
最后可看到输出的 goroutines 数量是在不断增加的,每次多 2 个。也就是每调用一次,都会泄露 Goroutine。
原因在于 channel 均已经发送了(每次发送 3 个),但是在接收端并没有接收完全(只返回 1 个 ch),所诱发的 Goroutine 泄露。
接收不发送
第二个例子:
func main() {
defer func() {
fmt.Println("goroutines: ", runtime.NumGoroutine())
}()
var ch chan struct{}
go func() {
ch <- struct{}{}
}()
time.Sleep(time.Second)
}
输出结果:
goroutines: 2
在这个例子中,与 “发送不接收” 两者是相对的,channel 接收了值,但是不发送的话,同样会造成阻塞。
但在实际业务场景中,一般更复杂。基本是一大堆业务逻辑里,有一个 channel 的读写操作出现了问题,自然就阻塞了。
nil channel
第三个例子:
func main() {
defer func() {
fmt.Println("goroutines: ", runtime.NumGoroutine())
}()
var ch chan int
go func() {
<-ch
}()
time.Sleep(time.Second)
}
输出结果:
goroutines: 2
在这个例子中,可以得知 channel 如果忘记初始化,那么无论你是读,还是写操作,都会造成阻塞。
正常的初始化姿势是:
ch := make(chan int)
go func() {
<-ch
}()
ch <- 0
time.Sleep(time.Second)
调用 make
函数进行初始化。
奇怪的慢等待
第四个例子:
func main() {
for {
go func() {
_, err := http.Get("https://www.xxx.com/")
if err != nil {
fmt.Printf("http.Get err: %v\n", err)
}
}()
time.Sleep(time.Second * 1)
fmt.Println("goroutines: ", runtime.NumGoroutine())
}
}
输出结果:
goroutines: 5
goroutines: 9
goroutines: 13
goroutines: 17
goroutines: 21
goroutines: 25
...
在这个例子中,展示了一个 Go 语言中经典的事故场景。也就是一般我们会在应用程序中去调用第三方服务的接口。
但是第三方接口,有时候会很慢,久久不返回响应结果。恰好,Go 语言中默认的 http.Client
是没有设置超时时间的。
因此就会导致一直阻塞,一直阻塞就一直爽,Goroutine 自然也就持续暴涨,不断泄露,最终占满资源,导致事故。
在 Go 工程中,我们一般建议至少对 http.Client
设置超时时间:
httpClient := http.Client{
Timeout: time.Second * 15,
}
并且要做限流、熔断等措施,以防突发流量造成依赖崩塌,依然吃 P0。
互斥锁忘记解锁
第五个例子:
func main() {
total := 0
defer func() {
time.Sleep(time.Second)
fmt.Println("total: ", total)
fmt.Println("goroutines: ", runtime.NumGoroutine())
}()
var mutex sync.Mutex
for i := 0; i < 10; i++ {
go func() {
mutex.Lock()
total += 1
}()
}
}
输出结果:
total: 1
goroutines: 10
在这个例子中,第一个互斥锁 sync.Mutex
加锁了,但是他可能在处理业务逻辑,又或是忘记 Unlock
了。
因此导致后面的所有 sync.Mutex
想加锁,却因未释放又都阻塞住了。一般在 Go 工程中,我们建议如下写法:
var mutex sync.Mutex
for i := 0; i < 10; i++ {
go func() {
mutex.Lock()
defer mutex.Unlock()
total += 1
}()
}
同步锁使用不当
第六个例子:
func handle(v int) {
var wg sync.WaitGroup
wg.Add(5)
for i := 0; i < v; i++ {
fmt.Println("脑子进煎鱼了")
wg.Done()
}
wg.Wait()
}
func main() {
defer func() {
fmt.Println("goroutines: ", runtime.NumGoroutine())
}()
go handle(3)
time.Sleep(time.Second)
}
在这个例子中,我们调用了同步编排 sync.WaitGroup
,模拟了一遍我们会从外部传入循环遍历的控制变量。
但由于 wg.Add
的数量与 wg.Done
数量并不匹配,因此在调用 wg.Wait
方法后一直阻塞等待。
在 Go 工程中使用,我们会建议如下写法:
var wg sync.WaitGroup
for i := 0; i < v; i++ {
wg.Add(1)
defer wg.Done()
fmt.Println("脑子进煎鱼了")
}
wg.Wait()