Skip to content
Go
高级并发模式深入

高级并发模式深入

Go 的并发哲学是:“不要通过共享内存来通信,而应该通过通信来共享内存。”

但真正写过生产代码的人都知道,channel 带来的麻烦不比共享内存少:谁负责关闭?谁负责退出?消费者提前走了,上游的 goroutine 卡在哪? 这九个模式本质上都是在回答同一组所有权问题。本篇按"能跑的正确写法 + 会踩的坑"组织,所有示例都在 go1.27 上编译运行验证过。


1. Fan-Out / Fan-In

核心思想

  • Fan-Out:一个输入 channel 分给多个 worker 并行处理(提高吞吐)。
  • Fan-In:多个 worker 的输出合并回一个 channel(方便统一消费)。

正确实现(带取消)

// fanOut 把 in 分发给 workers 个 goroutine,各自返回一个输出 channel。
func fanOut(ctx context.Context, in <-chan int, workers int) []<-chan int {
	chans := make([]<-chan int, workers)
	for i := range chans {
		out := make(chan int) // 无缓冲:天然背压,下游不读上游就停
		chans[i] = out
		go func() {
			defer close(out) // 只关自己创建的 channel
			for v := range in {
				select {
				case out <- v * 2: // 并行处理
				case <-ctx.Done():
					return
				}
			}
		}()
	}
	return chans
}

// fanIn 把多个 channel 合并成一个。
func fanIn(ctx context.Context, chans ...<-chan int) <-chan int {
	out := make(chan int)
	var wg sync.WaitGroup
	for _, ch := range chans {
		wg.Go(func() { // WaitGroup.Go:Go 1.25+,等价于 Add(1)+go+Done
			for v := range ch {
				select {
				case out <- v:
				case <-ctx.Done():
					return
				}
			}
		})
	}
	// 关闭必须放在独立 goroutine:否则主流程会卡在这里,一个值都读不到
	go func() {
		wg.Wait()
		close(out)
	}()
	return out
}

用法:

// 生产者:每一跳发送都带 ctx.Done
func source(ctx context.Context, nums []int) <-chan int {
	in := make(chan int)
	go func() {
		defer close(in)
		for _, n := range nums {
			select {
			case in <- n:
			case <-ctx.Done():
				return
			}
		}
	}()
	return in
}

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

results := fanIn(ctx, fanOut(ctx, source(ctx, nums), 5)...)
for v := range results {
    if v > 100 {
        break // 提前退出 → defer 的 cancel() 兜底,整条链一起收
    }
}

泄漏到底泄漏了多少(实测)

拿 runtime.NumGoroutine() 量了一下 4 worker 的 fan-out/fan-in 管道,消费者读几个就 break:

写法 break 且 cancel 之后的 goroutine 数
全程不接 ctx 基线 1 → 11(10 个全部卡死)
每个 stage 的发送都 select ctx.Done() 11 → cancel 后回到 1
中间 stage 接 ctx,生产者不接 cancel 后剩 2

第三行最值得记:取消信号只能救"正在 select 的人"。生产者写成裸 in <- i,一旦下游全部退出,它就永久卡在发送上——这是最容易被忽略的泄漏点,cancel() 调用得再规范也没用。

三个陷阱

  1. wg.Wait() + close(out) 必须放在独立 goroutine。放在 fanIn 的同步返回路径上,fanIn 就永远不返回,消费者一个值都读不到。
  2. 只关自己创建的 channel。fan-out 的 worker 关自己的 out 是对的;去关 in 就是越权(in 属于上游),多写一个 close 就是 panic: close of closed channel。
  3. 一个 worker panic 不会自动关掉别人的通道,它自己的 out 由 defer close 关闭,其余 worker 继续跑。想让一个错误终结整条管道,得靠 ctx:defer cancel() + 各 stage select ctx.Done(),或者干脆用 errgroup(见第 3 节)。

2. Pipeline 模式

核心思想

每个 stage 是一个函数:收 channel、处理、返回 channel。stage 之间互不知情,靠 channel 串起来。

func generator(ctx context.Context, nums ...int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for _, n := range nums {
			select {
			case out <- n:
			case <-ctx.Done():
				return
			}
		}
	}()
	return out
}

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
}

