⭐ 推荐:社区规则条款 V1.0

Go 语言并发编程:Goroutine / Channel / Context 深度解析

XiaoC
XiaoC 正式会员正式会员认证极客认证极客
发布于 2026-10-10 23:44 ·4 浏览 ·3 回复
内容摘要

讲解 Go 中 Goroutine、Channel、Context 的配合使用,包括 WaitGroup 等待协程、channel 缓冲与关闭原则、select 多路复用及超时取消传递,并指出易导致泄漏和死锁的写法。

学完这篇,你能把 Goroutine、Channel、Context 三件套串成一套能落地的并发写法:知道什么时候该开协程、数据怎么安全传递、超时和取消怎么一路传到底,以及哪些写法会直接埋出泄漏和死锁。

第一步:把 Goroutine 跑起来,并且等它结束

启动协程只有一个动作——在函数调用前加 go:

go func() {
    fmt.Println("干活")
}()

但 go 本身不阻塞,主函数一结束,所有协程会被直接掐断。所以必须有个等待手段,标准库给了 sync.WaitGroup:

var wg sync.WaitGroup
for i := 0; i < 3; i++ {
    wg.Add(1)
    go func(id int) {
        defer wg.Done()
        fmt.Println("worker", id)
    }(i)
}
wg.Wait()

用 go run main.go 就能验证输出是否稳定是 3 行。

注意:wg.Add(1) 要写在 go 之前。写进协程内部的话,wg.Wait() 可能在 Add 执行前就返回,导致漏等。另外循环变量在 Go 1.22 之前是共享的,老版本必须用 (i) 传参拷贝,否则三个协程打印的可能是同一个值。

第二步:Channel 怎么用,什么时候关

Channel 是协程之间唯一的推荐通信方式,核心区别在缓冲:

ch := make(chan int)      // 无缓冲:发送和接收必须同时就绪
buf := make(chan int, 10) // 缓冲 10:装满才阻塞

无缓冲适合「交接棒」式的同步,缓冲适合「生产者快、消费者慢」的削峰。读取有两种写法:

v := <-ch          // 读一个,没数据就阻塞
for v := range ch { // 一直读到 channel 被关闭
    fmt.Println(v)
}

注意:range ch 只有在 channel 被 close 后才会退出,忘了关就是永久阻塞,go run 会直接报 all goroutines are asleep - deadlock!。关闭原则是「谁发送谁关闭」,接收方永远不要关;对已关闭的 channel 再发送会 panic,重复关闭也会 panic。

第三步:用 select 做多路复用和超时

select 让一个协程同时等多个 channel:

select {
case v := <-ch:
    fmt.Println("收到", v)
case <-time.After(2 * time.Second):
    fmt.Println("超时")
case <-ctx.Done():
    return ctx.Err()
}

注意:select 里加 default 会变成非阻塞轮询,放在 for 里空转能把 CPU 吃满,除非你确实要做「有就取、没有就走」。另外在长循环里反复调用 time.After 会不断创建定时器,高频场景应改用 time.NewTimer 并复用。

第四步:Context 把取消信号传下去

Context 解决的是「一个请求牵扯多个协程,上游取消要通知所有下游」。三种派生方式对应三种场景:

ctx, cancel := context.WithCancel(context.Background())        // 手动取消
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)         // 绝对超时
ctx, cancel := context.WithDeadline(ctx, time.Now().Add(3*time.Second))
ctx = context.WithValue(ctx, "traceID", "abc123")              // 传请求域数据
defer cancel()

取消信号是向下传播的:父 ctx 取消,所有子 ctx 的 Done() 都会关闭。所以每个协程只需监听自己拿到的那份:

go func(ctx context.Context) {
    select {
    case <-ctx.Done():
        return
    case <-doSomething():
    }
}(ctx)

注意:defer cancel() 一个都不能省,否则定时器和挂载的子节点会一直留在内存里,这是最常见的 context 泄漏。Context 必须作为函数的第一个参数 func Do(ctx context.Context, ...),不要塞进 struct 字段。WithValue 只放请求级元数据(traceID、用户 ID),别拿它传业务参数。

第五步:一个完整可跑的并发抓取

把上面几个组件拼起来,效果是「并发跑 3 个任务,任意一个超时或整体超时都立刻退出」:

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    results := make(chan string, 3)
    var wg sync.WaitGroup

    for _, url := range []string{"a.com", "b.com", "c.com"} {
        wg.Add(1)
        go func(u string) {
            defer wg.Done()
            select {
            case results <- fetch(ctx, u):
            case <-ctx.Done():
            }
        }(url)
    }

    go func() { wg.Wait(); close(results) }()

    for r := range results {
        fmt.Println(r)
    }
}

这里 results 用带缓冲的 channel,是为了让协程在没人接收时也能把结果放进去并正常退出,避免泄漏。

注意:close(results) 必须等所有发送者退出后再执行,所以单独起一个协程做 wg.Wait()。如果直接在循环里 close,会出现「send on closed channel」panic。

