你有没有遇到过这样的情况:Go 程序运行到一半突然卡住,日志不输出、接口没响应,查看进程发现 CPU 占用几乎为 0,重启后又正常?十有八九,这是遇到了「死锁」。

死锁就像两个人在狭窄的走廊里碰面,都想让对方先让开,结果谁也动不了 —— 程序中的 goroutine 也会因为「互相等待资源」陷入这种僵局,最终整个流程卡死。

今天这篇文章,我们用大白话 + 代码例子,彻底搞懂 Go 中死锁的「4 个必要条件」,拆解 5 个高频死锁场景,再给出 6 个实用避坑方法,让你不仅能看懂死锁,还能从根源避免它。

一、先搞懂:死锁到底是什么?

在讲技术细节前,先举个生活中的例子:

假设你和朋友去餐厅吃饭,需要「筷子 + 勺子」才能用餐。你手里拿着唯一的筷子,等着朋友手里的勺子;朋友手里拿着唯一的勺子,等着你的筷子 —— 你们俩都拿着一个资源,又等着对方的资源,永远等不到结果,这就是死锁。

对应到 Go 程序中,「你和朋友」就是 goroutine,「筷子和勺子」就是程序中的资源(比如锁、通道、文件句柄)。当多个 goroutine 互相持有对方需要的资源,且都不释放自己的资源时,就会陷入永远等待的僵局,这就是死锁。

死锁的核心特征是:程序卡住不推进,无错误日志,资源(CPU、内存)占用低—— 因为 goroutine 都在等待,没做实际工作。

二、死锁的 4 个必要条件:少一个都不会发生

死锁不是随便就能发生的,它必须同时满足 4 个条件(这是计算机科学中的经典理论,对 Go 同样适用)。只要破坏其中任意一个条件,死锁就不会发生。

我们逐个拆解这 4 个条件,每个条件都配 Go 代码例子,让你一看就懂。

条件 1:互斥条件 —— 资源只能「独占使用」

定义:某个资源只能被一个 goroutine 占用,其他 goroutine 要使用,必须等当前占用者释放。

就像厕所隔间,一次只能一个人用;Go 中的 sync.Mutex(互斥锁)就是典型的「互斥资源」—— 一个 goroutine 加锁后,其他 goroutine 必须等它解锁才能加锁。

代码例子

package main

import (
   "sync"
   "time"
)

func main() {
   var mu sync.Mutex // 互斥锁(互斥资源)
   // goroutine1 占用锁
   go func() {
       mu.Lock()         // 加锁:占用资源
       defer mu.Unlock() // 退出时解锁:释放资源
       time.Sleep(3 * time.Second) // 模拟占用3秒
       println("goroutine1:释放锁")
   }()

   // 主goroutine 等待后尝试获取锁
   time.Sleep(1 * time.Second)
   mu.Lock()         // 此时goroutine1还没解锁,主goroutine会阻塞
   defer mu.Unlock()
   println("主goroutine:获取到锁")

}

说明:这个例子中,互斥锁满足「互斥条件」,但不会死锁 —— 因为主 goroutine 只是「等待资源」,没有持有其他资源,等 goroutine1 释放锁后,主 goroutine 就能获取到。但如果加上其他条件,就可能触发死锁。

条件 2:持有并等待条件 —— 拿着资源等其他资源

定义:一个 goroutine 已经持有了至少一个资源,还在等待获取其他 goroutine 持有的资源,且不释放自己已持有的资源。

还是用餐厅的例子:你拿着筷子(已持有资源),还在等朋友的勺子(等待其他资源),且不把筷子给朋友 —— 这就是「持有并等待」。

代码例子(结合条件 1,仍未死锁):

package main

import (
   "sync"
   "time"
)

func main() {
   var mu1, mu2 sync.Mutex // 两个互斥锁(两个资源)
   // goroutine1:持有mu1,等待mu2

   go func() {
       mu1.Lock()         // 已持有mu1
       defer mu1.Unlock()

       time.Sleep(1 * time.Second) // 模拟占用mu1
       mu2.Lock()         // 等待mu2(此时mu2未被占用)
       defer mu2.Unlock()
       println("goroutine1:获取到两个锁")

   }()

   // 主goroutine:直接获取mu2(不持有其他资源)
   time.Sleep(2 * time.Second) // 等goroutine1释放mu1
   mu2.Lock()
   defer mu2.Unlock()
   println("主goroutine:获取到mu2")
}

