Go 并发控制(附 3 种做法),新手避坑一次讲清:从用法到失败排查

编程狮(w3cschool.cn) 2026-09-24 15:44:23 浏览数 (30)
反馈

Go 并发控制主要有三套手段:sync.WaitGroup 等待一组任务、channel 在 goroutine 间传数据、context 做取消与超时传播。三者适用边界完全不同:用错会让程序死锁、发生数据竞态,甚至造成 goroutine 泄漏。WaitGroup 要配对 Add 与 Done;无缓冲 channel 收发必须同时就绪;context 必须调用 cancel 防止泄漏。本文用五段可复制的最小示例讲清每种做法的代码与失败点,先确认 Go 版本,再照示例运行验证,最后对照排错表处理异常。读完后你能针对任务数已知、需交换数据、需随时叫停这三种场景,正确选型并写出不会死锁的并发代码。

Go 并发控制的三种做法对比示意图,展示 WaitGroup、channel 与 context 的适用边界

一、先看结论:Go 并发控制怎么选

下面这张表直接给出选择顺序,避免你在不适用的地方踩坑。

做法 适用场景 注意点
sync.WaitGroup 等一组数量已知的任务全部完成 必须配对 Add 与 Done,少一次 Done 就死锁
channel goroutine 之间传值或发信号 无缓冲 channel 收发要配对,否则阻塞主流程
context 取消传播、超时控制、跨层传值 只该取消一次,漏掉 cancel 会 goroutine 泄漏

三种做法的适用场景与边界对比图

如果你只是想等 N 个任务跑完,优先用 sync.WaitGroup;要在任务间交换数据再上 channel;需要随时叫停整条调用链就用 context。语法不熟可以先看 Go 语言教程 把基础语法跑通,再回到这里对照示例。下面的代码都基于 Go 1.18+,旧版本在泛型等细节上略有差异,但并发原语本身完全兼容。

二、环境确认与最小示例

写代码前先确认运行环境,能少走弯路。本文示例基于 Go 1.18+(泛型之后的稳定版本),需要本机已安装 Go 并配置好模块环境。先运行命令 go version 检查版本号;若提示找不到命令,先安装 Go 并配置 PATH。确认版本不低于 1.18 后,再用 go run 执行下面的示例,遇到 undefined 之类报错优先核对 import 是否写全。更完整的并发标准库说明见 Go 语言标准库教程,里面按包列出了 sync、channel 与 context 的全部 API。

下面是一段能直接 go run 的最小示例,用 WaitGroup 等两个 goroutine 结束:

package main

import (
    "fmt"
    "sync" // 引入 sync 包,提供 WaitGroup
)

func main() {
    // 声明一个等待组,用来等一组 goroutine 结束
    var wg sync.WaitGroup
    // 设定要等待的任务数量,这里准备启动 2 个
    wg.Add(2)
    for i := 1; i <= 2; i++ {
        // 用 go 关键字启动一个 goroutine,并把 id 传进去
        go func(id int) {
            // 任务结束必须调用 Done,通知等待组减一
            defer wg.Done()
            fmt.Printf("任务 %d 完成
", id)
        }(i)
    }
    // 阻塞主 goroutine,直到所有 Done 都被调用
    wg.Wait()
    fmt.Println("全部任务结束")
}

关于输出:本机未实际执行,按官方文档推断,预期结果(未在本机执行)为先后打印两条「任务 N 完成」与一行「全部任务结束」,顺序可能不同,因为 goroutine 调度顺序不确定。如果运行后报错 undefined: sync,检查 import 是否写全;如果程序一直不退出,多半是 AddDone 数量不匹配,按第五节排错表修复后再验证。

三、做法一:sync.WaitGroup 等待一组任务

sync.WaitGroup 是最直接的「等一堆任务跑完」工具,核心是 Add 登记数量、Done 减一、Wait 阻塞。它适合任务数已知、彼此不交换数据的场景。

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    nums := []int{1, 2, 3, 4, 5}
    // 提前开好结果切片,每个 goroutine 写自己的索引
    results := make([]int, len(nums))
    for i, n := range nums {
        // 每启动一个任务就 Add(1),和 go 的数量严格对应
        wg.Add(1)
        go func(idx, v int) {
            // defer 保证函数退出前一定 Done,避免漏调用
            defer wg.Done()
            results[idx] = v * v // 各写各的索引,不存在竞态
        }(i, n)
    }
    // 等全部任务结束再往下走
    wg.Wait()
    sum := 0
    for _, r := range results {
        sum += r
    }
    fmt.Println("平方和:", sum)
}

常见失败:把 wg.Add(1) 写在了 go 之外却没对应 goroutine,或忘了 defer wg.Done(),都会让 Wait 永久阻塞。多个 goroutine 要共享同一个变量时,必须加 sync.Mutex 或改用第四节的 channel。边界上,WaitGroup 不能复制(值传递会让计数错乱),也不能在 Wait 返回后再复用而不重新 Add

四、做法二:channel 在 goroutine 间通信

