返回首页

Golang横向:09 死锁与并发问题排查

Go 语言把并发能力做成了一等公民:goroutine 足够轻量,channel 语义清晰,标准库也提供了 synccontextatomic 等成熟工具。但越是容易写并发,越容易写出“看起来能跑、压力一上来就出事”的代码。

线上稳定性问题里,死锁、数据竞争、并发 panic、goroutine 堆积,往往都不是“语法错误”,而是在特定时序下才暴露的逻辑错误。这类问题的麻烦之处在于:本地可能复现不稳定,测试环境不一定触发,线上一旦出问题又常常伴随偶发性和难回溯性。

本文聚焦 Go 并发问题中最常见、也最容易造成线上事故的几类场景,分别讨论:

  • Go 死锁的本质与检测
  • 数据竞争检测
  • 常见并发 bug 模式:并发写 map、double-close channel
  • goroutine 调度器与 GOMAXPROCS
  • 一次线上并发 panic 的复盘案例

目标不是背概念,而是建立一套看到异常日志时能迅速定位、看到并发代码时能提前避坑的排查思路。

一、Go 死锁的本质与检测

1.1 什么是死锁

在 Go 里,死锁可以简单理解为:所有相关 goroutine 都在等待某个永远不会再发生的事件,因此程序无法继续推进

最典型的等待包括:

  • 等待 channel 收发
  • 等待互斥锁释放
  • 等待 WaitGroup 归零
  • 等待某个 goroutine 结束,但对方也在等待自己

Go 运行时会在某些场景下直接帮你识别死锁,并抛出非常经典的报错:

fatal error: all goroutines are asleep - deadlock!

这句话的意思并不是“系统线程睡眠了”,而是:运行时发现所有 goroutine 都阻塞住了,没有任何一个 goroutine 能继续执行,也没有外部事件能让程序恢复推进

1.2 一个最小死锁示例

下面这段代码可以直接运行,并稳定触发死锁:

package main

func main() {
	ch := make(chan int)
	ch <- 1
}

执行:

go run main.go

你会看到类似输出:

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:
main.main()
    /path/to/main.go:5 +0x...
exit status 2

原因很直接:

  • ch 是一个无缓冲 channel
  • 往无缓冲 channel 发送数据时,必须同时有接收方准备好
  • 当前只有主 goroutine
  • 主 goroutine 在发送时阻塞
  • 没有其他 goroutine 能来接收
  • 所以程序进入死锁

1.3 稍复杂一点:两个 goroutine 互相等待

下面的例子模拟“你等我、我等你”的场景:

package main

import (
	"fmt"
)

func main() {
	ch1 := make(chan string)
	ch2 := make(chan string)

	go func() {
		msg := <-ch1
		fmt.Println("goroutine A received:", msg)
		ch2 <- "ack from A"
	}()

	go func() {
		msg := <-ch2
		fmt.Println("goroutine B received:", msg)
		ch1 <- "ack from B"
	}()

	select {}
}

这段代码里:

  • goroutine A 先等 ch1
  • goroutine B 先等 ch2
  • 但没有任何一方先发起发送
  • 所以两个 goroutine 都卡住
  • 主 goroutine 通过 select {} 永久阻塞

这个例子说明:死锁不一定只有一个 goroutine,也可能是一组 goroutine 构成环形等待。

1.4 WaitGroup 使用错误也会造成“假死”

另一个非常常见的问题是 WaitGroup 计数不匹配,导致主流程一直等不到结束。

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	wg.Add(1)

	go func() {
		fmt.Println("worker start")
		// 忘记调用 wg.Done()
	}()

	wg.Wait()
	fmt.Println("done")
}

这类问题未必总是触发 fatal error: all goroutines are asleep,但从业务效果上看,程序就是“卡死”了。

排查这种问题时,不要只盯着 channel,也要检查:

  • wg.Add(n) 和实际启动的 goroutine 数量是否一致
  • 每条退出路径上是否都能执行 wg.Done()
  • 是否存在提前 return 导致 Done() 没走到

