在Go语言中,goroutine是实现并发的核心机制。它不同于操作系统线程,由Go runtime自身调度,初始栈仅2KB且可动态扩容,因此我们可以轻松创建成千上万个协程。但很多初学者只关注如何用go关键字启动任务,却忽略了协程该如何正确退出,最终导致协程泄漏、内存上涨甚至服务崩溃。

一、goroutine的创建机制
Go中创建协程的语法极其简单,只需在函数或方法调用前加上go关键字,该函数就会在一个新的goroutine中并发执行。runtime会为其分配一个g结构体,并挂到调度器的运行队列中等待执行。由于创建成本极低,开发者容易滥用,比如在for循环中无限制地go func(),而不去考虑这些协程何时结束。
下面是一段典型的创建代码,它启动了一个后台打印任务:
package main
import (
"fmt"
"time"
)
func worker(id int) {
for i := 0; i < 3; i++ {
fmt.Println("worker", id, "print", i)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
go worker(1)
go worker(2)
time.Sleep(500 * time.Millisecond)
fmt.Println("main exit")
}
上述代码在main函数中启动了两个协程。但要注意,main本身也是一个goroutine,当main函数退出时,整个程序会直接终止,其他未执行完的goroutine也会被强制结束。因此,在正式服务中我们不能依赖time.Sleep来等待,而需要明确的协同退出机制。
二、为什么goroutine不能显式销毁
Go语言设计上刻意没有提供类似pthread_cancel或kill这样的强制终止协程的API。原因在于,强制中断一个协程可能导致它正持有的锁无法释放、文件描述符未关闭、通道处于半发送状态,进而破坏全局状态一致性。Go官方推荐的做法是“协作式退出”:由协程自己检查退出信号并主动返回。
最常见的误区是认为通道关闭后,接收方会自动“被通知退出”。实际上,通道关闭只是让接收操作立即返回零值,如果协程内部逻辑没有判断,它仍可能继续循环或使用已关闭的通道而panic。下面这段代码就存在泄漏风险:
package main
import (
"time"
)
func leak() {
ch := make(chan int)
go func() {
// 一直等待接收,但外部从未发送也未关闭
for v := range ch {
_ = v
}
}()
// 函数返回后,ch无引用,但子协程仍阻塞在range上
time.Sleep(1 * time.Second)
}
在leak函数退出后,局部变量ch虽然不再被main引用,但子协程还持有ch的引用并阻塞在接收端,该协程永远无法退出,造成泄漏。这要求我们在设计协程时,必须规划好谁负责关闭通道、谁负责发送退出信号。
三、使用channel实现受控退出
最基础的受控退出方式是使用一个单独的done通道来传递退出信号。父协程在需要停止时关闭done通道,子协程在循环中选择监听done,一旦收到信号就清理资源并返回。这种方式清晰直观,适合少量协程的场景。
以下示例展示了如何用done通道安全退出:
package main
import (
"fmt"
"time"
)
func safeWorker(done chan struct{}) {
for {
select {
case <-done:
fmt.Println("worker received done, exit")
return
default:
fmt.Println("working...")
time.Sleep(200 * time.Millisecond)
}
}
}
func main() {
done := make(chan struct{})
go safeWorker(done)
time.Sleep(1 * time.Second)
close(done)
time.Sleep(200 * time.Millisecond)
fmt.Println("main exit")
}
这里select搭配default实现了非阻塞的工作与退出检查。close(done)后,所有监听<-done的协程都会立即收到零值信号,从而统一退出。需要注意,close只能由发送方调用一次,重复关闭会引发panic,因此通常把关闭权交给明确的管理者。
四、结合context进行生命周期管理
当协程存在层级调用,或需要同时传递取消信号与超时控制时,标准库的context包是更优选择。context.WithCancel生成的cancel函数可被任意层级的子协程共享,调用cancel即广播退出;context.WithTimeout还能自动到期取消,避免人工管理计时器。
下面例子演示了context控制多级协程退出:
package main
import (
"context"
"fmt"
"time"
)
func subTask(ctx context.Context, name string) {
for {
select {
case <-ctx.Done():
fmt.Println(name, "cancel:", ctx.Err())
return
default:
fmt.Println(name, "running")
time.Sleep(150 * time.Millisecond)
}
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 800*time.Millisecond)
defer cancel()
go subTask(ctx, "A")
go subTask(ctx, "B")
<-ctx.Done()
fmt.Println("all tasks should exit")
time.Sleep(200 * time.Millisecond)
}
使用context的好处是,取消信号可沿着调用链自然下传,不需要每层都传done通道。ctx.Err()能区分是主动取消还是超时,方便做不同的清理逻辑。在HTTP服务、数据库查询等场景中,context几乎是必选的协程管控工具。
五、常见错误与最佳实践
实践中,开发者常犯的错误包括:在协程中直接访问外部循环变量而未传参,导致变量被后续迭代覆盖;忘记处理通道的关闭责任方;用time.Sleep做同步等待。这些都会让退出机制失效。
推荐的最佳实践可总结为:第一,启动协程时显式传入所需参数,避免闭包陷阱;第二,明确通道关闭责任,通常谁发送完数据谁关闭;第三,用context或done通道做统一取消,不在协程内部自行决定全局生命周期;第四,在测试中使用runtime.NumGoroutine做泄漏检测。遵循这些原则,才能写出健壮的并发代码。
| 方式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| done通道 | 简单父子协程 | 直观、易理解 | 多层传递繁琐 |
| context | 层级调用、超时控制 | 标准、可传播 | 需习惯Err检查 |
| time.Sleep | 临时演示 | 写法快 | 不可靠、易泄漏 |
通过合理运用上述机制,我们可以让goroutine在完成任务后干净退出,保障Go程序长期运行的稳定性。