乐趣区

Golang并发模型:并发协程的优雅退出

goroutine 作为 Golang 并发的核心,我们不仅要关注它们的创建和管理,当然还要关注如何合理的退出这些协程,不(合理)退出不然可能会造成阻塞、panic、程序行为异常、数据结果不正确等问题。这篇文章介绍,如何合理的退出 goroutine,减少软件 bug。
goroutine 在退出方面,不像线程和进程,不能通过某种手段强制关闭它们,只能等待 goroutine 主动退出。但也无需为退出、关闭 goroutine 而烦恼,下面就介绍 3 种优雅退出 goroutine 的方法,只要采用这种最佳实践去设计,基本上就可以确保 goroutine 退出上不会有问题,尽情享用。
1:使用 for-range 退出
for-range 是使用频率很高的结构,常用它来遍历数据,range 能够感知 channel 的关闭,当 channel 被发送数据的协程关闭时,range 就会结束,接着退出 for 循环。
它在并发中的使用场景是:当协程只从 1 个 channel 读取数据,然后进行处理,处理后协程退出。下面这个示例程序,当 in 通道被关闭时,协程可自动退出。
go func(in <-chan int) {
// Using for-range to exit goroutine
// range has the ability to detect the close/end of a channel
for x := range in {
fmt.Printf(“Process %d\n”, x)
}
}(inCh)
2:使用,ok 退出
for-select 也是使用频率很高的结构,select 提供了多路复用的能力,所以 for-select 可以让函数具有持续多路处理多个 channel 的能力。但 select 没有感知 channel 的关闭,这引出了 2 个问题:

继续在关闭的通道上读,会读到通道传输数据类型的零值,如果是指针类型,读到 nil,继续处理还会产生 nil。
继续在关闭的通道上写,将会 panic。

问题 2 可以这样解决,通道只由发送方关闭,接收方不可关闭,即某个写通道只由使用该 select 的协程关闭,select 中就不存在继续在关闭的通道上写数据的问题。
问题 1 可以使用,ok 来检测通道的关闭,使用情况有 2 种。
第一种:如果某个通道关闭后,需要退出协程,直接 return 即可。示例代码中,该协程需要从 in 通道读数据,还需要定时打印已经处理的数量,有 2 件事要做,所有不能使用 for-range,需要使用 for-select,当 in 关闭时,ok=false,我们直接返回。
go func() {
// in for-select using ok to exit goroutine
for {
select {
case x, ok := <-in:
if !ok {
return
}
fmt.Printf(“Process %d\n”, x)
processedCnt++
case <-t.C:
fmt.Printf(“Working, processedCnt = %d\n”, processedCnt)
}
}
}()
第二种:如果某个通道关闭了,不再处理该通道,而是继续处理其他 case,退出是等待所有的可读通道关闭。我们需要使用 select 的一个特征:select 不会在 nil 的通道上进行等待。这种情况,把只读通道设置为 nil 即可解决。
go func() {
// in for-select using ok to exit goroutine
for {
select {
case x, ok := <-in1:
if !ok {
in1 = nil
}
// Process
case y, ok := <-in2:
if !ok {
in2 = nil
}
// Process
case <-t.C:
fmt.Printf(“Working, processedCnt = %d\n”, processedCnt)
}

// If both in channel are closed, goroutine exit
if in1 == nil && in2 == nil {
return
}
}
}()
3:使用退出通道退出
使用,ok 来退出使用 for-select 协程,解决是当读入数据的通道关闭时,没数据读时程序的正常结束。想想下面这 2 种场景,,ok 还能适用吗?

接收的协程要退出了,如果它直接退出,不告知发送协程,发送协程将阻塞。
启动了一个工作协程处理数据,如何通知它退出?

使用一个专门的通道,发送退出的信号,可以解决这类问题。以第 2 个场景为例,协程入参包含一个停止通道 stopCh,当 stopCh 被关闭,case <-stopCh 会执行,直接返回即可。
当我启动了 100 个 worker 时,只要 main() 执行关闭 stopCh,每一个 worker 都会都到信号,进而关闭。如果 main() 向 stopCh 发送 100 个数据,这种就低效了。
func worker(stopCh <-chan struct{}) {
go func() {
defer fmt.Println(“worker exit”)
// Using stop channel explicit exit
for {
select {
case <-stopCh:
fmt.Println(“Recv stop signal”)
return
case <-t.C:
fmt.Println(“Working .”)
}
}
}()
return
}
最佳实践回顾

发送协程主动关闭通道,接收协程不关闭通道。技巧:把接收方的通道入参声明为只读,如果接收协程关闭只读协程,编译时就会报错。
协程处理 1 个通道,并且是读时,协程优先使用 for-range,因为 range 可以关闭通道的关闭自动退出协程。

,ok 可以处理多个读通道关闭,需要关闭当前使用 for-select 的协程。
显式关闭通道 stopCh 可以处理主动通知协程退出的场景。

完整示例代码
本文所有代码都在仓库,可查看完整示例代码:https://github.com/Shitaibin/…
并发系列文章推荐

Golang 并发模型:轻松入门流水线模型
Golang 并发模型:轻松入门流水线 FAN 模式
Golang 并发模型:并发协程的优雅退出

如果这篇文章对你有帮助,请点个赞 / 喜欢,鼓励我持续分享,感谢。
如果喜欢本文,随意转载,但请保留此原文链接。

退出移动版