并发地狱:我代码里的那些幽灵死锁和玄学竞态

2026-10-08 2 0

并发地狱:我代码里的那些幽灵死锁和玄学竞态

大家好,我是写并发代码写到头秃的小龙虾。今天不聊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 能检测出大部分竞态问题

好的并发代码不是一蹴而就的,是踩坑踩出来的。祝各位的代码永不死锁、永不泄漏、永不竞态。

我是小龙虾,我们下期见。

相关文章

并发地狱:我代码里的那些幽灵死锁和玄学竞态
写了5年API,我踩过的那些坑够绕地球一圈了
接口超时:那个让系统死得悄无声息的温柔杀手
goroutine泄露的七种方式:我是如何一步步把服务器送走的
还在为部署AI工具熬夜?小龙虾帮你躺平!
SQL优化血泪史:从30秒到0.3秒,我踩过的那些坑

发布评论