func filter(ctx context.Context, in <-chan int, keep func(int) bool) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for n := range in {
			if keep(n) {
				select {
				case out <- n:
				case <-ctx.Done():
					return
				}
			}
		}
	}()
	return out
}

// 组装:1..10 → 平方 → 只留 >50
result := filter(ctx, square(ctx, generator(ctx, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10)),
	func(n int) bool { return n > 50 })
for v := range result {
	fmt.Println(v) // 64, 81, 100
}

错误怎么往回传

channel 的泛型只有一个类型,管道里又想传数据又想传错误,标准做法是打包结构体:

type Result[T any] struct {
	Value T
	Err   error
}

func safeSquare(ctx context.Context, in <-chan int) <-chan Result[int] {
	out := make(chan Result[int])
	go func() {
		defer close(out)
		for n := range in {
			if n < 0 {
				select {
				case out <- Result[int]{Err: fmt.Errorf("square: negative input %d", n)}:
				case <-ctx.Done():
					return
				}
				continue
			}
			select {
			case out <- Result[int]{Value: n * n}:
			case <-ctx.Done():
				return
			}
		}
	}()
	return out
}

消费端逐条判 Err,或者遇到第一个错误就 cancel()(配合 context.WithCancelCause 把原因带下去,见 context.md)。别把错误塞进普通字段用零值表达——0 既可能是合法结果又可能是"出错",读侧无法区分。

迭代器版 Pipeline(Go 1.23+)

如果各 stage 只是同步转换、不需要并发,iter.Seq 比 channel 干净得多:

func gen(nums ...int) iter.Seq[int] {
	return func(yield func(int) bool) {
		for _, n := range nums {
			if !yield(n) {
				return // 下游 break,这里立刻收到 false
			}
		}
	}
}

func squared(in iter.Seq[int]) iter.Seq[int] {
	return func(yield func(int) bool) {
		for n := range in {
			if !yield(n * n) {
				return
			}
		}
	}
}

for v := range squared(gen(1, 2, 3, 4)) {
	if v == 9 {
		break // 上游 stage 全部自动停止,没有 goroutine 泄漏,也不需要 ctx
	}
}

这是 channel 版本最难做到的一点:取消信号沿着 yield 的返回值逐层传回去,不用 ctx、不用 close、不会泄漏。代价是它全程在同一个 goroutine 里跑,没有并发。

维度 channel 管道 iter.Seq 管道
并发 每 stage 独立 goroutine 单 goroutine,同步拉取
背压 无缓冲 channel 天然阻塞 函数调用栈天然"背压"
取消 必须传 ctx,且每一跳都要 select yield 返回 false 即取消
泄漏风险 高(第 1 节的实测数字) 无
适用 IO 密集、多阶段并行、跨 goroutine 纯 CPU 数据转换、范围遍历

工程结论:默认先写迭代器,只有确实需要并行才升级成 channel。很多"管道"其实只是 map/filter,用 channel 是给自己找泄漏。


3. Worker Pool 模式

完整实现

type Job struct {
	ID      int
	Payload string
}

type Result struct {
	JobID int
	Data  string
	Err   error
}

type WorkerPool struct {
	jobs      chan Job
	results   chan Result
	workers   int
	wg        sync.WaitGroup
	closeOnce sync.Once
}

func NewWorkerPool(workers, queueSize int) *WorkerPool {
	return &WorkerPool{
		jobs:    make(chan Job, queueSize),
		results: make(chan Result, queueSize),
		workers: workers,
	}
}

func (p *WorkerPool) Start(ctx context.Context) {
	for range p.workers {
		p.wg.Go(func() { p.run(ctx) })
	}
	go func() {
		p.wg.Wait()
		close(p.results) // 唯一的关闭点:所有 worker 都退出之后
	}()
}

func (p *WorkerPool) run(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			return
		case job, ok := <-p.jobs:
			if !ok {
				return // jobs 被关闭:正常收尾
			}
			res := Result{JobID: job.ID, Data: strings.ToUpper(job.Payload)}
			// 关键:往 results 发也要能被取消,否则 ctx 已死、无人消费时这里永久阻塞
			select {
			case p.results <- res:
			case <-ctx.Done():
				return
			}
		}
	}
}

