Go 实战锦囊|errgroup:优雅地管理并发任务组

0 阅读9分钟

在 Go 的并发编程中,sync.WaitGroup 几乎是每个开发者的"启蒙工具"。但当任务需要错误传播上下文取消并发数量控制时,WaitGroup 就显得力不从心了。今天的主角——golang.org/x/sync/errgroup,正是为解决这些痛点而生的利器。本文将跳过玩具示例,直接聚焦生产环境中的最佳实践与隐蔽陷阱,并特别补充 Gin 框架下的适配要点。


一、为什么需要 errgroup?

先看一个典型的 WaitGroup 使用场景:

func fetchAll() error {
    var wg sync.WaitGroup
    var mu sync.Mutex
    var firstErr error

    urls := []string{"https://api.example.com/a", "https://api.example.com/b"}

    for _, url := range urls {
        wg.Add(1)
        go func(u string) {
            defer wg.Done()
            if err := fetch(u); err != nil {
                mu.Lock()
                if firstErr == nil {
                    firstErr = err
                }
                mu.Unlock()
            }
        }(url)
    }

    wg.Wait()
    return firstErr
}

这段代码在生产环境中存在三个硬伤:

  • 🧩 样板代码多:手动管理 Add/Done、互斥锁保护错误变量
  • 无法提前取消:一个任务失败后,其余任务仍在白白执行,浪费资源
  • 🔢 无法限制并发:如果 urls 有 10000 个,会瞬间启动 10000 个 goroutine,可能打垮下游服务或耗尽本地文件描述符

errgroup 将这三件事优雅地封装在了一起。


二、核心 API 速览

API说明
errgroup.WithContext(ctx)创建带 Context 的 Group,任一 goroutine 返回 error 时自动 cancel
new(errgroup.Group)创建不带 Context 的 Group(仅需错误收集时使用)
g.Go(func() error)启动一个 goroutine 执行任务
g.Wait()等待所有 goroutine 完成,返回第一个非 nil 的 error
g.SetLimit(n)限制最大并发数(x/sync v0.3.0+)
g.TryGo(func() error)非阻塞提交任务,达到并发上限时返回 false

三、基础用法:并行请求 + 并发控制

这是生产中最常见的模式:并行调用多个外部接口,同时通过 SetLimit 保护下游服务。

package main

import (
	"context"
	"fmt"
	"net/http"

	"golang.org/x/sync/errgroup"
)

func fetchURLs(ctx context.Context, urls []string) error {
	g, ctx := errgroup.WithContext(ctx)
	g.SetLimit(5) // 最多 5 个并发请求,避免打爆下游

	for _, url := range urls {
		url := url
		g.Go(func() error {
			req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
			if err != nil {
				return fmt.Errorf("构建请求 %s 失败: %w", url, err)
			}

			resp, err := http.DefaultClient.Do(req)
			if err != nil {
				return fmt.Errorf("请求 %s 失败: %w", url, err)
			}
			defer resp.Body.Close()

			if resp.StatusCode >= 400 {
				return fmt.Errorf("请求 %s 返回异常状态码: %d", url, resp.StatusCode)
			}

			fmt.Printf("✅ %s -> %d\n", url, resp.StatusCode)
			return nil
		})
	}

	return g.Wait()
}

生产要点

  • SetLimit 的值应根据下游服务的承载能力设定,而非随意填写
  • 使用 http.NewRequestWithContext 而非 http.Get,确保 context 取消能真正中断 HTTP 请求
  • g.Wait() 的返回值必须处理,不要丢弃

四、Gin 框架适配:Handler 层拆包,业务层纯数据

在 Gin 等 Web 框架中使用 errgroup 时,有一个高频踩坑点:*gin.Context 不是并发安全的,也不能直接传给 errgroup.WithContext

⚠️ 两个核心约束

约束原因
不能直接传 *gin.ContextWithContext它未实现标准 context.Context 接口,编译报错
不能在 goroutine 中访问 c.Set/c.Get内部 map 无锁,并发读写直接 panic

✅ 正确做法:Handler 层提取,业务层解耦

