golang中goroutine泄漏的问题以及解决方案
参考文章
问题纠正
之前视频讲过一个知识点,如何设置子协裎超时机制,其实像下面这段代码,主协裎关闭后子协裎是不会停止的:
func TestZ92(t *testing.T) { // 超时时间为1秒的ctx ctx, cancel := context.WithTimeout(context.Background(), time.Duration(time.Second*1)) defer cancel() // 超时的子任务 go func(ctx context.Context) { // 子任务执行3秒 fmt.Println("子协裎任务开始执行!") // Notice 已经在执行的这个任务没有办法立刻退出! time.Sleep(time.Second * 3) fmt.Println("子协裎任务执行完成!") }(ctx) select { case <-ctx.Done(): fmt.Println("主协裎的ctx已经完成!") // case <-time.After(time.Duration(time.Second * 5)): // fmt.Println("timeout!!!") // return } time.Sleep(time.Second * 6) fmt.Println("主协裎关闭!!!") }
打印的结果如下(可以看到,虽然把ctx传进去但是没有用,主协裎由于超时关闭了,子协裎还在执行~):
子协裎任务开始执行! 主协裎的ctx已经完成! 子协裎任务执行完成! 主协裎关闭!!!
可以做如下修改:
func TestZ92(t *testing.T) { // 超时时间为1秒的ctx ctx, cancel := context.WithTimeout(context.Background(), time.Duration(time.Second*1)) defer cancel() // 超时的子任务 go func(ctx context.Context) { // 子任务执行3秒 fmt.Println("子协裎任务开始执行!") // Notice 已经在执行的这个任务没有办法立刻退出! time.Sleep(time.Second * 3) // Notice 在耗时操作后面判断一下,如果ctx已经Done了,就不往下执行了!!! select { case <-ctx.Done(): return } fmt.Println("子协裎任务执行完成!") }(ctx) select { case <-ctx.Done(): fmt.Println("主协裎的ctx已经完成!") // case <-time.After(time.Duration(time.Second * 5)): // fmt.Println("timeout!!!") // return } time.Sleep(time.Second * 6) fmt.Println("主协裎关闭!!!") }
本机测试主协裎数量为2
func TestGnum(t *testing.T) { fmt.Println("最后协裎剩余数: ", runtime.NumGoroutine()) // 2 }
goroutine泄漏的概念
goroutine leak 的意思是go协程泄漏,那么什么又是协程泄漏呢?我们知道每次使用go关键字开启一个gorountine任务,经过一段时间的运行,最终是会结束,从而进行系统资源的释放回收。而如果由于操作不当导致一些goroutine一直处于阻塞状态或者永远运行中,永远也不会结束,这就必定会一直占用系统资源。最球的情况下是随着系统运行,一直在创建此类goroutine,那么最终结果就是程序崩溃或者系统崩溃。这种情况我们一般称为goroutine leak。
超时场景1及修改方案
func doSth1(done chan bool) { // 子协裎执行1秒 time.Sleep(time.Second) // Notice 由于done是不带缓存的channel 如果done没有接收方,子协裎会一直夯在这里 done <- true } func timeOut1(f func(chan bool)) error { // 不带缓存的channel ———— 发送/接收 得配对,否则都会夯住 done := make(chan bool) go f(done) select { case <-done: // 接收done fmt.Println("done!") return nil case <-time.After(time.Millisecond): // 主协裎 1微秒 就超时 //fmt.Println("timeOut!!!") return fmt.Errorf("timeout!") } } func test1(t *testing.T, f func(chan bool)) { t.Helper() for i := 0; i < 1000; i++ { timeOut1(f) } // 主协裎执行 time.Sleep(time.Second * 2) t.Log("最终协裎的数量: ", runtime.NumGoroutine()) } func TestZ93(t *testing.T) { test1(t, doSth1) // // 最终协裎的数量: 1002 }
done
是一个无缓冲区的 channel,如果没有超时,doBadthing
中会向 done 发送信号,select
中会接收 done 的信号,因此 doBadthing
能够正常退出,子协程也能够正常退出。
但是,当超时发生时,select 接收到 time.After
的超时信号就返回了,done
没有了接收方(receiver),而 doBadthing
在执行 1s 后向 done
发送信号,由于没有接收者且无缓存区,发送者(sender)会一直阻塞,导致协程不能退出。
方案一:使用带缓存的channel
func doSth1(done chan bool) { // 子协裎执行1秒 time.Sleep(time.Second) done <- true } func timeOut1(f func(chan bool)) error { // 带缓存的channel 缓冲区设置为 1,即使没有接收方,发送方也不会发生阻塞 done := make(chan bool, 1) go f(done) select { case <-done: // 接收done fmt.Println("done!") return nil case <-time.After(time.Millisecond): // 主协裎 1微秒 就超时 //fmt.Println("timeOut!!!") return fmt.Errorf("timeout!") } } func test1(t *testing.T, f func(chan bool)) { t.Helper() for i := 0; i < 1000; i++ { timeOut1(f) } // 主协裎执行 time.Sleep(time.Second * 2) t.Log("最终协裎的数量: ", runtime.NumGoroutine()) } func TestZ93(t *testing.T) { test1(t, doSth1) // // 最终协裎的数量: 2 }
方案二:使用select尝试发送
func doSth1(done chan bool) { // 子协裎执行1秒 time.Sleep(time.Second) // 使用select尝试发送 // 使用 select 尝试向信道 done 发送信号,如果发送失败,则说明缺少接收者(receiver),即超时了,那么直接退出即可。 select { case done <- true: default: return } } func timeOut1(f func(chan bool)) error { // 带缓存的channel 缓冲区设置为 1,即使没有接收方,发送方也不会发生阻塞 done := make(chan bool) go f(done) select { case <-done: // 接收done fmt.Println("done!") return nil case <-time.After(time.Millisecond): // 主协裎 1微秒 就超时 //fmt.Println("timeOut!!!") return fmt.Errorf("timeout!") } } func test1(t *testing.T, f func(chan bool)) { t.Helper() for i := 0; i < 1000; i++ { timeOut1(f) } // 主协裎执行 time.Sleep(time.Second * 2) t.Log("最终协裎的数量: ", runtime.NumGoroutine()) } func TestZ93(t *testing.T) { test1(t, doSth1) // // 最终协裎的数量: 2 }
使用goleak工具检测
go get go.uber.org/goleak
在上面有问题的那段代码中加上goleak检测的代码,运行一下就会提示报错了!:
func TestZ93(t *testing.T) { defer goleak.VerifyNone(t) test1(t, doSth1) // // 最终协裎的数量: 1002 }
超时场景2(复杂/常见)及修改方案
还有一些更复杂的场景,例如将任务拆分为多段,只检测第一段是否超时,若没有超时,后续任务继续执行,超时则终止。
// 假设处理业务时需要分为2个步骤 func do2thing(p1, done chan bool) { // 第一个步骤 耗时1秒 time.Sleep(time.Second * 1) // 结束后使用select尝试给p1这个channel中写入数据 select { case p1 <- true: default: return } // 第二个步骤 time.Sleep(time.Second * 1) // 如果主协程超时退出,没有 phase1 的接收方,子协程中 case phase1 <- true: 仍然是阻塞的消息发送失败就返回了(走default分支) // 所以这里可以使用无缓存的channel并且也不需要用select尝试发送 done <- true } func timeOut2() error { // 都是不带缓存的channel p1 := make(chan bool) done := make(chan bool) go do2thing(p1, done) select { case <-p1: <-done fmt.Println("done!") return nil case <-time.After(time.Millisecond): return fmt.Errorf("timeOut!") } } func TestZ99(t *testing.T) { for i := 0; i < 1000; i++ { timeOut2() } time.Sleep(time.Second * 3) t.Log("最后协裎的数量: ", runtime.NumGoroutine()) // 最后协裎的数量: 2 }
这种场景在实际的业务中更为常见,例如我们将服务端接收请求后的任务拆分为 2 段,一段是执行任务,一段是发送结果。那么就会有两种情况:
- 任务正常执行,向客户端返回执行结果。
- 任务超时执行,向客户端返回超时。
这种情况下,就只能够使用 select,而不能能够设置缓冲区的方式了。因为如果给信道 phase1 设置了缓冲区,phase1 <- true
总能执行成功,那么无论是否超时,都会执行到第二阶段,而没有即时返回,这是我们不愿意看到的。对应到上面的业务,就可能发生一种异常情况,向客户端发送了 2 次响应:
- 任务超时执行,向客户端返回超时,一段时间后,向客户端返回执行结果。
缓冲区不能够区分是否超时了,但是 select 可以(没有接收方,信道发送信号失败,则说明超时了)。
忘记关闭channel的场景及解决方案
func do(taskCh chan int) { for { select { case t := <-taskCh: time.Sleep(time.Millisecond) fmt.Printf("task %d is done\n", t) } } } func sendTasks() { // 缓冲区为10 taskCh := make(chan int, 10) go do(taskCh) for i := 0; i < 1000; i++ { taskCh <- i } } func TestDo(t *testing.T) { t.Log(runtime.NumGoroutine()) sendTasks() time.Sleep(time.Second) // 最后多了一个协裎~因为taskCh一直没有关闭 t.Log("最后剩余的协裎数: ",runtime.NumGoroutine()) // 3 }
解决
解决的核心是得让子协裎知道channel已经关闭了:
func do(taskCh chan int) { for { select { case t, beforeClosed := <-taskCh: // channel在接收前是否已经被关闭了,false表示已经关闭了 if !beforeClosed { fmt.Println("taskCh has been closed") return } time.Sleep(time.Millisecond) fmt.Printf("task %d is done\n", t) } } } func sendTasks() { // 缓冲区为10 taskCh := make(chan int, 10) go do(taskCh) for i := 0; i < 100; i++ { taskCh <- i } // close close(taskCh) } func TestDo(t *testing.T) { t.Log(runtime.NumGoroutine()) sendTasks() time.Sleep(time.Second) // 最后多了一个协裎~因为taskCh一直没有关闭 t.Log("最后剩余的协裎数: ", runtime.NumGoroutine()) // 2 }
~~~