并发安全问题
问题
Go 服务中出现数据不一致、panic、诡异的偶发错误,如何排查并发安全问题?
答案
并发安全问题分类
检测工具:Race Detector
Go 内置的竞态检测器是排查数据竞争的第一利器:
# 编译时加 -race
go build -race -o myapp ./...
# 测试时加 -race
go test -race ./...
# 运行带 -race 的程序,发现数据竞争会打印详细栈信息:
# WARNING: DATA RACE
# Goroutine 7 at 0x... wrote to 0x...
# Goroutine 6 at 0x... previously read from 0x...
警告
-race 有额外性能开销(CPU 210x,内存 510x)。建议:
- CI 测试必须开启
-race - 线上不要长期开启,但可以短时开启排查
场景 1:Map 并发读写
// ❌ panic: concurrent map read and map write
var cache = make(map[string]int)
// 多个 goroutine 同时读写
go func() { cache["a"] = 1 }()
go func() { _ = cache["a"] }()
三种修复方案:
// 方案 1: sync.RWMutex(最常用)
var (
mu sync.RWMutex
cache = make(map[string]int)
)
func Get(key string) int {
mu.RLock()
defer mu.RUnlock()
return cache[key]
}
func Set(key string, val int) {
mu.Lock()
defer mu.Unlock()
cache[key] = val
}
// 方案 2: sync.Map(读多写少场景)
var cache sync.Map
func Get(key string) (int, bool) {
val, ok := cache.Load(key)
if !ok {
return 0, false
}
return val.(int), true
}
func Set(key string, val int) {
cache.Store(key, val)
}
// 方案 3: 分片锁(高并发场景)
// 见 CPU 飙高排查中的 ShardedMap 示例
| 方案 | 适用场景 |
|---|---|
sync.RWMutex + map | 通用方案,读写比例均匀 |
sync.Map | 读多写少、key 稳定 |
| 分片锁 | 高并发、需精细控制 |
| Channel | 传递所有权模型 |
场景 2:check-then-act 竞态
// ❌ 竞态条件:检查和操作不是原子的
if _, ok := cache[key]; !ok {
// 多个 goroutine 可能同时进入这里
cache[key] = expensiveCompute(key)
}
// ✅ 修复 1: 加锁保证原子
mu.Lock()
defer mu.Unlock()
if _, ok := cache[key]; !ok {
cache[key] = expensiveCompute(key)
}
// ✅ 修复 2: singleflight 防击穿
var g singleflight.Group
func GetOrCreate(key string) (int, error) {
val, err, _ := g.Do(key, func() (any, error) {
return expensiveCompute(key), nil
})
return val.(int), err
}
场景 3:变量非原子操作
// ❌ 数据竞争:多 goroutine 操作 count
var count int
go func() { count++ }()
go func() { count++ }()
// count 结果不确定
// ✅ 修复 1: atomic
var count atomic.Int64
go func() { count.Add(1) }()
go func() { count.Add(1) }()
// ✅ 修复 2: Mutex(复合操作)
var mu sync.Mutex
mu.Lock()
count++
mu.Unlock()
场景 4:Slice 并发 Append
// ❌ 数据竞争:slice 并发 append 可能丢数据或 panic
var results []int
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
results = append(results, n) // 不安全!
}(i)
}
wg.Wait()
// ✅ 修复 1: 加锁
var mu sync.Mutex
for i := 0; i < 100; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
mu.Lock()
results = append(results, n)
mu.Unlock()
}(i)
}
// ✅ 修复 2: 预分配 + 索引赋值(更快)
results := make([]int, 100)
for i := 0; i < 100; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
results[n] = compute(n) // 每个 goroutine 写不同索引,无竞争
}(i)
}
// ✅ 修复 3: Channel 收集结果
ch := make(chan int, 100)
for i := 0; i < 100; i++ {
go func(n int) {
ch <- compute(n)
}(i)
}
for i := 0; i < 100; i++ {
results = append(results, <-ch)
}
场景 5:闭包变量捕获
// ❌ 经典陷阱:闭包捕获循环变量
for i := 0; i < 5; i++ {
go func() {
fmt.Println(i) // 可能全部打印 5
}()
}
// ✅ 修复(Go 1.22+ 已自动修复)
// Go < 1.22 需要显式传参
for i := 0; i < 5; i++ {
go func(n int) {
fmt.Println(n)
}(i)
}
场景 6:死锁
// ❌ 死锁:两个 goroutine 交叉加锁
var mu1, mu2 sync.Mutex
go func() {
mu1.Lock()
time.Sleep(time.Millisecond)
mu2.Lock() // 等待 goroutine 2 释放 mu2
mu1.Unlock()
mu2.Unlock()
}()
go func() {
mu2.Lock()
time.Sleep(time.Millisecond)
mu1.Lock() // 等待 goroutine 1 释放 mu1
mu2.Unlock()
mu1.Unlock()
}()
// ✅ 修复:统一加锁顺序
// 所有地方都先锁 mu1 再锁 mu2
并发安全最佳实践
| 原则 | 做法 |
|---|---|
| 优先用 Channel | 传递数据所有权,而非共享内存 |
| 最小锁范围 | 只保护需要保护的临界区,不要大锁 |
| 读写锁分离 | 读多写少用 sync.RWMutex |
| 原子操作 | 简单计数器用 atomic |
| 不可变数据 | 只读数据不需要加锁 |
| 测试加 -race | CI 必须开启 -race |
常见面试问题
Q1: 数据竞争(Data Race)和竞态条件(Race Condition)有什么区别?
答案:
- Data Race:两个 goroutine 同时访问同一内存地址,至少一个是写操作,且没有同步。Go race detector 能发现
- Race Condition:程序结果依赖于 goroutine 执行顺序。即使没有 data race(用了锁),逻辑上仍可能有竞态
- 例:check-then-act 即使每步都加锁,如果检查和操作之间释放了锁,仍是竞态
Q2: sync.Map 和 sync.RWMutex+map 怎么选?
答案:
sync.Map适合:key 集合稳定、读远多于写、goroutine 之间 key 不重叠RWMutex+map:通用场景、读写比例均匀、需要遍历- 高并发下
sync.Map读性能更好(内部用了 read-only map + dirty map 分离) - 不确定时先用
RWMutex+map,确认瓶颈再换
Q3: Go 的 race detector 能发现所有并发问题吗?
答案:
- 只能发现 data race,不能发现所有并发 bug
- 不保证能触发所有竞争路径(依赖运行时的实际调度)
- 死锁、饥饿、竞态条件等无法检测
- 必要但不充分,还需要 Code Review + 压测 + 设计上避免
Q4: 为什么 Go 的 map 并发读写会 panic 而不是返回错误结果?
答案:
- Go 1.6+ 在 map 实现中加了并发检测(
hashWriting标志位) - 这是故意设计:crash early,让开发者尽早发现并发 bug
- 如果只是返回错误数据(像 C++),问题会更隐蔽,更难排查