// Handler 层:负责从 gin.Context 中"拆包"
func (h *Handler) BatchFetch(c *gin.Context) {
    // ✅ 1. 在主 goroutine 中提取所有需要的值
    userID := c.GetString("userID")     // 类型安全方法,不存在则返回零值
    traceID := c.GetString("traceID")
    ctx := c.Request.Context()          // ✅ 2. 提取标准 context

    urls := []string{"https://api.example.com/a", "https://api.example.com/b"}

    // ✅ 3. 只传标准 context + 提取后的纯数据
    if err := fetchURLsForUser(ctx, userID, traceID, urls); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    c.JSON(http.StatusOK, gin.H{"message": "done"})
}

// 业务函数:完全不依赖 *gin.Context,可独立测试
func fetchURLsForUser(ctx context.Context, userID, traceID string, urls []string) error {
    g, ctx := errgroup.WithContext(ctx)
    g.SetLimit(5)

    for _, url := range urls {
        url := url
        g.Go(func() error {
            // ✅ 安全地使用提取后的值,无并发风险
            req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
            req.Header.Set("X-User-ID", userID)
            req.Header.Set("X-Trace-ID", traceID)

            resp, err := http.DefaultClient.Do(req)
            if err != nil {
                return fmt.Errorf("请求 %s 失败: %w", url, err)
            }
            defer resp.Body.Close()

            if resp.StatusCode >= 400 {
                return fmt.Errorf("请求 %s 返回异常状态码: %d", url, resp.StatusCode)
            }
            return nil
        })
    }

    return g.Wait()
}

❌ 典型错误写法

func (h *Handler) BatchFetch(c *gin.Context) {
    // ❌ 编译错误!*gin.Context 不是 context.Context
    g, ctx := errgroup.WithContext(c)

    for _, url := range urls {
        url := url
        g.Go(func() error {
            // ❌ 并发读取 gin.Context 内部 map → panic
            userID, _ := c.Get("userID")

            // ❌ 即使加锁也不行,gin.Context 的 Writer/Params 等字段都不是线程安全的
            return fetchWithUserID(ctx, userID.(string), url)
        })
    }
    return g.Wait()
}

💡 黄金法则

Handler 层负责"拆包",业务层只接收"纯数据"。在进入 errgroup.Go 之前,把所有需要从 *gin.Context 获取的东西(Set 的值、路由参数、请求头等)全部提取为局部变量。goroutine 中只使用这些局部变量和 errgroup 返回的标准 ctx。这不仅是并发安全的要求,也是 Clean Architecture 的基本实践。


五、级联取消:理解 errgroup 的 Context 生命周期

当任务之间存在逻辑关联时,某个任务失败意味着后续工作已无意义。WithContext 会自动将取消信号传递给所有 goroutine。但正确使用它的前提,是理解返回的 ctx 到底是什么。

⚠️ 核心概念:不要用父 context

WithContext 的签名如下:

func WithContext(ctx context.Context) (*Group, context.Context)

它接收一个父 context,返回一个全新的子 context。这个新 context 的生命周期与 Group 绑定:当任一 goroutine 返回 error 或 Wait() 完成时,只有这个新 context 会被 cancel,父 context 不受影响。

这意味着:在 errgroup 管理的并发任务中,必须且只能使用 WithContext 返回的那个 ctx

parentCtx := context.Background()
g, ctx := errgroup.WithContext(parentCtx)
//       ^^^ 这是新的子 context,专属于这个 Group

for _, url := range urls {
    url := url
    g.Go(func() error {
        // ✅ 正确:使用 errgroup 返回的 ctx
        req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
        resp, err := http.DefaultClient.Do(req)
        // ...
        return err
    })
}

❌ 混用父 context 的后果

这是生产中最隐蔽的 bug 之一:

parentCtx := context.Background()
g, ctx := errgroup.WithContext(parentCtx)

for _, url := range urls {
    url := url
    g.Go(func() error {
        // ❌ 致命错误!使用了 parentCtx 而非 errgroup 返回的 ctx
        req, _ := http.NewRequestWithContext(parentCtx, http.MethodGet, url, nil)
        resp, err := http.DefaultClient.Do(req)
        // ...
        return err
    })
}