一个稳妥写法是把 wg.Done() 放在 goroutine 开头并配合 defer

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	wg.Add(1)

	go func() {
		defer wg.Done()
		fmt.Println("worker start")
	}()

	wg.Wait()
	fmt.Println("done")
}

1.5 死锁排查时应该看什么

遇到死锁或怀疑死锁时,排查重点通常不是“这段代码是不是有 bug”,而是:当前所有 goroutine 分别在等什么

一个实用排查思路如下:

排查点 重点问题 常见现象
channel 是否有人永远在等发送/接收 goroutine 卡在 chan sendchan receive
mutex / rwmutex 是否存在未释放锁、重复加锁、锁顺序反转 goroutine 卡在 sync.(*Mutex).Lock
WaitGroup Add / Done 是否匹配 主 goroutine 卡在 wg.Wait()
goroutine 生命周期 生产者或消费者是否提前退出 一端永远等不到另一端
close 时机 channel 是否该关未关,或过早关闭 阻塞或 panic 混合出现

线上或压测时,除了看应用日志,也可以重点关注 goroutine 堆栈信息。很多时候,堆栈已经把问题写得很清楚了,例如:

  • 卡在 chan receive
  • 卡在 chan send
  • 卡在 sync.runtime_SemacquireMutex
  • 卡在 sync.(*WaitGroup).Wait

结论是:死锁排查,本质上就是还原等待关系图。

二、数据竞争检测

2.1 什么是数据竞争

数据竞争(data race)指的是:多个 goroutine 并发访问同一块内存,并且至少有一个是写操作,同时这些访问之间没有正确同步。

注意,数据竞争和死锁不是一回事:

  • 死锁强调“谁也走不动了”
  • 数据竞争强调“虽然还能跑,但读写顺序不可预期,结果不可靠”

数据竞争的危险之处在于:

  • 有时候程序不会立刻崩
  • 有时候结果只是偶尔错
  • 有时候线上跑几天才暴露一次
  • 一旦和 map、slice、指针、共享状态结合,后果可能非常随机

2.2 最小复现:多个 goroutine 竞争累加

下面是一段很典型的竞争代码:

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	counter := 0

	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			counter++
		}()
	}

	wg.Wait()
	fmt.Println("counter =", counter)
}

这段代码的问题是:counter++ 不是原子操作,它至少包含“读、改、写”三个步骤。多个 goroutine 同时执行时,就会发生数据竞争。

你可能会看到输出不是 1000,但更关键的是:即使某次刚好输出 1000,也不代表代码没问题。

2.3 如何使用 race detector

Go 自带 race detector,排查这类问题时非常实用。

运行单文件程序:

go run -race main.go

运行测试:

go test -race ./...

如果存在数据竞争,你会看到类似输出:

WARNING: DATA RACE
Read at 0x00c000014148 by goroutine 8:
  main.main.func1()
      /path/to/main.go:15 +0x...

Previous write at 0x00c000014148 by goroutine 7:
  main.main.func1()
      /path/to/main.go:15 +0x...

这个输出很有价值,它会告诉你:

  • 哪个地址存在竞争
  • 哪个 goroutine 在读
  • 哪个 goroutine 在写
  • 对应代码位置在哪一行

2.4 用互斥锁修复

最直观的修复方式,是给共享变量加锁。

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	var mu sync.Mutex
	counter := 0

	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			mu.Lock()
			counter++
			mu.Unlock()
		}()
	}

	wg.Wait()
	fmt.Println("counter =", counter)
}

再次执行:

go run -race main.go

如果没有其他问题,race detector 就不会再报竞争。

2.5 race detector 的使用边界

虽然 -race 很好用,但也要知道它的边界:

  1. 它只能发现“被执行到”的竞争路径,不能证明所有路径都安全。
  2. 它会带来明显性能开销,因此通常用于开发、测试、预发,而不是直接常驻生产。
  3. 它可能改变程序时序,让某些竞态更容易暴露,也可能让极端时序问题不再稳定复现。

