2. Goroutine 基础:go 关键字、闭包陷阱与生命周期管理
Goroutine 是 Go 并发编程的基石。本章从最基础的 go 关键字出发,深入剖析匿名函数与闭包在并发场景下的常见陷阱,并建立完整的 Goroutine 生命周期管理模型。掌握这些基础,是后续调优与剖析的前提。
2.1 go 关键字:启动一个轻量级执行单元
在任意函数或方法前加上 go 关键字,即可创建一个新的 Goroutine 并异步执行该函数。Goroutine 由 Go 运行时调度,初始栈仅几 KB,可动态伸缩,创建成本远低于操作系统线程。
package main
import (
"fmt"
"time"
)
func say(msg string) {
for i := 0; i < 3; i++ {
fmt.Println(msg, i)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
go say("hello") // 启动 Goroutine
go say("world") // 启动另一个 Goroutine
time.Sleep(1 * time.Second) // 等待 Goroutine 执行
fmt.Println("main exit")
}
关键点:Goroutine 是非阻塞的,
main 函数返回时所有 Goroutine 立即终止,不会等待它们完成。因此需要同步机制(如 time.Sleep、sync.WaitGroup)来保证主 Goroutine 等待子 Goroutine 结束。
2.2 匿名函数与闭包在并发中的陷阱
在 Goroutine 中大量使用匿名函数和闭包,但闭包捕获外部变量的方式极易引发数据竞争和逻辑错误。以下是最常见的两类陷阱。
陷阱一:循环变量捕获
在 for 循环中启动 Goroutine 时,闭包直接引用循环变量,所有 Goroutine 共享同一个变量地址,导致最终读取的值是循环结束后的值。
// 错误示例:所有 Goroutine 打印相同的值
for i := 0; i < 5; i++ {
go func() {
fmt.Println(i) // 捕获的是变量 i 的引用
}()
}
// 输出:5 5 5 5 5
// 正确做法:通过参数传递副本
for i := 0; i < 5; i++ {
go func(val int) {
fmt.Println(val) // 值传递,每个 Goroutine 持有独立副本
}(i)
}
// 输出:0 1 2 3 4(顺序不定)
陷阱二:闭包修改外部变量
多个 Goroutine 通过闭包修改同一个外部变量,导致数据竞争(Data Race)。
counter := 0
for i := 0; i < 1000; i++ {
go func() {
counter++ // 非原子操作,存在数据竞争
}()
}
// 最终 counter 的值不确定,远小于 1000
避坑指南:在 Goroutine 内部尽量只读外部变量;如需写入,必须使用互斥锁(
sync.Mutex)或原子操作(sync/atomic)。优先通过函数参数传递值,避免闭包直接引用循环变量。
2.3 Goroutine 生命周期管理
Goroutine 的生命周期包括:创建、运行、阻塞/等待、结束。缺乏管理会导致 Goroutine 泄漏(永不结束)或过早终止。以下是三种核心管理手段。
2.3.1 sync.WaitGroup:等待一组 Goroutine 完成
sync.WaitGroup 是等待多个 Goroutine 结束的标准工具。它维护一个内部计数器,Add 增加计数,Done 减少计数,Wait 阻塞直到计数归零。
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Println("worker", id)
}(i)
}
wg.Wait()
fmt.Println("all workers done")
2.3.2 context.Context:控制 Goroutine 取消与超时
当需要主动取消正在执行的 Goroutine 或设置超时时间时,使用 context.Context。通过 select 监听 ctx.Done() 通道,实现优雅退出。
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
go func(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("goroutine exiting:", ctx.Err())
return
default:
// 执行任务
time.Sleep(500 * time.Millisecond)
fmt.Println("working...")
}
}
}(ctx)
time.Sleep(3 * time.Second)
fmt.Println("main exit")
2.3.3 通道(Channel)通知:手动控制 Goroutine 退出
使用一个专门的 chan struct{} 作为停止信号,Goroutine 通过 select 监听该通道,收到信号后自行退出。
stop := make(chan struct{})
go func() {
for {
select {
case <-stop:
fmt.Println("received stop signal, exiting")
return
default:
fmt.Println("working...")
time.Sleep(300 * time.Millisecond)
}
}
}()
time.Sleep(1 * time.Second)
close(stop) // 发送停止信号
time.Sleep(200 * time.Millisecond)
生命周期管理对比:
| 管理方式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| sync.WaitGroup | 等待固定数量的 Goroutine 完成 | 简单、高效 | 无法主动取消 |
| context.Context | 超时控制、级联取消、传递请求范围值 | 标准化、可嵌套、支持超时 | 需要显式传递 ctx |
| Channel 通知 | 手动精确控制单个 Goroutine 退出 | 灵活、无额外依赖 | 需要自行管理通道生命周期 |
2.4 Goroutine 生命周期状态图
下图展示了 Goroutine 从创建到结束的完整状态流转,以及三种管理方式在生命周期中的介入点。
2.5 典型错误模式与修复对照表
| 错误模式 | 错误代码片段 | 修复方案 |
|---|---|---|
| 循环变量捕获 | go func() { fmt.Println(i) }() |
go func(val int) { fmt.Println(val) }(i) |
| 闭包修改外部变量 | go func() { counter++ }() |
使用 atomic.AddInt64 或 sync.Mutex |
| Goroutine 泄漏 | Goroutine 在 channel 上永久阻塞 | 使用 context.WithTimeout 或带缓冲 channel |
| 主 Goroutine 提前退出 | 未等待子 Goroutine 完成 | 使用 sync.WaitGroup 或 time.Sleep |
实践建议:在编写并发代码时,先画出 Goroutine 的生命周期图,明确每个 Goroutine 的创建点、退出条件和同步方式。使用
go run -race 检测数据竞争,使用 pprof 分析 Goroutine 数量是否异常增长。