说明:goroutine1 持有 mu1 并等待 mu2,但主 goroutine 没有持有任何资源,只是获取 mu2—— 所以不会死锁。但如果主 goroutine 也「持有资源并等待」,就会触发下一个条件。

条件 3:不可剥夺条件 —— 资源不能「强行抢走」

定义:一个 goroutine 持有的资源,不能被其他 goroutine 强行剥夺,只能由持有它的 goroutine 主动释放。

就像你拿着筷子,别人不能直接从你手里抢走,只能等你主动放下;Go 中的互斥锁也满足这个条件 —— 一个 goroutine 加锁后,其他 goroutine 不能强行解锁,只能等它自己调用 Unlock()

为什么这是死锁条件:如果资源可以被强行剥夺,比如系统能把你手里的筷子抢过来给朋友,朋友就能先用勺子 + 筷子吃饭,吃完再把筷子还你,就不会陷入僵局。但 Go 中没有这种「强行剥夺」机制,所以这个条件默认满足。

条件 4:循环等待条件 —— 互相等待对方的资源

定义:多个 goroutine 形成一个循环,每个 goroutine 都在等待下一个 goroutine 持有的资源。

比如:goroutineA 持有资源 1,等资源 2;goroutineB 持有资源 2,等资源 1—— 两者形成循环等待,永远等不到结果。

代码例子(4 个条件全满足,触发死锁):

package main

import (
   "sync"
   "time"
)

func main() {
   var mu1, mu2 sync.Mutex // 两个互斥资源
   // goroutine1:持有mu1,等mu2(条件2)
   go func() {
       mu1.Lock()
       defer mu1.Unlock()
       time.Sleep(1 * time.Second) // 确保先持有mu1
       // 等待mu2:此时mu2已被goroutine2持有
       mu2.Lock()
       defer mu2.Unlock()
       println("goroutine1:获取到两个锁(不会执行)")
   }()

   // goroutine2:持有mu2,等mu1(条件2)

   go func() {
       mu2.Lock()
       defer mu2.Unlock()
       time.Sleep(1 * time.Second) // 确保先持有mu2

       // 等待mu1:此时mu1已被goroutine1持有
       mu1.Lock()
       defer mu1.Unlock()

       println("goroutine2:获取到两个锁(不会执行)")
   }()

   // 主goroutine等待,防止程序退出
   select {} // 主goroutine阻塞,程序卡住(死锁)
}

运行结果:程序会卡住,日志什么都不输出 —— 因为 4 个条件全满足:

  1. 互斥:mu1 和 mu2 都是互斥锁;

  2. 持有并等待:两个 goroutine 都拿着一个锁等另一个;

  3. 不可剥夺:锁不能被强行抢走;

  4. 循环等待:goroutine1 等 mu2(goroutine2 持有),goroutine2 等 mu1(goroutine1 持有)。

这就是一个典型的 Go 死锁案例 —— 也是实际开发中最容易遇到的死锁类型。

三、Go 中 5 个高频死锁场景:看完少踩坑

知道了死锁的 4 个条件后,我们来看看 Go 开发中最常遇到的 5 个死锁场景,每个场景都告诉你「为什么会发生」和「怎么改」。

场景 1:无缓冲通道「只发不收」或「只收不发」

原理:Go 中的无缓冲通道(chan T)有个特性:发送方会阻塞,直到有接收方接收;接收方会阻塞,直到有发送方发送。如果只有发送没有接收(或反之),goroutine 会永远阻塞,导致死锁。

死锁代码(只发不收):

package main

func main() {
   ch := make(chan int) // 无缓冲通道

   // 主goroutine发送数据,但没有接收方
   ch <- 10 // 发送方阻塞,程序卡住(死锁)

   println("数据发送成功(不会执行)")
}

