并发设计模式与陷阱总结
需要预先了解:
1. 并发设计模式与陷阱总结
前四篇讲了 goroutine、channel、同步原语和 context 的用法,这一篇把它们组织成可复用的模式,并把并发编程里最容易踩的坑集中列出来。
1.1 pipeline
**pipeline(流水线)**把任务拆成若干阶段,每个阶段的输入输出都是 channel,数据像流水一样流过:
// 生成器:产生数据
func gen(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
out <- n
}
}()
return out
}
// 阶段:处理数据
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
out <- n * n
}
}()
return out
}
for v := range square(gen(1, 2, 3, 4)) {
fmt.Print(v, " ")
}
1 4 9 16
每个阶段都是一个独立 goroutine,谁生产谁关闭(defer close(out)),下游用 for range 读到 channel 关闭后自然退出。整条管道就实现了"优雅关闭"。
需要注意:如果下游提前退出(比如 break),上游的 goroutine 会阻塞在发送上,造成泄漏。解决办法是给每个阶段传 ctx,在 select 里同时等待取消:
func square(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
select {
case out <- n * n:
case <-ctx.Done():
return
}
}
}()
return out
}
1.2 fan-in / fan-out
fan-out:多个 goroutine 从同一个 channel 读取,把工作分摊开。
fan-in:把多个 channel 的输出合并到一个 channel。
func merge(chs ...<-chan int) <-chan int {
var wg sync.WaitGroup
out := make(chan int)
for _, ch := range chs {
wg.Add(1)
go func(c <-chan int) {
defer wg.Done()
for v := range c {
out <- v
}
}(ch)
}
go func() {
wg.Wait()
close(out) // 所有输入都转发完才关闭输出
}()
return out
}
in := gen(1, 2, 3, 4, 5, 6)
c1 := square(in)
c2 := square(in)
c3 := square(in)
for v := range merge(c1, c2, c3) {
fmt.Print(v, " ")
}
4 9 1 16 25 36
merge 的关键是"用 WaitGroup 等所有转发 goroutine 结束后再关闭输出 channel"——关闭的责任落在唯一的那个 goroutine 上。
1.3 worker pool
固定数量的 worker 消费一个任务队列,是控制并发度最常用的模式:
jobs := make(chan int, 20)
results := make(chan int, 20)
var wg sync.WaitGroup
for w := 1; w <= 3; w++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := range jobs {
time.Sleep(10 * time.Millisecond)
results <- j * 2
}
}(w)
}
go func() {
for i := 1; i <= 6; i++ {
jobs <- i
}
close(jobs) // 任务发完就关闭,worker 的 range 会退出
}()
go func() {
wg.Wait()
close(results) // 所有 worker 结束后关闭结果 channel
}()
total := 0
for r := range results {
total += r
}
fmt.Println("worker pool 结果总和:", total)
worker pool 结果总和: 42
三个要点:
close(jobs)由生产任务的 goroutine 负责;close(results)必须等所有 worker 退出后再执行,否则会 panic 或丢结果;- worker 数量固定,内存和并发度都可控。
1.4 信号量限流
用带缓冲 channel 实现"最多同时 N 个任务":
sem := make(chan struct{}, 3)
for i := 1; i <= 6; i++ {
go func(id int) {
sem <- struct{}{} // 获取令牌
defer func() { <-sem }() // 归还令牌
time.Sleep(20 * time.Millisecond)
fmt.Println("任务", id, "完成")
}(i)
}
缓冲容量就是并发上限。比 worker pool 更灵活的地方在于:任务本身是按需创建的,只是"同时运行的"被限制住。
1.5 errgroup
golang.org/x/sync/errgroup 把 WaitGroup、错误收集和 context 取消组合在一起:
g, ctx := errgroup.WithContext(context.Background())
g.Go(func() error { /* ... */ })
g.Go(func() error { /* ... */ })
if err := g.Wait(); err != nil {
// 任意一个任务返回非 nil 错误就会拿到
}
它的行为:
Wait返回第一个非 nil 错误;- 任意任务出错时,派生的
ctx被取消; - 但取消不会强制停止其他 goroutine,它们必须自己检查
ctx.Done()。
看两种写法的差别:
g2, _ := errgroup.WithContext(context.Background())
g2.Go(func() error {
time.Sleep(20 * time.Millisecond)
return errors.New("模拟失败")
})
g2.Go(func() error {
time.Sleep(100 * time.Millisecond)
fmt.Println("不检查 ctx 的任务会继续跑完")
return nil
})
fmt.Println("g2.Wait:", g2.Wait())
不检查 ctx 的任务会继续跑完
g2.Wait: 模拟失败
第二个任务没有检查 ctx,所以照样跑完。写成主动响应取消的版本:
g3, ctx3 := errgroup.WithContext(context.Background())
g3.Go(func() error {
time.Sleep(20 * time.Millisecond)
return errors.New("先失败")
})
g3.Go(func() error {
select {
case <-ctx3.Done():
fmt.Println("第二个任务收到取消信号:", ctx3.Err())
return ctx3.Err()
case <-time.After(200 * time.Millisecond):
fmt.Println("不会走到这里")
return nil
}
})
fmt.Println("g3.Wait:", g3.Wait())
第二个任务收到取消信号: context canceled
g3.Wait: 先失败
SetLimit(n) 可以限制并发数(x/sync v0.3.0+,需要 Go 1.20+):
g4 := &errgroup.Group{}
g4.SetLimit(2)
1.6 定时任务与 ticker
time.NewTicker 按固定间隔触发,必须显式 Stop:
ticker := time.NewTicker(30 * time.Millisecond)
defer ticker.Stop()
for i := 0; i < 3; i++ {
<-ticker.C
fmt.Println("tick", i+1)
}
tick 1
tick 2
tick 3
几个注意点:
- 不要用
time.Tick,它返回的 ticker 无法停止,会一直占用资源; ticker.C的缓冲只有 1,如果处理速度跟不上,会丢弃中间的 tick,而不是排队;- 需要"每次执行完再等间隔"而不是"固定频率"时,用
time.After或time.Sleep重新计时; - Go 1.23 起,未被引用的
Timer/Ticker可以被 GC 回收,但显式Stop仍然是好习惯。
1.7 goroutine 泄漏排查
泄漏的本质是:goroutine 永远等不到它要等的事件。
func leakyPipeline() <-chan int {
out := make(chan int)
go func() {
defer close(out)
for i := 0; i < 3; i++ {
out <- i
}
}()
return out
}
for i := 0; i < 3; i++ {
ch := leakyPipeline()
fmt.Println("读到:", <-ch)
// ch 被丢弃,发送方还阻塞在第二个值上
}
初始: 1
读到: 0
读到: 0
读到: 0
泄漏后: 4
只读一个值就不读了,发送方永远阻塞在 out <- i 上。三次调用泄漏了三个 goroutine。
排查手段:
runtime.NumGoroutine()观察数量是否只增不减;- pprof 的 goroutine profile:
http://localhost:6060/debug/pprof/goroutine?debug=1能看到每个 goroutine 的调用栈,直接定位卡在哪一行(详见 性能分析与调优); - 给所有可能阻塞的操作加上
ctx或超时,让 goroutine 有退出路径。
常见泄漏原因:
- 忘记读结果 channel;
- 向无人接收的 channel 发送;
time.Ticker没有Stop;- 阻塞在锁上(锁的持有者已退出或死锁);
- 请求取消后仍阻塞在 IO 上。
1.8 陷阱总结表
| 陷阱 | 现象 | 规避方式 |
|---|---|---|
| 闭包捕获循环变量(1.22 之前) | 所有闭包看到循环结束时的值 | 把循环变量作为参数传入,或用 i := i 复制;1.22+ 默认每轮独立变量 |
WaitGroup.Add 放在 goroutine 内 |
Wait 提前返回,任务没跑完 |
在 go 语句之前调用 Add;1.25+ 可用 wg.Go |
拷贝含 Mutex 的结构体 |
锁失效,保护不住数据 | 用指针接收者/指针传参;go vet 会报 “passes lock by value” |
| map 并发读写 | fatal error: concurrent map writes,不可 recover |
用 sync.Mutex 或 sync.Map |
| nil channel 收发 | 永久阻塞,最终 all goroutines are asleep - deadlock! |
用 select + default,或在 select 中作为禁用分支 |
| 无缓冲 channel 自收自发 | 同一 goroutine 里死锁 | 发送和接收放在不同的 goroutine |
| channel 元素是大结构体 | 每次收发都完整拷贝,GC 压力大 | 传指针(并保证不再并发访问)或改小结构体 |
| 循环变量在 goroutine 中使用 | 所有 goroutine 可能拿到同一个值(1.22 之前) | 同上,1.22+ 已修复 |
忘记 cancel() |
context 树无法释放,定时器泄漏 | defer cancel() 紧跟派生语句之后 |
注意:Go 1.22 起,
for循环的循环变量每轮迭代都是新变量,go func() { fmt.Println(i) }()不再需要i := i。但闭包捕获的是变量本身这条规则没有变,显式共享变量的场景仍然要小心。
1.9 机制选型速查表
| 需求 | 机制 |
|---|---|
| 接收任务结果 | channel(或函数返回值) |
| 完成通知/等待一批任务 | sync.WaitGroup、done channel |
| 传递数据/所有权 | channel |
| 保护共享状态 | sync.Mutex / sync.RWMutex |
| 单个变量计数/标志 | sync/atomic(含 1.19+ 类型化 API) |
| 限制并发数 | 带缓冲 channel(信号量)、errgroup.SetLimit |
| 取消 / 超时 | context(详见 context) |
| 只执行一次 | sync.Once |
| 复用临时对象 | sync.Pool |
| 定时/周期任务 | time.NewTicker + Stop |
选型时先问自己三个问题:
- 数据是传递还是共享?传递用 channel,共享用锁/原子操作。
- 是一次性的结果还是持续的事件流?一次性用
WaitGroup/返回值,事件流用 channel。 - 有没有取消/超时需求?有就带
ctx。
1.10 练习
- 用 pipeline +
errgroup实现一个"读取一批文件、并发统计行数、汇总结果"的程序,要求任一文件出错时全部取消。 - 用信号量限制并发数为 4,抓取 20 个 URL(用
http.Get或time.Sleep模拟),统计总耗时。 - 写一个必然泄漏的 goroutine,用
runtime.NumGoroutine()确认泄漏,再改成带ctx的版本验证不再泄漏。



