最近在工作中使用 golang 编程,因为语言内置对并发的支持(go 关键字),所以 golang 越来越受到服务端开发的青睐。今天的文章给大家分享一下 go 并发编程中一个比较隐晦的 bug --- goroutine 泄漏。
注意本文不是 golang 语法的科普贴,如果对 golang channel、go、error 等基础语法不了解的话,推荐大家先去看一下 《go 语言圣经》。
在日常的开发场景中,我们经常可以将一个大任务分解成多个互相独立、可以相互并行(无前后依赖)的小任务。一旦完成了对任务的分解,我们就可以使用并发/并行技术,加速我们的程序。假设现有:
func Task() error {
// 调度各个 sub-task 等待其结束
return nil
}
func SubTaskA() error {
// 略
return nil
}
func SubTaskB() error {
// 略
return nil
}
func SubTaskC() error {
// 略
return nil
}
这些 SubTask 可以独立于其他 SubTask 自己运行。那在 go 中通过下面的代码实现自任务的并发处理:
func Task() error {
go SubTaskA()
go SubTaskB()
go SubTaskC()
return nil
}
简单的一个关键字 go,就实现了多个 subroutine 的并发调度,这里真的不得不感慨 golang 对并发的友好。
上面的代码固然实现了多个子任务并发执行的需求,但是使用 go 关键字后,我们对子任务各自的返回值变得一无所知,这显然是不科学的。这个问题的本质其实是线程(注意 go 中的是 goroutine,比线程要更加轻量,但是因为本质特征是在同一内存地址内的通信)之间的通信问题。这个主题就不是三言两语可以覆盖的了,共享内存、有名/无名管道、fifo 等等不一而足。这里我们使用 golang 中内置提供的 channel 来实现通信:
func Task() error {
chA := make(chan error)
chB := make(chan error)
chC := make(chan error)
go SubTaskA(chA)
go SubTaskB(chB)
go SubTaskC(chC)
for i := 0; i < 3; i ++ {
select {
case err := <-chA:
// 处理 SubTaskA 的返回值
case err := <-ChB:
// 处理 SubTaskB 的返回值
case err := <-ChC:
// 处理 SubTaskC 的返回值
}
}
return nil
}
func SubTaskA(ch <-chan error) {
err := doSomethingA()
ch <- err
}
func SubTaskB(ch <-chan error) {
err := doSomethingB()
ch <- err
}
func SubTaskC(ch <-chan error) {
err := doSomethingC()
ch <- err
}
为了实现 goroutine 之间的通信,我们使用了 channel 来传递信息(这里是各个子任务的 error)。 Task 主 routine 在启动了各个 subroutine 以后,就在 for 循环中 select 3 个 channel,等待 SubTask 的信息。
这里需要注意一点,在处理 SubTask 的 error 的时候,千万不要这样做:
func Task() error {
chA := make(chan error)
chB := make(chan error)
chC := make(chan error)
go SubTaskA(chA)
go SubTaskB(chB)
go SubTaskC(chC)
for i := 0; i < 3; i ++ {
select {
case err := <-chB:
if err != nil {
// 千万不要直接返回,这样可能导致 goroutine 泄漏
return err
}
// 正常逻辑
// ... 类似处理 SubTaskA
}
return nil
}
func SubTaskA(ch <-chan error) {
err := doSomethingA()
// 写入 ch 的代码,必须要在 channel 的接收端接收了信息才可以返回
ch <- err
}
这样的代码可能会导致 goroutine 泄漏的问题,因为我们创建的都是无 buffer 的 channel,如果写入 channel 的时候,没有接收端(在这里是我们收到了某个 SubTask* 的 error 以后直接退出了 Task 函数),那么写入端(这里的 SubTaskA,见注释),会一直阻塞,最终导致这个本该被回收的 goroutine 无法被 runtime 回收。一个简单的代码可以验证:
package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
fmt.Println("I'm recver")
<-ch
}()
ch <- 1
fmt.Println("All right")
// 会一直阻塞
ch <- 1
fmt.Println("Will never reach")
}
/*
输出:
I'm recver
All right
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main.main()
/Users/nealzhu/Downloads/keyspider/x.go:17 +0xda
exit status 2
*/
可以看到,这样的代码可以导致死锁、资源无法回收等问题。那么怎么解决呢?一般来说可以通过两种方法:
对应到例子的代码:
buffered channel:
func Task() error {
chA := make(chan error, 1)
chB := make(chan error, 1)
chC := make(chan error, 1)
go SubTaskA(chA)
go SubTaskB(chB)
go SubTaskC(chC)
for i := 0; i < 3; i ++ {
select {
case err := <-chB:
if err != nil {
return err
}
// 正常逻辑
// ... 类似处理 SubTaskA
}
return nil
}
func SubTaskA(ch <-chan error) {
err := doSomethingA()
// 写入 ch 的代码,因为 ch 的 cap 是1,所以第一次的写入不可能被阻塞
ch <- err
}
channel drainer
func Task() error {
chA := make(chan error)
chB := make(chan error)
chC := make(chan error)
go SubTaskA(chA)
go SubTaskB(chB)
go SubTaskC(chC)
defer func() {
// drainer, 注意需要写入端配合加入 close(chan),否则这些 for range 会一直阻塞
for _ := range chA {
}
for _ := range chB {
}
for _ := range chC {
}
}()
for i := 0; i < 3; i ++ {
select {
case err := <-chB:
if err != nil {
return
}
// 正常逻辑
// ... 类似处理 SubTaskA
}
return nil
}
func SubTaskA(ch <-chan error) {
err := doSomethingA()
// 写入 ch 的代码,因为 ch 的 cap 是1,所以第一次的写入不可能被阻塞
ch <- err
// close,防止接收端陷入无限的等待
close(ch)
}
可以看到,buffered channel 的处理方式要优雅不少,channel drainer 的方法的话,不同业务代码中,drainer 的逻辑、写法相差比较大,而且往往需要写入方(有时候可能还需要引入额外的通知机制)配合。
看到这里,相信大家对 goroutine 泄漏为什么会发生,和如何避免已经有所了解了,这篇博客也就到此为止。希望有帮到大家,也希望大家可以帮忙点赞、评论~