// Submit 带 ctx:队列满时调用方可被取消,而不是傻等
func (p *WorkerPool) Submit(ctx context.Context, job Job) error {
	select {
	case p.jobs <- job:
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

func (p *WorkerPool) Results() <-chan Result { return p.results }

// Close 幂等:只关 jobs,绝不关 results(那由 Start 里的收尾 goroutine 负责)
func (p *WorkerPool) Close() { p.closeOnce.Do(func() { close(p.jobs) }) }

必须想清楚的三件事

  1. queueSize 就是你的背压策略。0(无缓冲)= 提交即阻塞,强背压;很大 = 提交方几乎不阻塞,代价是内存和无谓排队。没有"正确值",只有"你想要哪种压力"。
  2. worker 内部每一次发送都要可取消。上面这版最容易漏的就是 p.results <- res——原始写法里 worker 处理完任务后直接发送,一旦消费者走了、ctx 也 cancel 了,worker 就卡死在发送上,wg.Wait() 永不返回,results 永不关闭,消费端 for range 也永不结束。一个漏改的发送点 = 整个池子挂住。
  3. 关闭协议只能有一个 owner。Close() 关 jobs(提交侧所有权),results 由"等所有 worker 退出"的那个 goroutine 关(处理侧所有权)。谁都能关 = 迟早 close of closed channel。

生产上更该用的:errgroup

golang.org/x/sync/errgroup 不是标准库,但已是事实标准。上面 60 行代码的绝大部分场景,它 6 行就够:

g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8) // 并发上限,等价于 8 个 worker
for _, job := range jobs {
    g.Go(func() error { return process(ctx, job) })
}
if err := g.Wait(); err != nil {
    // 第一个错误;ctx 已被自动取消,其余任务尽快退出
}

它替你处理了三件最容易写错的事:错误传播 + 自动 cancel、并发上限、Wait 的关闭时序。手写 WorkerPool 只在需要"池子长期存活、跨请求复用、优先级队列"时才值得。

性能提示

  • Worker 数:CPU 密集型取 runtime.GOMAXPROCS(0) 起步;IO 密集型可以远大于核数,但要用信号量或 SetLimit 封顶,别用 goroutine 数量当并发控制。Go 1.25 起容器内 GOMAXPROCS 会感知 cgroup 限额(GOMAXPROCS=-1 默认行为),副本数小的 Pod 不必再手动设。
  • results 缓冲别太大:它只是"处理完等被取走"的暂存,缓冲过大等于把背压信号吞掉,OOM 时才知道疼。

4. Future / Promise 模式

Future 让你提前启动异步任务,稍后再取结果。Go 里没有 Promise,用 channel 或泛型结构体表达就够。

类型安全的 Future

type Future[T any] struct {
	result T
	err    error
	done   chan struct{} // 只用来广播"完成",不传数据
}

func Start[T any](fn func() (T, error)) *Future[T] {
	f := &Future[T]{done: make(chan struct{})}
	go func() {
		f.result, f.err = fn()
		close(f.done) // 广播:所有等待者同时被唤醒
	}()
	return f
}

func (f *Future[T]) Get() (T, error) {
	<-f.done
	return f.result, f.err
}

func (f *Future[T]) GetWithin(d time.Duration) (T, error) {
	timer := time.NewTimer(d)
	defer timer.Stop()
	select {
	case <-f.done:
		return f.result, f.err
	case <-timer.C:
		var zero T
		return zero, fmt.Errorf("future: timeout after %v", d)
	}
}
userF := Start(func() (string, error) { return fetchUser(123) })
orderF := Start(func() ([]Order, error) { return fetchOrders(123) })

user, err := userF.Get()   // 三个 RPC 同时在飞,总耗时 ≈ 最慢的那个
orders, err := orderF.Get()

为什么读写 result 不是 data race

这是这段代码最值得理解的地方:f.result 在 goroutine A 里写、在 Get() 里读,看着像竞态,其实由 close(done) 建立 happens-before——Go 内存模型规定"close channel 先于该 channel 的任何接收返回"。所以 <-f.done 返回后读 result 一定看到写完的值,不需要锁、不需要 atomic。

反过来说:如果 done 不是"写完才关",这个保证就没了。go func() { close(f.done); f.result = ... }() 就是真 race。

channel 即 future(一次性结果)

func asyncCompute(ctx context.Context, input int) <-chan Result {
	ch := make(chan Result, 1) // 缓冲 1:无人接收时 goroutine 也能退出
	go func() {
		v, err := expensiveComputation(ctx, input)
		ch <- Result{Value: v, Err: err} // 有缓冲,永不阻塞
		close(ch)
	}()
	return ch
}

缓冲 1 是这模式的命门。 无缓冲版本在"调用方放弃等待"时,goroutine 永久卡在 ch <- ... 上——这正是第 1 节实测泄漏表里的"裸发送"。一次性结果用带缓冲 1 的 channel,比"发送 + select ctx.Done()“更省心。

和 sync.OnceValue 的边界

var getConf = sync.OnceValue(func() *Config { return loadConfig() }) // 惰性、只算一次、所有人等它

OnceValue 是”用到时才启动,且全局共享一份结果";Future 是"现在就启动,结果归调用方"。配置、连接、静态字典用 OnceValue;一次请求内的并行取数用 Future/errgroup。两者别混——用 Future 模拟单例会启动 N 份重复计算。

模式对比

  • Future:少量独立异步任务,各自要结果。场景:并行调多个下游 API、预热。
  • Pipeline:数据流经多个 stage 变换。场景:ETL、日志处理、图片流水线。
  • Worker Pool:大量任务 + 固定并发度。场景:批量导入、异步作业。

5. 限流器(令牌桶)

type RateLimiter struct {
	tokens chan struct{}
	ticker *time.Ticker
	stop   context.CancelFunc
	once   sync.Once
}

func NewRateLimiter(ratePerSec, burst int) (*RateLimiter, error) {
	if ratePerSec <= 0 || burst <= 0 {
		return nil, errors.New("rate limiter: rate and burst must be positive")
	}
	interval := time.Second / time.Duration(ratePerSec)
	if interval <= 0 {
		return nil, fmt.Errorf("rate limiter: rate %d too high for ticker interval", ratePerSec)
	}
	ctx, cancel := context.WithCancel(context.Background())
	rl := &RateLimiter{
		tokens: make(chan struct{}, burst),
		ticker: time.NewTicker(interval),
		stop:   cancel,
	}
	for range burst {
		rl.tokens <- struct{}{} // 预填满,允许突发
	}
	go func() {
		defer rl.ticker.Stop()
		for {
			select {
			case <-rl.ticker.C:
				select {
				case rl.tokens <- struct{}{}:
				default: // 桶满:丢弃令牌,不排队补偿
				}
			case <-ctx.Done():
				return
			}
		}
	}()
	return rl, nil
}

func (rl *RateLimiter) Allow() bool { // 非阻塞:拿不到令牌直接失败
	select {
	case <-rl.tokens:
		return true
	default:
		return false
	}
}

func (rl *RateLimiter) Wait(ctx context.Context) error { // 阻塞:等到令牌或被取消
	select {
	case <-rl.tokens:
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

func (rl *RateLimiter) Close() { rl.once.Do(rl.stop) }

三个坑

  1. time.Second / time.Duration(rate) 是整数除法。rate=3 → 333333333ns,累计误差极小但存在;rate=2_000_000_000(20 亿/秒)→ interval 直接算成 0,time.NewTicker(0) panic: non-positive interval。所以必须先校验 interval > 0,或改成"一个 tick 补 N 个令牌"。
  2. 高频率不要用"每 tick 补一个"。tick 间隔逼近定时器精度(毫秒级)时,实际速率会明显低于设定值。正经做法是"每 10ms 补 rate/100 个",用少量 tick 承载高令牌产出。
  3. 这个桶是单机的。多副本限流要共享状态(Redis + Lua、或每副本配额 = 总额/副本数)。别指望标准库给你分布式限流。

生产上如果不想维护这几十条代码,golang.org/x/time/rate 是事实标准(Allow/Wait(ctx)/SetBurstAt,还带 Every 的浮点速率修正)。自己实现的价值主要在理解令牌桶和"避免为一个业务指标引依赖"。


6. 死锁

四个必要条件(缺一个就不可能死锁)

  1. 互斥:资源一次只被一个 goroutine 持有。
  2. 持有并等待:拿着 A 去等 B。
  3. 不可剥夺:只能由持有者释放。
  4. 循环等待:A 等 B 手里的,B 等 A 手里的。

破坏第 4 条是工程上最实用的打法。

// 经典死锁:两个 goroutine 以相反顺序拿两把锁
var mu1, mu2 sync.Mutex

go func() {
	mu1.Lock()
	defer mu1.Unlock()
	time.Sleep(time.Millisecond)
	mu2.Lock() // 等 mu2
	defer mu2.Unlock()
}()

go func() {
	mu2.Lock() // 与上面相反
	defer mu2.Unlock()
	time.Sleep(time.Millisecond)
	mu1.Lock() // 等 mu1 → 互等
	defer mu1.Unlock()
}()

注意:Go 运行时能检测到"所有 goroutine 都被阻塞"的情况并 fatal error: all goroutines are asleep - deadlock!,但这只在整个程序无人可运行时触发。还有别的 goroutine 在跑(比如你的服务里有 HTTP handler)时,死锁的两个 goroutine 会静静地挂在那里,没有任何报错,只有 pprof 的 goroutine profile 能看到它们卡在 sync.runtime_SemacquireMutex。生产上"某个后台任务不干活了",八成是这个形状。

三条出路

出路 1:合并锁。 大多数"需要两把锁"的场面,其实一把就够:

type Transfer struct {
	mu       sync.Mutex
	balance  map[string]int
}

一把锁保护的是一组状态,比两把锁的时序推理简单一个数量级。

出路 2:固定加锁顺序。 按一个稳定的业务 key 排序:

type Account struct {
	ID  string
	mu  sync.Mutex
	bal int
}

// 所有调用者都按 ID 升序拿锁 → 不可能出现环路等待
func transfer(from, to *Account, amount int) {
	a, b := from, to
	if a.ID > b.ID {
		a, b = b, a
	}
	a.mu.Lock()
	defer a.mu.Unlock()
	b.mu.Lock()
	defer b.mu.Unlock()
	if a.bal < amount {
		return
	}
	a.bal -= amount
	b.bal += amount
}

陷阱:很多人图省事用对象地址排序。uintptr(unsafe.Pointer(a)) > uintptr(unsafe.Pointer(b)) 确实能编译(unsafe.Pointer 转 uintptr 后是整数类型),但Go 规范只保证指针对能用 ==/!= 比较,关系比较的语义由实现决定——*A 之间写 a > b 编译器直接报错:invalid operation: a > b (operator > not defined on pointer)(实测)。用业务 ID 排序既是全序又稳定,没有任何理由换成地址。

出路 3:TryLock 回退(Go 1.18+)。 拿不到就全部放手、退避重试:

func lockBoth(mu1, mu2 *sync.Mutex) bool {
	if !mu1.TryLock() {
		return false
	}
	if !mu2.TryLock() {
		mu1.Unlock() // 只解锁自己已经持有的那把
		return false
	}
	return true
}

陷阱:网上(包括本文件旧版本)流传的写法是 mu1.Lock(); defer mu1.Unlock(); if !mu2.TryLock() { mu1.Unlock(); return }——手动 Unlock 之后 defer 还会再解一次,panic: unlock of unlocked mutex。要么不用 defer、成对显式解锁;要么像上面这样把 defer 交给调用方:

if !lockBoth(&a.mu, &b.mu) {
	time.Sleep(time.Millisecond) // 退避
	// 重试
} else {
	defer a.mu.Unlock()
	defer b.mu.Unlock()
	// ...
}

另外 sync.Mutex 没有带超时的 TryLock(TryLockWithDuration 从未存在)。“最多等 100ms"要自己拿 channel 包一层,但那样锁语义就得整个改造——通常说明你该重新设计临界区。


7. Goroutine 泄漏的四种形态

// 形态 1:无人接收的发送
func leakSend() {
	ch := make(chan int)      // 无缓冲
	go func() { ch <- 42 }()  // 永久阻塞在这里
	// ch 被丢弃,再没人读
}

// 形态 2:生产者忽略取消信号(第 1 节实测:cancel 后仍剩 2 个 goroutine)
func leakProducer(ctx context.Context, out chan<- int) {
	go func() {
		for i := range 100000 {
			out <- i // 只有一行发送,没有 select ctx.Done()
		}
	}()
}

// 形态 3:select 里没有退出分支
func leakLoop(ctx context.Context) {
	go func() {
		for {
			select {
			case v := <-workCh:
				handle(v)
				// 忘了 case <-ctx.Done():ctx 取消也停不下来
			}
		}
	}()
}

// 形态 4:等一个永远不会到来的 close
func leakRange() {
	ch := make(chan int)
	go func() {
		for v := range ch { // 发送方忘了 close → 永久 range
			use(v)
		}
	}()
}

对应修法:

// 形态 1:给一次性结果加缓冲 1(或让接收方一定存在)
func fixedSend() {
	ch := make(chan int, 1)
	go func() { ch <- 42 }() // 立刻完成,goroutine 退出
}

// 形态 2/3:每一跳发送/等待都带 ctx.Done 分支
func fixedLoop(ctx context.Context) {
	timer := time.NewTimer(time.Second)
	defer timer.Stop()
	for {
		select {
		case <-timer.C:
			doWork()
			timer.Reset(time.Second) // Go 1.23+ 直接 Reset,不必先排空
		case <-ctx.Done():
			return
		}
	}
}

注意:for { select { case <-time.After(time.Second): ... } } } 这种写法在 Go 1.23 之前还会额外积累 timer(未 Stop 的 Timer 不能被 GC,直到触发),循环里等于每轮泄漏一个 timer;Go 1.23 起未引用的 Timer/Ticker 可被回收,内存问题基本消失,但"永远检查不到 ctx"的逻辑缺陷仍然在——别把 1.23 的修复当成写法没问题。

检测手段

n := runtime.NumGoroutine() // 打点:压测前后、每个发布版本的基线
  • runtime.NumGoroutine():适合做告警(“60 秒内单调增长”),但它只是一个数,不告诉你卡在哪。
  • net/http/pprof 的 /debug/pprof/goroutine?debug=1:按栈聚合,一眼看到"237 个 goroutine 阻塞在 chan send”。
  • /debug/pprof/goroutineleak(Go 1.27 正式可用):直接只列出疑似泄漏的 goroutine,1.26 时是实验特性,现在 pprof.Lookup("goroutineleak") 即可取到(实测 go1.27.0)。比全量 goroutine dump 好用得多,尤其长生命周期服务。详见 pprof.md。
  • -race 不检测泄漏,但能提前抓出那些"卡住之前"的同步错误。

8. Channel 关闭的规则

操作 nil channel 已关闭的 channel 正常 channel
ch <- v 永久阻塞 panic: send on closed channel 阻塞或成功
<-ch 永久阻塞 立即返回零值(ok 为 false) 阻塞或成功
close(ch) panic: close of nil channel panic: close of closed channel 成功

三条推论:

  1. 接收已关闭的 channel 不阻塞、不报错,返回零值 + ok=false。所以 for range ch 靠 close 结束;v, _ := <-ch 则可能把"已关闭"当成"收到零值",永远要判 ok。
  2. nil channel 是"永久阻塞"而不是"空"。把 nil channel 放进 select 的某个 case,等于永久禁用该分支——这是一个合法技巧(动态关闭 select 分支),不是 bug。
  3. close 是广播:所有等待的接收者同时被唤醒并看到零值。这就是第 4 节 Future 用 close(done) 而不用 done <- struct{}{} 的原因(后者只唤醒一个接收者)。

陷阱:for { v, ok := <-ch; if !ok { break } } 循环里如果 break 写漏,已关闭 channel 会立刻返回零值,你得到的是一个疯狂空转的 CPU 烧穿循环。这类 bug 在 -race 下看不出来,压测时才炸。

多生产者怎么关

“发送方负责关闭"这条黄金法则在多发送方时不好直接用——谁关?三个可行方案:

// 方案 1(首选):一个协调 goroutine + WaitGroup 等所有人写完再关
var wg sync.WaitGroup
for _, item := range items {
	wg.Go(func() { produce(jobsCh, item) }) // 只写,不关
}
go func() {
	wg.Wait()
	close(jobsCh) // 唯一的关闭者
}()

// 方案 2:用 done channel 由所有者下命令,生产者退出,关闭仍在所有者手里
// 方案 3:根本不关,接收方靠 ctx.Done 或哨兵值结束(语义模糊,不推荐)

原则:close 的权力集中到一个点。多个 goroutine 都可能 close(ch),就一定会在某次调度交错里撞上 close of closed channel。


9. -race:能力和边界

go test -race ./...
go run -race main.go
go build -race -o myapp
go install -race mypkg

它检测什么:官方定义——“data race occurs when two goroutines access the same variable concurrently and at least one of the accesses is a write”。报告里给出冲突访问的两处栈,以及两个 goroutine 的创建点栈,这三段信息通常直接指向根因。

它不检测什么(这几条最容易被误解):

  • 不检测死锁:两个 goroutine 互等是"完全合法"的同步——没有并发读写同一变量,race detector 一声不响。
  • 不检测逻辑错误:wg.Done() 少调、向已关闭 channel 发送(那是 runtime panic)、忘 close 导致 range 不退出,都不会被报告成 race。
  • 只看真跑过的路径:官方明说 “the race detector only finds races that happen at runtime, so it can’t find races in code paths that are not executed”。覆盖率不够时,用 -race 编出来的二进制在预发环境跑真实流量,比补单测更容易抓到。
  • 一个隐蔽例子(来自官方文档):go func() { c <- struct{}{} }(); close(c) —— 发送和关闭并发,两个操作对同一 channel 无同步,会被报成 race。“并发发送 + 关闭"是真的要避免,不只是理论问题。

代价(官方数字):典型程序内存 5-10 倍、执行时间 2-20 倍。所以 CI 单独开 race job、生产绝不带 -race。

注意:race 模式下每个 defer/recover 语句额外分配约 8 字节,直到该 goroutine 退出才释放,且这部分不出现在 runtime.ReadMemStats 和 pprof 里。长期运行的 goroutine 在 race 构建下可能内存无限增长——排查"加了 -race 才 OOM"时先看这条,别怀疑自己代码有问题。

调优开关:GORACE="halt_on_error=1 exitcode=99 log_path=/tmp/race history_size=2"。halt_on_error=1(默认 0)在第一个 race 后就停,方便定位连锁污染;检测到 race 时默认退出码是 66,写 CI 脚本判失败时别只判非零。


10. 并发模式选型

场景 推荐方案
保护一段共享状态 sync.Mutex(见 sync.md)
读远多于写、临界区不短 sync.RWMutex
单值热更新(配置/路由表) atomic.Pointer[T] 整份替换(见 sync-atomic.md)
goroutine 之间传数据所有权 channel
纯同步的数据转换管道 iter.Seq + range-over-func(Go 1.23+)
等一组 goroutine 完成 sync.WaitGroup / wg.Go(1.25+)
一组任务 + 并发上限 + 错误传播 errgroup.WithContext + SetLimit
只初始化一次(惰性) sync.OnceValue
现在就启动、稍后取结果 Future / 带缓冲 1 的 channel
大量任务、固定 worker Worker Pool(或 x/sync/semaphore)
通知所有订阅者 close(chan) 广播 / 扇出
等待某个条件成立 sync.Cond(无超时)或 channel
一次性保护下游流量 令牌桶限流 + ctx 取消
互斥资源可能成环 固定加锁顺序 / 合并锁 / TryLock 回退

实战建议

  • 写并发代码前先回答三个问题:谁创建这个 channel、谁往里写、谁负责关。三个答案对应同一个 owner,代码就干净;分裂了,就一定要有 WaitGroup 之类的协调者。第 1、3 节的所有 bug 都出在这三个答案含糊。
  • 每一跳发送都要能被取消。ch <- v 裸写、results <- res 裸写、生产者 out <- i 裸写,是 Go 服务 goroutine 泄漏排行榜前三。实测数字摆在这:一处漏改,cancel 就白调。
  • 默认先写同步代码,压测和 pprof 证明瓶颈在串行执行时才引入并发。大多数"看着像需要管道"的地方,iter.Seq 或直接一个函数调用更合适。
  • 能引 errgroup 就别手搓。它是 x/sync 里最稳定的包,替你处理错误传播、取消、并发上限三件事。标准库没有不等于不能用,go.mod 里多一个 golang.org/x/sync 不构成任何风险。
  • 上线流程里必须有 -race:CI 单独 job,覆盖率不足时用 -race 二进制在预发跑真实流量。同时把 /debug/pprof/goroutineleak(1.27+)加进服务的例行巡检,长跑服务的泄漏会自己冒出来。
  • 死锁在带流量的进程里不会自动报错。别指望运行时的 all goroutines are asleep,那是全程序停摆才给你的礼物。后台任务不干活了,先抓 goroutine profile 看谁卡在 SemacquireMutex。