为什么死锁:无缓冲通道需要「发送方和接收方同时就绪」才能完成通信。这个例子中只有发送方,没有接收方,主 goroutine 会永远阻塞在 ch <- 10,满足「循环等待」(主 goroutine 等接收方,但没有接收方)。

修复方法:添加接收方(启动一个 goroutine 接收):

package main

func main() {
   ch := make(chan int)

   // 启动接收方goroutine
   go func() {
       data := <-ch // 接收数据
       println("接收方:收到数据", data)
   }()

   ch <- 10 // 发送方:此时有接收方,不会阻塞
   println("发送方:数据发送成功")

}

场景 2:goroutine 互相等待通道数据

原理:两个 goroutine 互相给对方的通道发数据,且都先发送再接收 —— 导致双方都阻塞在发送步骤,形成循环等待。

死锁代码

package main

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

   // goroutine1:先给ch2发数据,再从ch1收数据
   go func() {
       ch2 <- 20 // 步骤1:发ch2,等待goroutine2接收
       data := <-ch1 // 步骤2:收ch1(永远执行不到)
       println("goroutine1:收到", data)
   }()

   // goroutine2:先给ch1发数据,再从ch2收数据
   go func() {
       ch1 <- 10 // 步骤1:发ch1,等待goroutine1接收
       data := <-ch2 // 步骤2:收ch2(永远执行不到)
       println("goroutine2:收到", data)
   }()

   select {} // 主goroutine阻塞

}

为什么死锁:goroutine1 阻塞在 ch2 <- 20(等 goroutine2 收),goroutine2 阻塞在 ch1 <- 10(等 goroutine1 收)—— 双方都持有「发送动作」,等待对方的「接收动作」,形成循环等待。

修复方法:调整顺序,先接收再发送(或用带缓冲通道):

package main

func main() {
   ch1 := make(chan int)
   ch2 := make(chan int)
   // goroutine1:先收ch1,再发ch2
   go func() {
       data := <-ch1 // 先接收(等goroutine2发)
       println("goroutine1:收到", data)
       ch2 <- 20     // 再发送(此时goroutine2已准备接收)
   }()

   // goroutine2:先发ch1,再收ch2
   go func() {
       ch1 <- 10     // 先发送(goroutine1已准备接收)
       data := <-ch2 // 再接收(等goroutine1发)
       println("goroutine2:收到", data)
   }()

   time.Sleep(1 * time.Second) // 等goroutine执行完
}

场景 3:忘记释放锁(Mutex/RWMutex)

原理sync.Mutex 加锁后,如果因为「提前 return」「panic」等原因没解锁,其他 goroutine 会永远等待这个锁,导致死锁。

死锁代码(提前 return 没解锁):

package main

import (
   "sync"
   "time"
)

func main() {
   var mu sync.Mutex
   var count int
   // goroutine1:加锁后提前return,没解锁

   go func() {
       mu.Lock()
       if count > 0 { // 假设count初始为0,不满足条件,但如果条件满足...
           // 错误:这里return会导致锁没释放!
           return
       }
       count++
       mu.Unlock() // 只有count<=0时才会解锁
       println("goroutine1:执行完成")

   }()

   // goroutine2:等待锁,但永远等不到(如果goroutine1提前return)
   time.Sleep(1 * time.Second)
   mu.Lock() // 阻塞,程序卡住
   defer mu.Unlock()
   count++
   println("goroutine2:执行完成(不会执行)")

}

为什么死锁:如果 goroutine1 因为条件满足提前 return,会跳过 mu.Unlock(),导致锁永远被持有。goroutine2 尝试加锁时会永远阻塞,满足「持有并等待」和「循环等待」(goroutine2 等锁,锁被 goroutine1 持有且不释放)。

修复方法:用 defer 确保解锁(defer 会在函数退出前执行,无论是否 return):

go func() {
   mu.Lock()
   defer mu.Unlock() // 关键:提前加defer,确保解锁
   if count > 0 {
       return // 即使return,defer也会解锁
   }
   count++
   println("goroutine1:执行完成")
}()

场景 4:sync.WaitGroup 计数不匹配

