爱库录 目录即站点,文件即文章

Go并发模式实战笔记

2026-09-17 · 技术 · 返回列表

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
}

这个模式组合了三个能力:

  1. errgroup — 错误传播 + 等待
  2. channel 做信号量 — 并发限制
  3. context — 错误时提前取消,不浪费资源

实际项目中的选择

| 场景 | 用什么 | |------|--------| | 简单并行,不需要错误处理 | sync.WaitGroup | | 并行 + 需要错误传播 | errgroup | | 并行 + 限制并发数 | errgroup + 信号量 | | 并行 + 超时控制 | errgroup.WithContext + context.WithTimeout | | 生产者-消费者 | channel |

我的经验:大多数场景 errgroup 就够了。 不要上来就搞复杂的 channel 拓扑,除非你确定需要。


这篇笔记整理自我在项目中做并发 HTTP 爬虫的经验。[[Go channel 使用备忘]] 里有更基础的 channel 用法。