小结

  • Goroutine 启动靠 go,结束必须靠 WaitGroup 或 channel 等它,主函数退出会杀掉全部协程。
  • Channel 无缓冲用于同步交接,有缓冲用于削峰;关闭原则是「谁发送谁关闭」,接收方不关。
  • range 读 channel 必须配合 close,否则死锁;select 加 time.After 或 ctx.Done() 做超时。
  • Context 从 Background 派生,defer cancel() 不能漏;取消信号向下传播,只传元数据不传业务参数。
  • 长跑服务里,凡是启动协程的地方,都要能回答「它什么时候、被谁、以什么方式结束」。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-781.html
转载请注明出处,版权归原作者所有。
他们都看过 1 人浏览过
XiaoC

全部回复 3

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客 1楼 2026-10-10 23:54

前三步写得很扎实,我把你被截断的第四步 Context 补完,再点几个真正会埋雷的地方。

Context 的本质是「沿调用链向下传递的取消/超时信号」,约定是作为函数的第一个参数 ctx context.Context。创建入口只有三类:WithCancel、WithTimeout、WithDeadline(WithValue 只用来传请求域元数据,比如 trace id、认证信息,别拿它当参数容器)。每一次创建都会返回 cancel,必须 defer cancel()——WithTimeout 内部挂了个定时器,不主动 cancel 就得等超时才释放,go vet 会直接报 lostcancel。

实际协作通常是 Context + errgroup 一起用:

g, ctx := errgroup.WithContext(ctx)
for _, id := range ids {
    id := id
    g.Go(func() error {
        return fetch(ctx, id) // 任一失败,ctx 立即取消,其余协程收到 ctx.Err()
    })
}
return g.Wait() // 返回第一个非 nil error

这样超时和取消才能一路传到最底层的 HTTP/DB 调用(http.NewRequestWithContext、db.QueryContext)。

两个容易踩的点:一是 context.Background() 和 TODO() 的 Done() 返回 nil,case <-ctx.Done() 在它们下面永远不触发——「明明设了取消却不生效」十有八九是上游把 Background 透传下来了,而不是从入口处创建可取消的 ctx。二是排查泄漏时,runtime.NumGoroutine() 前后对比,或开 /debug/pprof/goroutine?debug=2 看栈,比读代码快得多。

顺带一句:Step 1 提到的循环变量问题,Go 1.22 起 for i := range 每次迭代都是新变量,(i) 传参可以省了——但如果模块 go.mod 里写的是 1.21 或更低,语言版本按 go 行生效,该传还得传。

额外建议:把 ctx 存进 struct 字段是反模式,IDE 和 staticcheck 都会警告,原因就是生命周期说不清。

最长的电影
最长的电影 正式会员正式会员 #637 2楼 2026-10-11 00:04
做个坏人啦:前三步写得很扎实,我把你被截断的第四步 Context 补完,再点几个真正会埋雷的地方。 Context 的本质是「沿调用链向下传递的取消/超时信号」,约定是…

补得很到位,尤其「Background() 的 Done() 返回 nil」这条,是「明明设了取消却不生效」的头号原因,我再往上叠几个实操细节。

errgroup 落地时有两件事最好一开始就加上。一是限流:g.SetLimit(10)(Go 1.20+ 和 TryGo 一起进来的),否则 ids 有几千个就是几千个协程同时砸 HTTP/DB,把下游打挂的往往不是逻辑 bug 而是这个。

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP 钢铁之心 Lv1 #640 3楼 2026-10-11 00:08
最长的电影:补得很到位,尤其「`Background()` 的 `Done()` 返回 nil」这条,是「明明设了取消却不生效」的头号原因,我再往上叠几个实操细节。 `e…

SetLimit 这块补两句,它不只是限流阀,配错了会把「打挂下游」变成「永久卡死」。

先说行为:SetLimit(n) 的语义是 g.Go() 在并发满了之后阻塞排队,不是返回错误。想「排不进去就跳过」得用 g.TryGo()(返回 bool)。另外它内部是复用 worker 协程而不是每个任务新建 goroutine,所以别在里面依赖「一个任务一个 goroutine」的假设做隔离。

真正的坑在取消语义上:errgroup.WithContext 的取消只是通知(cancel 掉 ctx),而 g.Wait() 等的是所有协程返回,不是第一个出错的。也就是说,如果协程里没认 ctx——跑纯 CPU 密集循环,或者调了不带 ctx 的 db.Query——一个协程失败只会取消 ctx,其余协程照样跑到底,Wait 该等多久还等多久;limit 设得越小,这条尾巴拖得越长。所以 SetLimit 必须和「ctx 一路传到 HTTP/DB」配套上,缺一个都会变成「看着限流了,其实卡住了」。

最后两点:g.Wait() 只返回第一个非 nil error,要收全量错误得自己 errors.Join 或加 mutex 汇总;还有 WithContext 返回的 ctx 在 Wait 返回后就被 cancel 了,别拿出去复用。另外喂给 errgroup 的父 ctx 如果是 WithTimeout 建的,defer cancel() 照样得写——errgroup 只负责它自己派生的那个。