Golang凭借goroutine和channel的轻量并发模型,成为很多开发者编写高并发程序的首选语言,但并发场景下如果不注意资源的使用规则,很容易出现死锁问题,导致程序卡住无法继续运行。

什么是Golang中的死锁
死锁指的是两个或两个以上的goroutine在执行过程中,因为争夺资源而陷入互相等待的状态,如果没有外部干预,这些goroutine会永远阻塞下去。在Golang中,死锁不仅会出现在使用互斥锁的场景,也会出现在channel操作不当的场景中。
常见的死锁触发场景
1. 互斥锁使用不当
最常见的是重复加锁,同一个goroutine对一个已经锁定的互斥锁再次加锁,就会直接触发死锁。
package main
import (
"sync"
)
func main() {
var mu sync.Mutex
mu.Lock() // 第一次加锁
mu.Lock() // 第二次加锁,触发死锁
defer mu.Unlock()
}
2. 锁的获取顺序不一致
多个goroutine按照不同的顺序获取多把锁,很容易形成循环等待的死锁场景。
package main
import (
"sync"
"time"
)
func main() {
var mu1, mu2 sync.Mutex
// goroutine1先获取mu1再获取mu2
go func() {
mu1.Lock()
time.Sleep(time.Millisecond)
mu2.Lock()
mu2.Unlock()
mu1.Unlock()
}()
// goroutine2先获取mu2再获取mu1
go func() {
mu2.Lock()
time.Sleep(time.Millisecond)
mu1.Lock()
mu1.Unlock()
mu2.Unlock()
}()
time.Sleep(time.Second)
}
3. channel操作阻塞
无缓冲channel的发送和接收操作必须同时就绪才能完成,如果只有一端操作,就会阻塞形成死锁。另外如果有goroutine在channel上等待,而没有任何goroutine去执行对应的操作,也会触发死锁。
package main
func main() {
ch := make(chan int)
ch <- 1 // 无缓冲channel发送,没有接收方,触发死锁
}
避免死锁的实用方法
1. 规范互斥锁的使用
使用互斥锁时要保证加锁和解锁成对出现,推荐使用defer语句来解锁,避免因为逻辑分支遗漏解锁操作。同时要避免同一个goroutine重复对同一个互斥锁加锁,如果确实需要可重入的场景,可以使用sync.RWMutex或者第三方可重入锁实现。
package main
import (
"sync"
)
func main() {
var mu sync.Mutex
mu.Lock()
defer mu.Unlock() // 使用defer确保解锁,避免遗漏
// 业务逻辑
}
2. 统一多锁的获取顺序
如果程序中需要获取多把锁,所有goroutine都按照固定的顺序获取锁,就可以避免循环等待的问题。比如给锁编号,所有goroutine都按照编号从小到大的顺序获取锁。
package main
import (
"sync"
"time"
)
func main() {
var mu1, mu2 sync.Mutex
// 所有goroutine都先获取mu1再获取mu2
go func() {
mu1.Lock()
defer mu1.Unlock()
time.Sleep(time.Millisecond)
mu2.Lock()
defer mu2.Unlock()
// 业务逻辑
}()
go func() {
mu1.Lock()
defer mu1.Unlock()
time.Sleep(time.Millisecond)
mu2.Lock()
defer mu2.Unlock()
// 业务逻辑
}()
time.Sleep(time.Second)
}
3. 合理使用channel
优先使用带缓冲的channel,避免无缓冲channel的阻塞问题。如果必须使用无缓冲channel,要确保发送和接收操作在不同的goroutine中同时就绪。另外可以使用select语句配合default分支,避免channel操作永久阻塞。
package main
import "fmt"
func main() {
ch := make(chan int, 1) // 带缓冲的channel,发送不会立即阻塞
ch <- 1
fmt.Println(<-ch)
}
package main
import "fmt"
func main() {
ch := make(chan int)
select {
case ch <- 1:
fmt.Println("发送成功")
default:
fmt.Println("发送失败,避免阻塞")
}
}
4. 使用context控制goroutine生命周期
对于长时间运行的goroutine,使用context.Context来传递取消信号,当不需要继续执行时及时取消goroutine,避免goroutine一直阻塞等待资源。
package main
import (
"context"
"fmt"
"time"
)
func worker(ctx context.Context, ch chan int) {
for {
select {
case <-ctx.Done():
fmt.Println("goroutine退出")
return
case <-ch:
fmt.Println("收到数据")
default:
time.Sleep(time.Millisecond)
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
ch := make(chan int)
go worker(ctx, ch)
time.Sleep(time.Second)
cancel() // 取消goroutine,避免其一直阻塞
time.Sleep(time.Millisecond)
}
5. 借助工具检测死锁
Golang内置的竞态检测器可以在一定程度上发现并发问题,运行程序时加上-race参数即可。另外也可以使用go-deadlock等第三方库,在开发阶段提前发现潜在的死锁问题。
// 运行程序时开启竞态检测 // go run -race main.go
总结
避免Golang并发编程中的死锁,核心是要理解goroutine和资源的协作规则,规范锁和channel的使用方式,统一资源获取顺序,同时做好goroutine的生命周期管理。在开发过程中结合工具检测,就能大幅降低死锁出现的概率,写出更稳定的并发程序。