因此,正确姿势不是“跑一次 -race 没报错就万事大吉”,而是:

  • 单元测试开启 -race
  • 集成测试开启 -race
  • 关键并发模块做专项压测
  • 对共享状态做设计级约束,而不是事后补救

三、常见并发 bug 模式

下面这两类问题,在 Go 线上故障里非常高频,而且都很有代表性。

3.1 并发写 map

Go 原生 map 不是并发安全的。多个 goroutine 同时读写,或者尤其是同时写,很容易直接触发运行时 panic。

先看一段错误示例:

package main

import (
	"fmt"
	"sync"
)

func main() {
	m := make(map[int]int)
	var wg sync.WaitGroup

	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func(v int) {
			defer wg.Done()
			m[v] = v * 10
		}(i)
	}

	wg.Wait()
	fmt.Println("map size:", len(m))
}

执行时,常见报错类似:

fatal error: concurrent map writes

为什么会这样

Go 的 map 在写入时,内部可能发生:

  • bucket 更新
  • 哈希冲突处理
  • 扩容和搬迁
  • 元数据变更

这些过程并不是为并发写设计的。一旦多个 goroutine 同时写入,内部结构可能被破坏,因此运行时直接选择 panic,而不是放任结果悄悄出错。

修复方式 1:加锁保护 map

package main

import (
	"fmt"
	"sync"
)

func main() {
	m := make(map[int]int)
	var mu sync.Mutex
	var wg sync.WaitGroup

	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func(v int) {
			defer wg.Done()
			mu.Lock()
			m[v] = v * 10
			mu.Unlock()
		}(i)
	}

	wg.Wait()
	fmt.Println("map size:", len(m))
}

修复方式 2:使用 sync.Map

如果场景适合,也可以用标准库的 sync.Map

package main

import (
	"fmt"
	"sync"
)

func main() {
	var m sync.Map
	var wg sync.WaitGroup

	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func(v int) {
			defer wg.Done()
			m.Store(v, v*10)
		}(i)
	}

	wg.Wait()

	count := 0
	m.Range(func(key, value any) bool {
		count++
		return true
	})

	fmt.Println("map size:", count)
}

但要注意,sync.Map 不是“无脑替代所有 map”。它更适合:

  • 读多写少
  • key 生命周期复杂
  • 多 goroutine 高频并发访问
  • 不想手动维护锁粒度

如果你的数据结构稳定、访问模式明确,map + mutex 往往依然是更可控的选择。

3.2 double-close channel

另一个经典问题是:同一个 channel 被关闭两次

错误示例:

package main

import "fmt"

func main() {
	ch := make(chan int)
	close(ch)
	close(ch)
	fmt.Println("unreachable")
}

运行结果:

panic: close of closed channel

为什么会出问题

在 Go 里,close(channel) 表示:

  • 不会再有新的发送了
  • 接收方可以继续把缓冲区里的数据读完
  • 读空后会得到零值和 ok=false

也就是说,close 是一个“状态终结动作”。这个动作只能发生一次。

更隐蔽的 double-close 场景

线上更常见的,不是直接写两个 close(ch),而是多个 goroutine 都认为“自己应该负责关闭 channel”。

package main

import (
	"fmt"
	"sync"
)

func main() {
	ch := make(chan int)
	var wg sync.WaitGroup

	closer := func(name string) {
		defer wg.Done()
		defer func() {
			if r := recover(); r != nil {
				fmt.Printf("%s recovered panic: %v\n", name, r)
			}
		}()
		close(ch)
		fmt.Println(name, "closed channel")
	}

	wg.Add(2)
	go closer("worker-1")
	go closer("worker-2")
	wg.Wait()
}

这段代码中,两个 goroutine 竞争关闭同一个 channel,最终一定会有一个 panic。

正确思路:只约定一个关闭方