后果:当某个任务失败时,errgroup 内部调用 cancel() 取消的是它返回的子 ctx,而你传入的 parentCtx 完全不受影响。其余 goroutine 中的 HTTP 请求不会收到取消信号,继续执行直到自然超时或完成。级联取消机制彻底失效,但你从代码表面看不出任何问题。

✅ 抽取独立函数时的正确做法

当任务逻辑被抽取为独立函数时,将 errgroup 返回的 ctx 作为参数传入即可,这与普通的 context 传递规范一致:

func validateBatch(ctx context.Context, records []Record) error {
	g, ctx := errgroup.WithContext(ctx)
	g.SetLimit(10)

	for _, record := range records {
		record := record
		g.Go(func() error {
			// ✅ 将 errgroup 的 ctx 传给子函数
			return validate(ctx, record)
		})
	}

	if err := g.Wait(); err != nil {
		if errors.Is(err, context.Canceled) {
			log.Printf("批量校验因前置错误被取消")
		} else {
			log.Printf("批量校验出错: %v", err)
		}
		return err
	}
	return nil
}

// 独立函数正常接收 ctx 参数
func validate(ctx context.Context, r Record) error {
	data, err := fetchData(ctx, r.ID)
	if err != nil {
		return err
	}
	// ...
	return nil
}

Context 使用规则速查

场景做法说明
闭包内直接使用捕获 WithContext 返回的 ctx无需额外传参
调用独立函数将该 ctx 作为第一个参数传入Go 标准惯例
使用原始父 context永远不要这样做级联取消失效
Wait() 之后使用不要这样做此时 ctx 已被 cancel
Gin Handler 中c.Request.Context() 再传入见第四节详解

💡 记忆口诀WithContext 返回的 ctx 是这批任务的"总开关"。用它,级联取消才能生效;不用它,WithContext 就退化成了普通的 new(Group)


六、结果收集:预分配切片 + 索引写入

并行处理并收集结果是高频需求。通过预分配切片 + 索引写入,可以完全避免锁的使用:

func enrichUsers(ctx context.Context, userIDs []string) ([]UserDetail, error) {
	g, ctx := errgroup.WithContext(ctx)
	g.SetLimit(8)

	// ✅ 预分配固定长度切片,每个 goroutine 写入独立索引,无需加锁
	results := make([]UserDetail, len(userIDs))

	for i, id := range userIDs {
		i, id := i, id
		g.Go(func() error {
			detail, err := fetchUserDetail(ctx, id)
			if err != nil {
				return fmt.Errorf("获取用户 %s 详情失败: %w", id, err)
			}
			results[i] = detail
			return nil
		})
	}

	if err := g.Wait(); err != nil {
		return nil, err
	}
	return results, nil
}

💡 为什么不用 map + mutex? 切片的索引写入是 O(1) 且无竞争的;而 map 写入不仅需要锁,在高并发下还会成为性能瓶颈。只要结果集大小已知,预分配切片永远是最优解。


七、优雅关闭:等待多个子系统退出

在服务 Shutdown 时,errgroup 可以并行等待多个组件完成清理:

func (s *Server) Shutdown(ctx context.Context) error {
	g, ctx := errgroup.WithContext(ctx)

	g.Go(func() error {
		s.logger.Info("正在关闭 HTTP 服务...")
		return s.httpServer.Shutdown(ctx)
	})

	g.Go(func() error {
		s.logger.Info("正在关闭 gRPC 服务...")
		s.grpcServer.GracefulStop()
		return nil
	})

	g.Go(func() error {
		s.logger.Info("正在关闭数据库连接池...")
		return s.db.Close()
	})

	g.Go(func() error {
		s.logger.Info("正在关闭消息队列消费者...")
		return s.mqConsumer.Close()
	})

	if err := g.Wait(); err != nil {
		return fmt.Errorf("服务关闭过程中出错: %w", err)
	}
	s.logger.Info("所有子系统已安全关闭")
	return nil
}

