典型表现为程序退出后pprof显示大量goroutine阻塞在runtime.gopark或chan recv;根源是worker pool关闭时未正确关闭jobs channel,导致worker协程持续等待任务。

worker pool 关闭时 goroutine 泄漏的典型表现
程序退出后 pprof 显示仍有大量 goroutine 处于 runtime.gopark 或阻塞在 chan recv,http://localhost:6060/debug/pprof/goroutine?debug=2 里能看到几百个卡在 worker 循环里——这不是内存泄漏,是关闭逻辑没切断协程的等待链。
- worker 从
jobschannel 读取任务时用了无缓冲或带缓冲但未关闭的 channel,永远阻塞 - 主流程调用
close(jobs)前没等所有 worker 处理完已接收的任务,导致部分 job 被丢弃或 panic - 用了
sync.WaitGroup但忘记在每个 worker 退出前wg.Done(),或者wg.Wait()被提前调用
用 context.WithCancel 控制 worker 生命周期比 close(channel) 更可靠
单纯 close(jobs) 只能告诉 worker “不再有新任务”,但无法中断正在执行的长期任务(比如 HTTP 请求、数据库查询)。context.Context 提供可取消信号 + 超时 + 传递语义,是 Go 官方推荐的协作取消机制。
- worker 启动时接收
ctx context.Context,所有阻塞操作(http.Client.Do、time.Sleep、select等)都应监听ctx.Done() - 主流程调用
cancel()后,worker 应尽快清理并退出,而不是等当前 job 执行完(除非业务强要求) - 不要在 worker 内部调用
ctx.Cancel()—— 只能由创建者调用,否则会 panic - 示例关键片段:
for { select { case job, ok := <-jobs: if !ok { return } job.Process() case <-ctx.Done(): return } }
WaitGroup 和 channel 关闭顺序不能颠倒
常见错误是先 close(jobs) 再 wg.Wait(),看起来合理,但若某个 worker 正在处理耗时 job,它可能在收到 jobs 关闭信号前就卡住了,wg.Wait() 就永远等不到它调用 wg.Done()。
- 正确顺序:启动所有 worker 后,用
wg.Add(n);每个 worker 结束前必须执行wg.Done();最后close(jobs),再wg.Wait() - 更稳妥做法:把
close(jobs)放在单独 goroutine 里,在所有 worker 启动完成后立即执行,避免竞态 - 如果 job channel 是带缓冲的,关闭前要确保缓冲区为空,否则
close后仍可能有未被读取的 job - 别依赖
len(jobs) == 0判断是否清空 —— 这不是原子操作,且无法反映正在处理中的 job
Shutdown 超时和强制终止的边界要划清
生产环境不能无限等待 worker 自行退出,但也不能粗暴 os.Exit() —— 会跳过 defer、日志 flush、连接释放等清理动作。
立即学习“go语言免费学习笔记(深入)”;
- 设一个合理的 shutdown timeout(比如 5–10 秒),超时后记录 warn 日志,并考虑是否允许强制终止(如设置
force: true标志位) - 强制终止不等于杀 goroutine(Go 不支持),而是通过 context 取消 + 在关键阻塞点检查
ctx.Err()实现“软杀” - 数据库连接池、HTTP client transport、文件句柄等资源应在 worker 退出前显式关闭,不能只靠 GC
- 测试 shutdown 行为时,故意在 job 中加
time.Sleep(10 * time.Second),验证是否在 timeout 内退出并输出预期日志
实际最难的部分不是写 shutdown 逻辑,而是判断哪些 job 必须完成、哪些可以丢弃,以及如何让 long-running job 主动响应 cancel。这得看业务语义,代码只能提供工具,没法替你做决定。










