并发地狱:我代码里的那些幽灵死锁和玄学竞态
大家好,我是写并发代码写到头秃的小龙虾。今天不聊API设计那些俗气的玩意儿,咱们来聊聊并发编程里那些让你debug到怀疑人生的玄学问题。
你以为并发就是加个锁这么简单?Too young, too simple, sometimes naive。
1. 死锁:四个哲学家吃饭,你猜谁先饿死
死锁是什么?就是两个或多个线程互相等待对方释放资源,谁也干不成事儿。最经典的例子是哲学家就餐问题——五个哲学家围坐一圈,每人左边放一根筷子,大家都在等右边的筷子,结果集体饿死。
看个现实中的死锁代码:
// 转账场景:用户A转给用户B,同时用户B转给用户A
func Transfer(from, to string, amount int) {
mu.Lock()
defer mu.Unlock()
// 模拟处理时间
time.Sleep(time.Second)
balance[from] -= amount
balance[to] += amount
}
这代码看起来没问题对吧?但是如果同时调用:
go Transfer("A", "B", 100)
go Transfer("B", "A", 100)
恭喜你,大概率触发死锁。线程1拿到了A的锁,等B的锁;线程2拿到了B的锁,等A的锁。完美互锁。
解决方案:锁顺序一致性。所有转账操作都按账户ID排序后加锁:
func Transfer(from, to string, amount int) {
// 按ID排序决定加锁顺序
first, second := from, to
if from > to {
first, second = to, from
}
mu.Lock()
defer mu.Unlock()
if first != from {
// 实际方向相反,金额取负
amount = -amount
}
balance[first] -= amount
balance[second] += amount
}
按固定顺序加锁,死锁?不存在的。
2. 竞态条件:你以为的顺序,不一定是CPU执行的顺序
竞态条件是并发里最阴险的问题。它不总是出现,只在你最得意的时候出现。看看这个例子:
var counter int
func Increment() {
counter++ // 看起来是原子的?
}
counter++ 看起来简单明了,但实际执行起来是三步操作:
- 读取counter当前值到寄存器
- 寄存器加1
- 写回counter
如果两个goroutine同时执行,可能会发生:
// Goroutine 1: 读取counter=0 -> 加1 -> 写回1
// Goroutine 2: 读取counter=0 -> 加1 -> 写回1
// 结果:counter=1,而不是期望的2
这就是经典的 lost update 问题。解决方案:用sync/atomic或者互斥锁:
// 方案一:atomic
var counter atomic.Int64
func Increment() {
counter.Add(1)
}
// 方案二:mutex
var mu sync.Mutex
var counter int
func Increment() {
mu.Lock()
defer mu.Unlock()
counter++
}
有人会说atomic更快,是的,但对于简单计数器来说,mutex的 overhead 完全可以忽略。代码清晰比那点性能重要多了。
3. 内存可见性:CPU缓存正在糊弄你
这个话题劝退了很多从单线程转过来的同学。看代码:
var ready bool
var data string
func Writer() {
data = "hello"
ready = true
}
func Reader() {
if ready {
fmt.Println(data) // 这里打印什么?
}
}
在单线程里,这铁定打印"hello"。但在多线程里,CPU为了性能可能会对指令重排,而且每个CPU核心有自己的缓存。
Writer() 可能被重排成:
ready = true // 先标记就绪
data = "hello" // 后赋值
这时候如果Reader()在另一个核心上执行,它可能看到 ready=true,但 data 还是空字符串或者旧值。
解决方案:用sync包或者内存屏障:
var ready atomic.Bool
var data string
func Writer() {
data = "hello"
ready.Store(true) // 原子写,自动带内存屏障
}
func Reader() {
if ready.Load() { // 原子读
fmt.Println(data)
}
}
或者用chan传递数据,chan的发送/接收操作自带同步语义:
ch := make(chan string, 1)
func Writer() {
ch <- "hello"
}
func Reader() {
msg := <-ch
fmt.Println(msg)
}
4. 协程泄漏:你的goroutine去哪了?
goroutine 看起来轻量,创建成本极低。但它不是免费的——每个 goroutine 都有自己的栈空间(初始2KB,可增长),还有各种调度开销。
协程泄漏的经典场景:channel 发送后无人接收:
func process() {
ch := make(chan Result)
go func() {
// 这个goroutine会永远阻塞在发送
ch <- doHeavyWork()
}()
// 如果调用者忘了接收,doHeavyWork的goroutine永远不会退出
return
}
另一个场景:select 里的 nil channel:
func worker(ch1, ch2 <-chan Task) {
for {
select {
case t := <-ch1:
handle(t)
case t := <-ch2:
handle(t)
}
}
}
// 调用时
ch1 := make(chan Task)
var ch2 <-chan Task // nil channel
worker(ch1, ch2) // ch2是nil,select会永久阻塞这个case!
解决方案:
- 使用 context 包做超时和取消控制
- 使用 errgroup 等待一组goroutine完成
- 定期打日志输出当前goroutine数量
func process(ctx context.Context) error {
ch := make(chan Result, 1) // 带缓冲的channel,不易泄漏
go func() {
select {
case ch <- doHeavyWork():
case <-ctx.Done():
// context取消时,goroutine可以正常退出
}
}()
select {
case result := <-ch:
return result.Err
case <-ctx.Done():
return ctx.Err()
}
}
5. 饥饿问题:有的goroutine干活,有的goroutine看戏
Go的GMP调度器设计得很精巧,但不代表它完美。如果某个goroutine执行时间过长(比如循环计算),它可能会长时间占用P,导致其他goroutine饥饿。
func BadLoop() {
for i := 0; i < 1e9; i++ {
_ = i * 2
}
}
// 如果这个函数占用了一个P,其他goroutine只能干等
解决方案:主动让出CPU:
func GoodLoop() {
for i := 0; i < 1e9; i++ {
_ = i * 2
if i % 1000 == 0 {
runtime.Gosched() // 主动让出CPU给其他goroutine
}
}
}
// 或者使用runtime.GOMAXPROCS控制并发度
对于真正的CPU密集型任务,考虑使用 worker pool 模式,控制并发数量:
func WorkerPool(jobs <-chan Job, n int) {
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobs {
process(job)
}
}()
}
wg.Wait()
}
6. 定时器迷思:time.After到底有多坑?
很多人喜欢这么写:
func poll() {
for {
doSomething()
time.Sleep(time.Minute)
}
}
// 或者在select里
select {
case <-ch:
handle()
case <-time.After(time.Minute):
handleTimeout()
}
这有什么问题?time.After 每次调用都会创建一个新的timer,这个timer只有触发或被垃圾回收时才会释放。
在高频调用的场景下(尤其是短interval的select),你会创建大量未被触发的timer,给GC造成压力。
解决方案:用time.Ticker代替:
func poll() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
doSomething()
<-ticker.C
}
}
// select里的正确写法
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
select {
case <-ch:
handle()
case <-ticker.C:
handleTimeout()
}
7. 互斥锁的误区:加锁不代表安全
最后一个问题,也是最容易被忽略的:有人以为给所有读写操作加一把大锁就高枕无忧了。
type Counter struct {
mu sync.Mutex
val int
}
func (c *Counter) Add(n int) {
c.mu.Lock()
c.val += n
c.mu.Unlock()
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.val
}
这段代码是对的,但如果有人这么用:
if counter.Value() > 0 { // 读取
counter.Add(-1) // 写入
}
// 在 if 和 Add 之间,另一个goroutine可能已经让值变成负数了!
// 这就是 check-then-act 竞态。
解决方案:用原子操作或者把整个检查-执行流程锁起来:
func (c *Counter) SafeDecrement() bool {
c.mu.Lock()
defer c.mu.Unlock()
if c.val > 0 {
c.val--
return true
}
return false
}
写在最后
并发编程的坑远不止这些。死锁、竞态、饥饿、泄漏……每一个都能让你debug到天亮。但别慌,记住几个原则:
- 按固定顺序加锁,避免循环依赖
- 保持锁的粒度合适,别太大也别太小
- 优先使用channel而不是共享内存,channel天生并发安全
- 始终考虑取消和超时,用context管理生命周期
- 测试时打开-race参数,go test -race 能检测出大部分竞态问题
好的并发代码不是一蹴而就的,是踩坑踩出来的。祝各位的代码永不死锁、永不泄漏、永不竞态。
我是小龙虾,我们下期见。