Go 语言把并发能力做成了一等公民:goroutine 足够轻量,channel 语义清晰,标准库也提供了 sync、context、atomic 等成熟工具。但越是容易写并发,越容易写出“看起来能跑、压力一上来就出事”的代码。
线上稳定性问题里,死锁、数据竞争、并发 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 send 或 chan 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 很好用,但也要知道它的边界:
- 它只能发现“被执行到”的竞争路径,不能证明所有路径都安全。
- 它会带来明显性能开销,因此通常用于开发、测试、预发,而不是直接常驻生产。
- 它可能改变程序时序,让某些竞态更容易暴露,也可能让极端时序问题不再稳定复现。
因此,正确姿势不是“跑一次 -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 没看出来?为什么测试环境没打出来?
这类问题常见原因包括:
- 功能测试只验证结果,不验证并发安全。
- 请求量小、CPU 核数少、调度时序单一,问题不容易暴露。
- 没有把
go test -race纳入 CI 或发布前检查。 - 大家默认认为“每个 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 只负责生产结果
- 只有主汇总流程写
resultmap - channel 的关闭责任明确,由等待所有 worker 结束的 goroutine 统一关闭
这比“多 goroutine 直接共享写 map”更稳定,也更容易维护。
5.6 复盘结论
这次线上问题的根因,并不是“某一行代码偶然写错”,而是并发模型设计过于松散:
- 共享状态没有明确保护
- 写权限边界不清晰
- 没有把 race detector 纳入日常流程
- 评审时只看业务逻辑,没有检查并发约束
从稳定性角度看,线上并发 panic 的治理重点,通常不是修某个点,而是建立下面这些习惯:
- 共享数据默认假设为不安全,先想同步策略。
- channel 设计时明确“谁发送、谁关闭、谁消费”。
- 关键并发代码必须配套压测和
-race。 - 避免用
Sleep、猜测时序、侥幸顺序来维持正确性。 - 尽量缩小共享状态,把并发变成“消息传递”,而不是“共同改一份数据”。
六、结语
死锁、数据竞争、并发 panic,看起来像三类问题,实则都指向同一个核心:并发程序的正确性,不能依赖运气,只能依赖约束。
当你排查 Go 并发问题时,可以按下面这个顺序快速建立思路:
- 先看现象:是卡死、panic、结果错误,还是偶发波动。
- 再看共享对象:map、channel、共享变量、锁、
WaitGroup。 - 再看责任边界:谁写、谁读、谁关、谁等。
- 最后用工具验证:
go run -race、go test -race、压测、goroutine 堆栈。
真正稳定的 Go 并发代码,未必是用了很多高级技巧的代码,而往往是:
- 共享状态少
- 所有权清晰
- 同步方式明确
- 排查路径可追踪
把这些基本功建立起来,很多线上“玄学问题”,最后都会变成可以解释、可以复现、可以修复的工程问题。
📝 版权声明:本文为原创技术博客,转载请注明出处。
如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!