channel 设计里非常重要的一条规则是:发送方负责关闭,接收方通常不关闭;多发送方场景下,要明确唯一关闭者。

一个比较稳妥的修复方式,是通过 sync.Once 保证只关闭一次:

package main

import (
	"fmt"
	"sync"
)

func main() {
	ch := make(chan int)
	var once sync.Once
	var wg sync.WaitGroup

	closeSafely := func(name string) {
		defer wg.Done()
		once.Do(func() {
			close(ch)
			fmt.Println(name, "closed channel")
		})
	}

	wg.Add(2)
	go closeSafely("worker-1")
	go closeSafely("worker-2")
	wg.Wait()
}

不过更进一步说,sync.Once 只是兜底手段。真正更好的方案是:在架构层面定义清楚 channel 的拥有者和关闭责任。

3.3 这类并发 bug 的共同特征

这类问题经常有几个共性:

  • 单测覆盖率不低,但没覆盖到关键时序
  • 本地复现概率低,压测和线上更容易暴露
  • 出问题时看起来像偶发 panic,实则是设计约束不清
  • 修复不能只靠 recover,必须修正并发模型

所以,看到并发 panic 时,不要第一反应就是“补一个 recover()”,而应该追问:

  • 谁拥有共享状态?
  • 谁有权限写?
  • 谁负责关闭?
  • 多个 goroutine 之间是否存在清晰边界?

四、goroutine 调度器与 GOMAXPROCS

4.1 为什么并发问题和调度器有关

很多人初学 Go 时容易把 goroutine 理解成“更轻量的线程”,但排查并发问题时,你必须知道:goroutine 的执行顺序和切换时机并不是你能精确控制的。

Go 运行时有自己的调度器,负责把大量 goroutine 映射到较少的系统线程上执行。虽然平时不用手动管理线程,但这并不意味着调度细节可以忽略。

因为很多并发 bug 之所以“偶发”,本质就是:只有在某种调度顺序下才触发。

4.2 先建立一个简化模型:G、M、P

理解 Go 调度器时,经常会提到三个角色:

  • G:goroutine
  • M:machine,实际执行代码的系统线程
  • P:processor,可理解为运行 goroutine 所需的调度上下文

可以先用一个简化理解:

  • goroutine 是任务
  • M 是工人
  • P 是工位
  • M 必须拿到 P,才能执行 G

这不是完整底层实现,但足够帮助我们理解并发排查中的关键现象。

4.3 GOMAXPROCS 是什么

GOMAXPROCS 决定的是:同一时刻最多有多少个 goroutine 可以并行地在 CPU 上执行用户态 Go 代码。

注意区分两个概念:

  • 并发(concurrency):结构上可以同时推进多个任务
  • 并行(parallelism):物理上同一时刻多个任务真的一起跑

GOMAXPROCS=1 并不代表程序没有并发,而是表示同一时刻只有一个 goroutine 真正在 CPU 上执行。

下面是一个演示程序:

package main

import (
	"fmt"
	"runtime"
	"sync"
)

func worker(name string, wg *sync.WaitGroup) {
	defer wg.Done()
	for i := 0; i < 5; i++ {
		fmt.Printf("%s -> %d\n", name, i)
	}
}

func main() {
	runtime.GOMAXPROCS(1)

	var wg sync.WaitGroup
	wg.Add(2)

	go worker("A", &wg)
	go worker("B", &wg)

	wg.Wait()
}

即使设置为 1,你仍然会看到 A、B 两个 goroutine 交替输出。这说明 goroutine 仍然在被调度,只是不能真正多核并行执行。

4.4 GOMAXPROCS 对问题复现的影响

并发问题排查时,GOMAXPROCS 很有帮助,因为它会影响时序。

常见经验如下:

设置方式 可能带来的效果
GOMAXPROCS=1 更容易看清串行化下的调度顺序,某些依赖抢占时机的 bug 反而不容易出现
GOMAXPROCS>1 更容易暴露真正的并行读写问题,例如 map 并发写、共享变量竞争
在压测中切换不同值 可以扩大问题暴露面,帮助发现对调度顺序敏感的代码

