直接用go关键字启动goroutine缺乏生命周期管理、错误传播、并发控制和重试机制;应采用worker pool模式,通过固定数量的长期goroutine从带缓冲channel队列取任务执行,并用sync.WaitGroup协调完成。

为什么直接用 go 关键字启动 goroutine 不够用
因为缺乏任务生命周期管理、错误传播、并发控制和重试机制。你写 go doWork() 后,一旦 doWork() panic 或耗时超长,主流程完全感知不到;多个任务同时启动还可能压垮系统资源。真实场景需要统一调度、限流、可观测性,而不是裸奔 goroutine。
用 worker pool 模式控制并发与复用
核心是固定数量的长期运行 goroutine 从队列中取任务执行,避免无限创建 goroutine 导致内存暴涨或调度开销过大。推荐用带缓冲的 chan 做任务队列,配合 sync.WaitGroup 等待所有任务完成:
// 任务结构体,可携带上下文、参数、回调
type Task struct {
ID string
Fn func() error
Done chan// 启动 5 个 worker
func StartWorkerPool(queue
- 队列 channel 缓冲区大小建议设为 1024 或根据吞吐预估,太小会阻塞提交方,太大浪费内存
- 不要在 worker 内部 recover panic —— 应让 panic 向上传播并由上层日志/监控捕获,否则错误被静默吞掉
- 如果任务需返回值,别用共享变量,优先走
Done chan或改用context.Context+ 回调函数
如何支持取消、超时和上下文传递
裸 chan 无法响应取消,必须把 context.Context 注入任务执行链。关键点不是“加 context”,而是确保每个可能阻塞的操作(如 HTTP 请求、DB 查询、channel receive)都接受 ctx 并响应 Done:
func (t *Task) Run(ctx context.Context) error {
select {
case // 所有下游调用都要传 ctx
resp, err := http.DefaultClient.Do(req.WithContext(ctx))
if err != nil {
return err
}
defer resp.Body.Close()
// 读取 body 也要带 ctx(例如用 io.CopyN + context)
_, err = io.CopyN(&buf, resp.Body, 1024*1024)
return err
}
- 不要在 worker goroutine 中新建
context.Background()—— 会丢失上游超时和取消信号 - 避免在任务中调用
time.Sleep(),改用time.AfterFunc(ctx.Done(), ...)或select等待 - 如果任务本身是 CPU 密集型,无法响应
ctx.Done(),需配合runtime.Gosched()让出时间片,定期检查ctx.Err()
错误处理和重试不能靠 for range 循环硬扛
任务失败后简单重发可能导致雪崩(比如下游服务已不可用)。应引入退避策略、最大重试次数、错误分类(临时性 vs 永久性):
func WithRetry(fn func() error, maxRetries int, baseDelay time.Duration) error {
var lastErr error
for i := 0; i 0 {
select {
case
- 重试逻辑必须放在任务函数内部,而非 worker 外层 —— 否则一次失败就整个任务丢弃
- 不要用
rand.Intn()做抖动(jitter),它可能生成 0 导致瞬间重试洪峰;改用time.Duration(rand.Int63n(int64(baseDelay))) - 重试次数建议设为 3~5 次,再高大概率是设计问题,不是网络抖动
真正难的不是并发,是让每个任务在失败、超时、取消、重试、日志、指标上报这些环节都保持行为可预测。多数人卡在“任务跑着跑着就没了”,其实是没把 context 和 error 链路贯穿到底。