和 WaitGroup 对比,channel 更适合「任务之间要传数据」。一个 goroutine 往里塞,另一个从里取,天然避免了共享内存的竞态。

package main

import "fmt"

func main() {
    // 创建无缓冲 channel,用于在 goroutine 间传结果
    ch := make(chan int)
    // 启动 worker,把计算结果发到 channel
    go func() {
        ch <- 42 // 发送会阻塞,直到有人接收
    }()
    // 从 channel 接收,主 goroutine 在此等待
    result := <-ch
    fmt.Println("收到结果:", result)
}

下面这个缓冲 channel 的写法更接近真实生产:主 goroutine 发任务、收结果,用 close(ch) 告诉接收方「发完了」。

package main

import "fmt"

func main() {
    // 带缓冲的 channel,可暂存 3 个值而不阻塞发送
    ch := make(chan int, 3)
    for i := 1; i <= 3; i++ {
        ch <- i * i // 缓冲未满,发送不阻塞
    }
    close(ch) // 发完关闭,避免接收方一直等
    // for range 会在 channel 关闭后自动退出
    for v := range ch {
        fmt.Println("值:", v)
    }
}

边界:无缓冲 channel 的发送和接收必须同时就绪,否则双方互相等待;只发不收就会触发 all goroutines are asleep - deadlock。性能上,缓冲 channel 能减少收发双方的互相等待,但缓冲过大会占用更多内存,按实际吞吐选合适容量即可。

五、做法三与常见排错

context 用来做取消传播和超时控制,特别适合「一键叫停整条调用链」。它和 WaitGroup、channel 不冲突,常常配合使用:用 context 决定要不要继续,用 channel 传结果。

package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    // 创建可取消的 context 和对应的取消函数
    ctx, cancel := context.WithCancel(context.Background())
    // defer 保证函数退出一定取消,防止 goroutine 泄漏
    defer cancel()
    go func() {
        for {
            select {
            // 收到取消信号就退出循环
            case <-ctx.Done():
                fmt.Println("收到取消,goroutine 退出")
                return
            default:
                fmt.Println("工作中...")
                time.Sleep(200 * time.Millisecond)
            }
        }
    }()
    time.Sleep(1 * time.Second)
    cancel() // 主动取消,ctx.Done() 会被关闭
    time.Sleep(200 * time.Millisecond)
}
现象 常见原因 修复方向
程序卡住不退出(疑似死锁) WaitGroup 的 Add 与 Done 数量不匹配 核对 Add 次数和 go 数量,每个 goroutine 用 defer wg.Done()
偶发结果错误、go run -race 报警 多个 goroutine 同写一变量(竞态) 改用 channel 传值、加 sync.Mutex,或各写独立索引
goroutine 数只增不减 context 没调用 cancel,goroutine 泄漏 用 defer cancel(),或换 context.WithTimeout 自动取消

排错时优先用自带工具:跑 go run -race 能直接报告数据竞态,比肉眼看共享变量更靠谱;怀疑泄漏时打印 runtime.NumGoroutine() 观察数量是否只增不减。把 Add/Done 配对、cancel 必调用这两点记牢,能挡掉绝大多数并发失败。

总结

Go 并发控制的核心结论很清楚:等一组已知任务用 sync.WaitGroup,goroutine 间交换数据用 channel,需要取消传播和超时用 context。选型时先确认 Go 版本与运行环境,再写最小示例验证,最后对照排错表处理死锁、竞态和 goroutine 泄漏。把 Add/Done 配对、channel 收发平衡、cancel 必调用这三点记牢,能避开绝大多数并发失败。建议结合编程狮的 Go 语言教程,把三种写法各跑一遍再对照排错表,比只看不练更扎实。

延伸学习

想系统学 Go,可以参考编程狮的以下资源:

常见问题

Q:WaitGroup 的 Add 和 Done 数量对不上会怎样?

A:Add 了多少次就要 Done 多少次。少调用 Done 会让 wg.Wait() 永远阻塞,主程序卡住不退出,表现像死锁;多调用会直接 panic。最稳的写法是在每个 goroutine 里用 defer wg.Done(),保证一定会减一。

Q:channel 收发送不平衡为什么会死锁?

A:无缓冲 channel 的发送和接收必须同时就绪。如果只发不收(或反过来),goroutine 会一直阻塞;主 goroutine 也卡在接收,就报 all goroutines are asleep - deadlock。标准收尾是用 for range ch 或在发送方 close(ch)

Q:context 忘记调用 cancel 会 goroutine 泄漏吗?

A:会。没调用 cancel,监听 ctx.Done() 的 goroutine 永远等不到信号,一直挂着不被回收。标准写法是 defer cancel(),或改用带超时的 context.WithTimeout,时间一到自动取消并释放资源。

Q:什么场景该用 channel 而不是共享变量加锁?

A:当数据本身需要在 goroutine 之间流转、且生产者消费者节奏明确时,channel 更直观也更安全,「不要通过共享内存来通信」正是 Go 的推荐思路;只有多个协程要改同一份状态时,才优先考虑 sync.Mutex 这类锁。

0 人点赞