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

一、先看结论: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 是否写全;如果程序一直不退出,多半是 Add 与 Done 数量不匹配,按第五节排错表修复后再验证。
三、做法一: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 这类锁。

TRAE-AI编程