很多线上“偶发”问题,本地复现不了,不妨试试:

GOMAXPROCS=1 go test -race ./...

或者:

GOMAXPROCS=8 go test -race ./...

同一份代码在不同调度条件下,暴露出来的问题可能完全不同。

4.5 不要把 time.Sleep 当同步手段

调度器相关问题里,还有一个常见误区:用 time.Sleep 猜测另一个 goroutine “应该已经执行完了”。

错误示例:

package main

import (
	"fmt"
	"time"
)

func main() {
	result := 0

	go func() {
		result = 42
	}()

	time.Sleep(10 * time.Millisecond)
	fmt.Println(result)
}

这段代码即使“很多时候能输出 42”,依然是错误的,因为:

  • 它存在数据竞争
  • 它把正确性建立在“调度器大概会这样执行”的猜测上
  • 机器负载一变、CPU 一忙、GC 一打断,行为就可能不同

应当使用明确同步原语,例如 channel、WaitGroup、mutex、condition、atomic,而不是靠 Sleep 碰运气。

五、案例:线上并发 panic 复盘

下面用一个典型案例,把前面几个知识点串起来。

5.1 事故背景

某服务负责聚合多个下游接口结果,并把成功结果缓存到内存 map 中,方便同一请求生命周期内复用。为了降低接口总耗时,代码把多个下游调用放到 goroutine 并发执行。

上线初期功能正常,但在流量高峰期,服务开始偶发崩溃,日志里主要出现两类信息:

fatal error: concurrent map writes

以及偶发的:

WARNING: DATA RACE

症状特点:

  • 单机压测低并发时难复现
  • 高并发下偶尔触发 panic
  • 出问题后进程直接退出,影响请求成功率

5.2 出问题的实现

下面这段代码是精简后的复现版本,可以直接运行:

package main

import (
	"fmt"
	"sync"
)

type Aggregator struct {
	cache map[string]string
}

func NewAggregator() *Aggregator {
	return &Aggregator{
		cache: make(map[string]string),
	}
}

func (a *Aggregator) FetchAll(keys []string) map[string]string {
	var wg sync.WaitGroup

	for _, key := range keys {
		wg.Add(1)
		go func(k string) {
			defer wg.Done()
			value := "value-for-" + k
			a.cache[k] = value
		}(key)
	}

	wg.Wait()
	return a.cache
}

func main() {
	a := NewAggregator()
	result := a.FetchAll([]string{"user", "order", "coupon", "profile", "cart"})
	fmt.Println(result)
}

问题点很明确:

  • a.cache 是普通 map
  • 多个 goroutine 并发写入 a.cache
  • 没有任何锁保护
  • 在一定并发度下直接触发 concurrent map writes

5.3 为什么测试阶段没及时发现

复盘时经常会问:为什么代码 review 没看出来?为什么测试环境没打出来?

这类问题常见原因包括:

  1. 功能测试只验证结果,不验证并发安全。
  2. 请求量小、CPU 核数少、调度时序单一,问题不容易暴露。
  3. 没有把 go test -race 纳入 CI 或发布前检查。
  4. 大家默认认为“每个 goroutine 写不同 key,应该没事”,忽略了 map 内部结构共享。

这也是并发 bug 最具迷惑性的地方:业务键不同,不代表底层对象不同。

5.4 正确修复

修复方式之一,是在 Aggregator 内部加锁,保护共享 cache:

package main

import (
	"fmt"
	"sync"
)

type Aggregator struct {
	mu    sync.Mutex
	cache map[string]string
}

func NewAggregator() *Aggregator {
	return &Aggregator{
		cache: make(map[string]string),
	}
}