八、踩坑指南 ⚠️

坑 1:循环变量捕获(最常见!)

// ❌ Go 1.22 之前的致命 bug
for _, url := range urls {
    g.Go(func() error {
        return fetch(url) // 所有 goroutine 共享同一个 url 变量
    })
}

// ✅ 正确写法
for _, url := range urls {
    url := url // 创建局部副本
    g.Go(func() error {
        return fetch(url)
    })
}

为什么需要 url := url

Go 1.22 之前for range 的循环变量在整个循环过程中是同一个内存地址。闭包捕获的是变量的引用而非值。当 goroutine 真正执行时,循环可能已经结束,url 被修改为最后一个元素,导致所有 goroutine 处理相同的数据。

url := url 在每次迭代中创建独立的局部变量,每个 goroutine 持有自己的副本。

Go 1.22+ 还需要这行吗?

Go 1.22 修改了语言规范,循环变量在每次迭代中自动独立。但推荐仍然保留

原因说明
兼容性很多项目尚未升级到 1.22,保留可确保旧版本下行为正确
可读性显式声明意图,减少 Code Review 时的疑虑
零副作用即使升级 Go 版本,这行代码也不会产生任何额外开销

坑 2:在闭包内调用 Wait

// ❌ 死锁!goroutine 等待自己完成
g.Go(func() error {
    g.Wait()
    return nil
})

坑 3:忽略 Wait 返回值

// ❌ 错误被静默吞掉
g.Wait()

// ✅ 始终检查
if err := g.Wait(); err != nil {
    return fmt.Errorf("batch failed: %w", err)
}

坑 4:SetLimit 放在 Go 之后

// ❌ 行为未定义
g.Go(func() error { ... })
g.SetLimit(3)

// ✅ SetLimit 必须在所有 Go 调用之前
g.SetLimit(3)
g.Go(func() error { ... })

坑 5:WithContext 下不传递 ctx

// ❌ 取消信号无法传递,级联取消失效
resp, err := http.Get(url)

// ✅ 使用 errgroup 返回的 ctx
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := http.DefaultClient.Do(req)

坑 6:Gin goroutine 中访问 gin.Context

// ❌ 并发读取 gin.Context 内部 map → panic
g.Go(func() error {
    userID, _ := c.Get("userID") // 💥 concurrent map read
    return fetch(ctx, userID.(string))
})

// ✅ Handler 层提前提取,goroutine 只用普通变量
userID := c.GetString("userID")
g.Go(func() error {
    return fetch(ctx, userID)
})

📌 详见第四节:Gin 适配的完整示例与原理分析。


九、errgroup vs 其他方案

特性sync.WaitGrouperrgroup.Groupchannel + select第三方库(pond)
错误收集❌ 需手动✅ 内置⚠️ 需手动
Context 取消⚠️ 需手动⚠️ 部分支持
并发限制SetLimit⚠️ 需信号量
API 简洁度⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
标准生态⚠️ x 扩展包

对于绝大多数"并行执行一组任务并收集结果"的场景,errgroup最优解。仅在需要 worker pool 复用、任务排队等高级特性时才考虑第三方库。


十、总结

场景推荐用法
纯并行,不关心错误sync.WaitGroup
并行 + 错误收集new(errgroup.Group)
并行 + 错误收集 + 级联取消errgroup.WithContext(ctx)
上述 + 并发数量控制WithContext + SetLimit(n)
Gin Handler 中使用c.Request.Context() + Handler 层拆包
非阻塞任务提交TryGo

四条黄金法则

  1. 📌 优先使用 WithContext —— 让任务可被取消是生产环境的基本素养
  2. 📌 始终设置 SetLimit —— 除非你能证明任务数量永远可控且下游无限承压
  3. 📌 始终检查 Wait() 返回值 —— 不要让错误悄悄溜走
  4. 📌 Gin 中先拆包再并发 —— Handler 层提取纯数据,goroutine 中永不触碰 *gin.Context

参考资料

如果这篇文章对你有帮助,欢迎点赞、收藏、转发。我们下期见!👋