Go并发模式实战笔记
title: "Go 并发模式:从 goroutine 到 errgroup 的实战笔记" date: 2026-09-15 tags: [Go, 并发, 编程, 后端] category: 技术笔记 description: "Go 的并发原语看起来简单,但组合起来有很多坑。这篇文章记录了我在实际项目中踩过的三个模式。"
Go 并发模式:从 goroutine 到 errgroup 的实战笔记
Go 的并发模型是它最大的卖点之一。go 关键字一加,函数就跑在新 goroutine 里,看起来毫无门槛。
但「能跑」和「写对」之间,差着不少坑。
模式一:最朴素的并发等待
刚写 Go 的时候,我这么干:
func main() {
urls := []string{"https://a.com", "https://b.com", "https://c.com"}
for _, u := range urls {
go func() {
resp, _ := http.Get(u)
fmt.Println(resp.StatusCode)
}()
}
// 等一下...主线程直接退出了
}
问题很明显:主线程不等 goroutine 完成就退出了。而且 u 是闭包捕获的经典陷阱——三个 goroutine 可能都会请求最后一个 URL。
正确做法用 sync.WaitGroup:
func main() {
urls := []string{"https://a.com", "https://b.com", "https://c.com"}
var wg sync.WaitGroup
for _, u := range urls {
wg.Add(1)
url := u // 重要:拷贝循环变量
go func() {
defer wg.Done()
resp, err := http.Get(url)
if err != nil {
log.Println(err)
return
}
fmt.Println(url, resp.StatusCode)
}()
}
wg.Wait()
}
2026 更新:Go 1.22+ 已经修复了循环变量捕获问题,
url := u这行可以省了。但老项目和老习惯还在,知道为什么重要依然有价值。
模式二:带错误处理的并发 — errgroup
WaitGroup 的问题是:goroutine 里出错了,主线程不知道。
golang.org/x/sync/errgroup 解决了这个问题:
import "golang.org/x/sync/errgroup"
func fetchAll(urls []string) error {
var g errgroup.Group
for _, u := range urls {
url := u
g.Go(func() error {
resp, err := http.Get(url)
if err != nil {
return fmt.Errorf("fetch %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("fetch %s: status %d", url, resp.StatusCode)
}
return nil
})
}
return g.Wait()
}
关键行为:
- 第一个返回非 nil error 的 goroutine,其 error 会被
g.Wait()返回 - 其他 goroutine 不会被自动取消(这点很多人误解)
- 想要取消,得配合
context
模式三:带并发限制的扇出
上面的模式对 3 个 URL 没问题。如果是 1000 个呢?
同时起 1000 个 goroutine 做 HTTP 请求,目标服务器可能直接把你 ban 了。需要控制并发度:
func fetchWithLimit(urls []string, concurrency int) error {
g, ctx := errgroup.WithContext(context.Background())
sem := make(chan struct{}, concurrency)
for _, u := range urls {
url := u
sem <- struct{}{} // 获取信号量
g.Go(func() error {
defer func() { <-sem }() // 释放信号量
select {
case <-ctx.Done():
return ctx.Err() // 前面有错了,别继续了
default:
}
return fetchOne(ctx, url)
})
}
return g.Wait()
}
func fetchOne(ctx context.Context, url string) error {
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
// ... 处理响应
return nil
}
这个模式组合了三个能力:
errgroup— 错误传播 + 等待channel做信号量 — 并发限制context— 错误时提前取消,不浪费资源
实际项目中的选择
| 场景 | 用什么 |
|------|--------|
| 简单并行,不需要错误处理 | sync.WaitGroup |
| 并行 + 需要错误传播 | errgroup |
| 并行 + 限制并发数 | errgroup + 信号量 |
| 并行 + 超时控制 | errgroup.WithContext + context.WithTimeout |
| 生产者-消费者 | channel |
我的经验:大多数场景 errgroup 就够了。 不要上来就搞复杂的 channel 拓扑,除非你确定需要。
这篇笔记整理自我在项目中做并发 HTTP 爬虫的经验。
[[Go channel 使用备忘]]里有更基础的 channel 用法。