原理sync.WaitGroup 用于等待多个 goroutine 完成 ——Add(n) 设为 n 个 goroutine,每个 goroutine 执行完调用 Done()(相当于计数 - 1),主 goroutine 调用 Wait() 等待计数到 0。如果「计数多了」(Add 多了,Done 少了),Wait() 会永远阻塞,导致死锁。

死锁代码(计数多了):

package main

import (
   "sync"
   "time"
)

func main() {
   var wg sync.WaitGroup
   // 错误:Add(3),但只启动2个goroutine
   wg.Add(3)
   // goroutine1:执行完调用Done()
   go func() {
       defer wg.Done()
       time.Sleep(1 * time.Second)
       println("goroutine1:完成")
   }()

   // goroutine2:执行完调用Done()
   go func() {
       defer wg.Done()
       time.Sleep(1 * time.Second)
       println("goroutine2:完成")
   }()

   // 主goroutine等待计数到0,但实际计数会停在1(3-2=1)
   wg.Wait() // 永远阻塞,程序卡住(死锁)
   println("所有goroutine完成(不会执行)")

}

为什么死锁wg.Add(3) 表示要等 3 个 goroutine 完成,但只启动了 2 个,Done() 只调用 2 次,wg.Wait() 会永远等待第 3 个 Done(),导致主 goroutine 阻塞。

修复方法:确保 Add(n) 的 n 与 goroutine 数量一致:

// 正确:Add(2),与goroutine数量匹配

wg.Add(2)

或在每个 goroutine 启动前调用 Add(1)(更安全,避免计数错误):

// 更安全的写法:每个goroutine启动前Add(1)
go func() {
   wg.Add(1)
   defer wg.Done()
   // ...
}()

场景 5:循环等待多个锁(最经典的死锁)

原理:多个 goroutine 按不同顺序申请多个锁,形成循环等待 —— 这就是我们在「死锁 4 个条件」中举的例子,也是实际开发中最容易犯的错(比如处理订单时,同时申请「用户锁」和「订单锁」)。

死锁代码(不同顺序申请锁):

package main

import (
   "sync"
   "time"
)

func main() {
   var userLock sync.Mutex   // 用户锁
   var orderLock sync.Mutex  // 订单锁
   // goroutine1:先申请用户锁,再申请订单锁
   go func() {
       userLock.Lock()
       defer userLock.Unlock()

       time.Sleep(1 * time.Second) // 确保先拿到用户锁

       orderLock.Lock()
       defer orderLock.Unlock()

       println("goroutine1:处理用户+订单(不会执行)")

   }()

   // goroutine2:先申请订单锁,再申请用户锁(顺序相反)
   go func() {
       orderLock.Lock()
       defer orderLock.Unlock()

       time.Sleep(1 * time.Second) // 确保先拿到订单锁

       userLock.Lock()
       defer userLock.Unlock()

       println("goroutine2:处理订单+用户(不会执行)")

   }()

   select {} // 主goroutine阻塞
}

为什么死锁:goroutine1 持有用户锁等订单锁,goroutine2 持有订单锁等用户锁 —— 按不同顺序申请锁,形成循环等待,4 个死锁条件全满足。

修复方法:所有 goroutine 按「固定顺序」申请锁(比如先申请用户锁,再申请订单锁):

// goroutine2:调整顺序,先申请用户锁,再申请订单锁(与goroutine1一致)

go func() {

   userLock.Lock() // 先申请用户锁
   defer userLock.Unlock()

   time.Sleep(1 * time.Second)

   orderLock.Lock() // 再申请订单锁
   defer orderLock.Unlock()

   println("goroutine2:处理订单+用户(正常执行)")
}()

四、6 个实用方法:从根源避免死锁

知道了死锁的条件和场景后,我们来总结 6 个可落地的避坑方法 —— 本质就是「破坏死锁的 4 个必要条件」,让死锁无法发生。

方法 1:按固定顺序申请资源(破坏「循环等待」)

这是避免「多锁死锁」最有效的方法 —— 所有 goroutine 申请多个资源时,都按同一个固定顺序(比如按资源的 ID 从小到大、按锁的变量名排序)。

比如处理「用户 + 订单」时,无论哪个 goroutine,都先申请「用户锁」,再申请「订单锁」—— 这样就不会形成循环等待。