func (a *Aggregator) FetchAll(keys []string) map[string]string {
	var wg sync.WaitGroup

	for _, key := range keys {
		wg.Add(1)
		go func(k string) {
			defer wg.Done()
			value := "value-for-" + k

			a.mu.Lock()
			a.cache[k] = value
			a.mu.Unlock()
		}(key)
	}

	wg.Wait()

	a.mu.Lock()
	defer a.mu.Unlock()

	result := make(map[string]string, len(a.cache))
	for k, v := range a.cache {
		result[k] = v
	}
	return result
}

func main() {
	a := NewAggregator()
	result := a.FetchAll([]string{"user", "order", "coupon", "profile", "cart"})
	fmt.Println(result)
}

5.5 进一步优化:缩小共享状态范围

不过真正高质量的修复,通常不止是“加把锁”。更重要的是回头审视:这份共享状态真的必须共享吗?

如果缓存只服务于当前一次聚合请求,那么更好的办法往往是:

  • 每个 goroutine 先把结果写入自己的局部变量
  • 再通过 channel 回传
  • 或者统一汇总到单 goroutine 中写 map

这样做的好处是:

  • 共享状态更少
  • 锁竞争更低
  • 并发边界更清晰
  • 后续维护风险更小

例如可以改成“生产者并发拉取、消费者串行汇总”的模型:

package main

import (
	"fmt"
	"sync"
)

type item struct {
	key   string
	value string
}

func FetchAll(keys []string) map[string]string {
	result := make(map[string]string)
	ch := make(chan item, len(keys))
	var wg sync.WaitGroup

	for _, key := range keys {
		wg.Add(1)
		go func(k string) {
			defer wg.Done()
			ch <- item{key: k, value: "value-for-" + k}
		}(key)
	}

	go func() {
		wg.Wait()
		close(ch)
	}()

	for it := range ch {
		result[it.key] = it.value
	}

	return result
}

func main() {
	result := FetchAll([]string{"user", "order", "coupon", "profile", "cart"})
	fmt.Println(result)
}

这个版本的关键点在于:

  • goroutine 只负责生产结果
  • 只有主汇总流程写 result map
  • channel 的关闭责任明确,由等待所有 worker 结束的 goroutine 统一关闭

这比“多 goroutine 直接共享写 map”更稳定,也更容易维护。

5.6 复盘结论

这次线上问题的根因,并不是“某一行代码偶然写错”,而是并发模型设计过于松散:

  • 共享状态没有明确保护
  • 写权限边界不清晰
  • 没有把 race detector 纳入日常流程
  • 评审时只看业务逻辑,没有检查并发约束

从稳定性角度看,线上并发 panic 的治理重点,通常不是修某个点,而是建立下面这些习惯:

  1. 共享数据默认假设为不安全,先想同步策略。
  2. channel 设计时明确“谁发送、谁关闭、谁消费”。
  3. 关键并发代码必须配套压测和 -race
  4. 避免用 Sleep、猜测时序、侥幸顺序来维持正确性。
  5. 尽量缩小共享状态,把并发变成“消息传递”,而不是“共同改一份数据”。

六、结语

死锁、数据竞争、并发 panic,看起来像三类问题,实则都指向同一个核心:并发程序的正确性,不能依赖运气,只能依赖约束。

当你排查 Go 并发问题时,可以按下面这个顺序快速建立思路:

  1. 先看现象:是卡死、panic、结果错误,还是偶发波动。
  2. 再看共享对象:map、channel、共享变量、锁、WaitGroup
  3. 再看责任边界:谁写、谁读、谁关、谁等。
  4. 最后用工具验证:go run -racego test -race、压测、goroutine 堆栈。

真正稳定的 Go 并发代码,未必是用了很多高级技巧的代码,而往往是:

  • 共享状态少
  • 所有权清晰
  • 同步方式明确
  • 排查路径可追踪

把这些基本功建立起来,很多线上“玄学问题”,最后都会变成可以解释、可以复现、可以修复的工程问题。


📝 版权声明:本文为原创技术博客,转载请注明出处。

如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!

上一篇

Golang横向:08 CPU 飙高与性能瓶颈分析

下一篇

golang横向:10 稳定性建设体系