高级并发模式深入
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() 调用得再规范也没用。
三个陷阱
wg.Wait()+close(out)必须放在独立 goroutine。放在fanIn的同步返回路径上,fanIn就永远不返回,消费者一个值都读不到。- 只关自己创建的 channel。fan-out 的 worker 关自己的
out是对的;去关in就是越权(in属于上游),多写一个close就是panic: close of closed channel。 - 一个 worker panic 不会自动关掉别人的通道,它自己的
out由defer close关闭,其余 worker 继续跑。想让一个错误终结整条管道,得靠 ctx:defer cancel()+ 各 stage selectctx.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) }) }必须想清楚的三件事
- queueSize 就是你的背压策略。
0(无缓冲)= 提交即阻塞,强背压;很大 = 提交方几乎不阻塞,代价是内存和无谓排队。没有"正确值",只有"你想要哪种压力"。 - worker 内部每一次发送都要可取消。上面这版最容易漏的就是
p.results <- res——原始写法里 worker 处理完任务后直接发送,一旦消费者走了、ctx 也 cancel 了,worker 就卡死在发送上,wg.Wait()永不返回,results永不关闭,消费端for range也永不结束。一个漏改的发送点 = 整个池子挂住。 - 关闭协议只能有一个 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) }三个坑
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 个令牌"。- 高频率不要用"每 tick 补一个"。tick 间隔逼近定时器精度(毫秒级)时,实际速率会明显低于设定值。正经做法是"每 10ms 补
rate/100个",用少量 tick 承载高令牌产出。 - 这个桶是单机的。多副本限流要共享状态(Redis + Lua、或每副本配额 = 总额/副本数)。别指望标准库给你分布式限流。
生产上如果不想维护这几十条代码,golang.org/x/time/rate 是事实标准(Allow/Wait(ctx)/SetBurstAt,还带 Every 的浮点速率修正)。自己实现的价值主要在理解令牌桶和"避免为一个业务指标引依赖"。
6. 死锁
四个必要条件(缺一个就不可能死锁)
- 互斥:资源一次只被一个 goroutine 持有。
- 持有并等待:拿着 A 去等 B。
- 不可剥夺:只能由持有者释放。
- 循环等待: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 | 成功 |
三条推论:
- 接收已关闭的 channel 不阻塞、不报错,返回零值 +
ok=false。所以for range ch靠 close 结束;v, _ := <-ch则可能把"已关闭"当成"收到零值",永远要判ok。 - nil channel 是"永久阻塞"而不是"空"。把 nil channel 放进 select 的某个 case,等于永久禁用该分支——这是一个合法技巧(动态关闭 select 分支),不是 bug。
- 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。