代码例子(固定顺序申请锁):

// 规则:所有goroutine都先申请userLock,再申请orderLock
func processUserAndOrder(userLock, orderLock *sync.Mutex) {
   userLock.Lock()         // 固定顺序1:用户锁
   defer userLock.Unlock()

   time.Sleep(1 * time.Second)

   orderLock.Lock()        // 固定顺序2:订单锁
   defer orderLock.Unlock()

   println("处理用户和订单")
}

func main() {
   var userLock, orderLock sync.Mutex

   go processUserAndOrder(&userLock, &orderLock)
   go processUserAndOrder(&userLock, &orderLock) // 同一顺序,不会死锁

   time.Sleep(3 * time.Second)
}

方法 2:用带缓冲通道(破坏「持有并等待」)

无缓冲通道需要「发送方和接收方同时就绪」,容易因为「只发不收」或「互相等待」导致死锁。而带缓冲通道(chan Tmake(chan T, n))有一个「缓冲区」,发送方在缓冲区未满时不会阻塞,接收方在缓冲区非空时不会阻塞 —— 可以避免很多通道死锁。

代码例子(用带缓冲通道修复「互相等待」):

package main

func main() {
   // 带缓冲通道(缓冲区大小1)
   ch1 := make(chan int, 1)
   ch2 := make(chan int, 1)
   // goroutine1:发ch2,再收ch1
   go func() {
       ch2 <- 20 // 带缓冲,即使goroutine2没接收,也不会阻塞(缓冲区未满)
       data := <-ch1

       println("goroutine1:收到", data)
   }()

   // goroutine2:发ch1,再收ch2
   go func() {
       ch1 <- 10 // 带缓冲,不会阻塞
       data := <-ch2

       println("goroutine2:收到", data)

   }()

   time.Sleep(1 * time.Second)
}

说明:带缓冲通道的缓冲区相当于「临时存储区」,打破了「发送方必须等待接收方」的限制,从而破坏「持有并等待」条件。

方法 3:用 context 设置超时(破坏「持有并等待」)

如果 goroutine 持有资源并等待其他资源时,能在「超时后主动释放资源」,就不会永远等待 ——Go 中的 context.WithTimeout 可以实现这个功能:超时后,goroutine 主动解锁或放弃等待,释放已持有的资源。

代码例子(超时释放锁):

package main

import (
   "context"
   "sync"
   "time"
)

func main() {
   var mu1, mu2 sync.Mutex
   // goroutine1:持有mu1,等待mu2,超时后释放mu1

   go func() {
       mu1.Lock()
       defer mu1.Unlock()

       // 创建1秒超时的context
       ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
       defer cancel()

       // 尝试获取mu2,超时则放弃
       select {
       case <-ctx.Done():
           // 超时:释放mu1(defer会执行),不再等待mu2
           println("goroutine1:等待mu2超时,释放mu1")
           return

       default:
           // 尝试获取mu2(如果获取不到,会阻塞,直到超时)
           mu2.Lock()
           defer mu2.Unlock()

           println("goroutine1:获取到两个锁")

       }
   }()

   // goroutine2:持有mu2,等待mu1,超时后释放mu2

   go func() {
       mu2.Lock()
       defer mu2.Unlock()

       ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
       defer cancel()

       select {
       case <-ctx.Done():
           println("goroutine2:等待mu1超时,释放mu2")
           return

       default:
           mu1.Lock()
           defer mu1.Unlock()

           println("goroutine2:获取到两个锁")
       }
   }()

   time.Sleep(2 * time.Second)
}

说明:超时后,goroutine 会主动放弃等待,释放已持有的锁 —— 打破了「持有并等待」条件,避免死锁。

方法 4:避免 goroutine 泄漏(防止「资源永远被持有」)

goroutine 泄漏(启动的 goroutine 永远不退出)会导致它持有的资源(锁、通道)永远不释放,其他 goroutine 会永远等待这些资源,最终导致死锁。

比如:

  • 启动的 goroutine 里有无限循环,且没有退出机制;

  • 通道接收方永远等不到数据(发送方退出)。

