需要预先了解:


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

三个要点:

  1. close(jobs) 由生产任务的 goroutine 负责;
  2. close(results) 必须等所有 worker 退出后再执行,否则会 panic 或丢结果;
  3. 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。

排查手段:

  1. runtime.NumGoroutine() 观察数量是否只增不减;
  2. pprof 的 goroutine profile:http://localhost:6060/debug/pprof/goroutine?debug=1 能看到每个 goroutine 的调用栈,直接定位卡在哪一行(详见 性能分析与调优);
  3. 给所有可能阻塞的操作加上 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

选型时先问自己三个问题:

  1. 数据是传递还是共享?传递用 channel,共享用锁/原子操作。
  2. 是一次性的结果还是持续的事件流?一次性用 WaitGroup/返回值,事件流用 channel。
  3. 有没有取消/超时需求?有就带 ctx。

1.10 练习

  1. 用 pipeline + errgroup 实现一个"读取一批文件、并发统计行数、汇总结果"的程序,要求任一文件出错时全部取消。
  2. 用信号量限制并发数为 4,抓取 20 个 URL(用 http.Get 或 time.Sleep 模拟),统计总耗时。
  3. 写一个必然泄漏的 goroutine,用 runtime.NumGoroutine() 确认泄漏,再改成带 ctx 的版本验证不再泄漏。