goroutine 与生命周期
goroutine 与生命周期 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
这课启动第一个 goroutine:做一个定期清理的后台任务,能用 WaitGroup 等它结束,能通过停止信号让它退出,最终在服务关闭时确认没有残留的活。
问题场景
书签数据会积累,你想要一个后台任务定期清理过期草稿。Go 里 go f() 一行就能启动——但「怎么停」才是重点:服务收到 Ctrl+C 要退出时,如果后台 goroutine 还拿着存储句柄在跑,轻则日志报错,重则数据写一半。goroutine 的生命必须和服务同呼吸。
核心解释
启动和结束必须成对设计。带停止信号的后台任务:
func startJanitor(ctx context.Context, wg *sync.WaitGroup, store *MemoryStore, every time.Duration) {
wg.Add(1) // 启动前登记,必须先于 go 语句
go func() {
defer wg.Done() // 退出时销账,任何退出路径都会执行
ticker := time.NewTicker(every)
defer ticker.Stop()
for {
select {
case <-ctx.Done(): // 收到停止信号,干净退出
log.Println("janitor stopped")
return
case <-ticker.C:
if n := store.CleanExpired(); n > 0 {
log.Printf("janitor cleaned %d", n)
}
}
}
}()
}
四个零件各司其职:wg.Add(1) 在 go 之前登记,defer wg.Done() 保证任何退出路径都销账;ticker 负责「每隔一段时间一次」,比手写 sleep 循环好在中断即时、不漂移;select 同时监听两个 channel,ctx.Done() 关闭时走退出分支;ctx 是唯一停止通道,不引入第二个布尔标志位——两套停止机制迟早打架。
main 里组装启动与等待:
func main() {
store := NewMemoryStore()
var wg sync.WaitGroup
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
startJanitor(ctx, &wg, store, 200*time.Millisecond)
log.Printf("goroutines after start: %d", runtime.NumGoroutine())
time.Sleep(time.Second) // 演示用:代替真实服务运行
cancel()
wg.Wait() // 阻塞直到 janitor 的 Done 被调用
log.Printf("goroutines after stop: %d", runtime.NumGoroutine())
}
wg.Wait() 返回那一刻,你能断言后台工作全部结束——这就是「没有无法停止的工作」的证据。runtime.NumGoroutine 前后各打一次,数量回落到接近启动前,是肉眼可查的旁证。反面模式是「忘了能停」:goroutine 里只有 for 加 sleep,不监听任何停止信号,进程退出靠硬杀,定时任务正在写的数据可能停在半路。
示例拆解
go run .
# goroutines after start: 2
# janitor cleaned 3 (每 200ms 一次)
# janitor stopped (cancel 生效)
# goroutines after stop: 1
启动后比启动前多 1 个 goroutine(janitor 自己),停止后回落——数字对得上,说明没有泄漏。实际服务里 G14 会把 ctx 换成 signal.NotifyContext 的产物,Ctrl+C 自动触发取消,janitor 这套代码一字不改。另外注意:wait 之后打日志的那行,如果换成打印 map 或写文件,就说明你已经在依赖「Wait 保证的先后关系」,这正是 WaitGroup 的价值所在。
动手任务
- 给 janitor 加「启动立即执行一次」的逻辑(进循环前先清理一遍),避免服务刚启动要等一个周期。
- 故意删掉 wg.Done() 那行,观察 wg.Wait() 永远阻塞——理解登记与销账必须对称,再改回来。
- 连续 cancel 五次再重启任务五次,用 runtime.NumGoroutine 确认数量不随次数增长。
常见错误
- wg.Add(1) 写在 goroutine 函数体里:主流程可能先 Wait、goroutine 还没来得及 Add,登记必须在 go 语句之前。
- 用 time.Sleep 猜测后台任务结束:等待靠 WaitGroup,不靠猜时长。
- goroutine 里 panic 没人接:整个进程崩掉,长驻任务要保证逻辑不 panic 或有 recover。
- 停止信号用布尔变量加锁实现:忘加锁就是数据竞争,ctx.Done() 的 channel 语义天生并发安全。
检查与复盘
完成下方检查题。自查清单:
- 每个后台 goroutine 都有明确的启动登记(Add)和退出销账(Done)
- 停止通道唯一:只有 ctx.Done(),没有第二套布尔开关
- 服务关闭路径上有 wg.Wait(),退出前确认无残留工作
参考资料
- Go Spec:go 语句 —— goroutine 的启动语义
- 标准库 sync.WaitGroup —— Add/Done/Wait 的配对规则
- Go 官方教程:并发 —— goroutine 与 select 入门
读完这一课了?
标记完成后,工作台会安排接下来的学习。