避免方法

  1. 给每个 goroutine 加退出机制(用 context 或退出通道);

  2. sync.WaitGroup 确保所有 goroutine 都能退出;

  3. 避免在 goroutine 里写无限循环,除非有明确的退出条件。

代码例子(用 context 避免 goroutine 泄漏):

package main

import (
   "context"
   "time"
)

func main() {
   // 创建可取消的context
   ctx, cancel := context.WithCancel(context.Background())
   defer cancel()

   // 启动goroutine,有退出机制

   go func() {
       for {
           select {
           case <-ctx.Done():
               // 收到退出信号,主动退出(避免泄漏)
               println("goroutine:收到退出信号,退出")
               return
           default:
               // 模拟业务逻辑
               time.Sleep(500 * time.Millisecond)
               println("goroutine:处理业务")
           }
       }
   }()

   // 主goroutine运行2秒后退出

   time.Sleep(2 * time.Second)
   println("主goroutine:退出")

}

方法 5:用工具提前检测死锁风险

Go 提供了多个工具,可以在开发阶段检测死锁风险,不用等到线上出问题才排查:

(1)race 检测器(检测数据竞争和死锁)

go run -race 运行程序,会检测数据竞争(数据竞争可能导致死锁)和部分死锁场景:

go run -race main.go

(2)go vet(静态分析工具)

go vet 会检查代码中的常见错误,比如「无缓冲通道只发不收」「sync.WaitGroup 计数不匹配」等:

go vet main.go

(3)pprof(排查已发生的死锁)

如果程序已经死锁,可以用 pprof 查看 goroutine 的状态,找到阻塞的 goroutine:

# 1. 先找到程序的PID

ps aux | grep 程序名

# 2. 用pprof查看goroutine状态

go tool pprof -goroutine 程序名 PID

运行后输入 web,会生成 goroutine 状态图,清晰看到哪些 goroutine 阻塞在哪个资源上。

方法 6:尽量减少资源持有时间

goroutine 持有资源的时间越短,其他 goroutine 等待的时间就越短,死锁的概率也越低。

实践建议

  1. 锁的粒度要小:只在需要保护数据的代码段加锁,不要整个函数加锁;

  2. 尽快释放资源:获取锁后,尽快执行完临界区代码,不要在锁内做耗时操作(比如网络请求、睡眠);

  3. 避免在锁内调用外部函数:外部函数可能有未知逻辑(比如又加了其他锁),增加死锁风险。

代码例子(减小锁粒度):

// 不好的写法:整个函数加锁,持有时间长

func badExample(mu *sync.Mutex, data []int) {
   mu.Lock()
   defer mu.Unlock()

   // 耗时操作(不应该在锁内)

   time.Sleep(1 * time.Second)

   // 实际需要保护的代码(只有这一行)
   data[0] = 100
}

// 好的写法:只在需要保护的代码段加锁

func goodExample(mu *sync.Mutex, data []int) {

   // 耗时操作(在锁外)
   time.Sleep(1 * time.Second)

   // 只在修改数据时加锁,持有时间短

   mu.Lock()
   data[0] = 100
   mu.Unlock()
}

五、总结:死锁避坑的核心原则

死锁虽然看起来可怕,但只要记住「4 个必要条件」和「6 个避坑方法」,就能从根源避免:

  1. 理解死锁条件:死锁必须同时满足「互斥、持有并等待、不可剥夺、循环等待」,破坏任意一个就不会发生;

  2. 通道使用注意:无缓冲通道要确保「有发有收」,复杂场景用带缓冲通道;

  3. 锁的使用规范:按固定顺序申请锁,用 defer 确保解锁,减小锁粒度;

  4. 工具提前检测:开发阶段用 racego vet 检测风险,线上用 pprof 排查问题;

  5. goroutine 有退出机制:用 context 或退出通道,避免 goroutine 泄漏。

死锁是 Go 并发编程中常见的问题,但通过理解其产生条件和采取适当的预防措施,我们可以有效地避免它。

记住,良好的程序设计和仔细的资源管理是预防死锁的最有效方法。在编写并发代码时,始终保持警惕,确保资源访问顺序的一致性,并使用适当的工具检测潜在问题。