死锁
了解死锁的基本概念。
死锁究竟是什么?
死锁是指一个任务开始等待某个永远不会发生的事件,从而导致其无法继续执行的状态。
操作系统通过多处理和多任务机制来完成进程和任务,并提升工作效率。当两个或多个进程并发运行并相互阻塞对方的执行路径时,这种状态就被称为死锁。
换句话说,死锁是一种进程无法继续推进的状态,因为它无法获得所需的资源。
判定死锁发生的四个必要条件如下:
- 资源在同一时间只能被一个进程使用,即资源具有排他性。
- 每个进程都持有某些资源,同时又在等待另一个进程所持有的资源。
- 进程在完成执行前不会主动释放已占有的资源。
- 所有涉及的进程形成一条等待链,每个进程所持有的资源恰好是下一个进程所请求的。
通过示例理解死锁
我们通过一个简单的例子来理解死锁。
示例 1
假设有两个进程(进程 A 和进程 B)同时运行:
1process A { 2 更新变量 x 3 更新变量 y 4} 5 6process B { 7 更新变量 y 8 更新变量 x 9}
由于两个进程并发执行,进程 A 会先获取变量 x 的锁,而进程 B 会先获取变量 y 的锁。
此时,因为进程 B 已经持有了变量 y 的锁,进程 A 无法继续获取 y 的锁;同理,进程 A 持有 x 的锁,导致进程 B 也无法继续获取 x 的锁。于是两个进程陷入互相等待的状态——即死锁。双方都无法继续执行,将无限期地等待对方释放资源。
示例 2
考虑银行账户之间转账的场景:假设用户 A 正在向用户 B 的账户转账金额 x,与此同时,用户 B 也在向用户 A 的账户转账金额 y。
为了保证数据一致性,线程1需要先对用户 A 的账户加锁,线程2需要对用户 B 的账户加锁。接着,在完成转账前,线程1还需获取用户 B 账户的锁,线程2也需获取用户 A 账户的锁。
但此时,线程1尝试获取用户 B 账户的锁时发现已被线程2持有;而线程2在尝试获取用户 A 账户的锁时,也发现已被线程1占用。结果就是两个线程彼此等待对方释放锁,造成死锁,导致程序无法继续推进。
Go语言中的死锁
下面我们通过几个 Go 语言的例子进一步理解死锁。代码中使用的通道(channel)没有缓冲区(即容量为0)。如果向该通道发送值,但没有其他 goroutine 来接收,则会触发死锁错误。
因发送方导致的死锁
1package main 2 3func main() { 4 goChannel := make(chan int) 5 goChannel <- 1 6}
上述代码会产生如下错误:
1fatal error: all goroutines are asleep - deadlock! 2 3goroutine 1 [chan send]: 4main.main() 5 /usercode/main.go:5 +0x59 6exit status 2
由于没有其他 goroutine 来接收这个发送的值(系统中没有任何可运行的协程来推动程序进展),程序崩溃并报告死锁。
因接收方导致的死锁
1package main 2 3func main() { 4 goChannel := make(chan int) 5 <-goChannel 6}
上述代码会产生如下错误:
1fatal error: all goroutines are asleep - deadlock! 2 3goroutine 1 [chan receive]: 4main.main() 5 /usercode/main.go:5 +0x4f 6exit status 2
主 goroutine 被阻塞。由于该通道的缓冲区大小为零,无法暂存任何值,因此它会一直等待有其他 goroutine 向通道发送数据。但由于没有任何发送操作,程序永远无法继续执行,最终触发死锁错误。
操作系统与Go语言中死锁的相似性
操作系统中的死锁与 Go 语言中的死锁有何相似之处?在操作系统中,死锁通常由资源竞争引起;而在 Go 中,我们并不直接管理传统意义上的“资源分配”。
那么两者为何仍称为“死锁”?根本原因在于:死锁的本质是系统无法继续推进。在 Go 中,如果没有其他 goroutine 接收或发送数据,当前 goroutine 就会永久阻塞,程序无法取得任何进展,这正是死锁的表现。
当程序中所有 goroutine 都处于阻塞状态时,Go 运行时调度器会检测到这一点:它无法调度任何可运行的协程,于是判断系统已进入死锁状态,并抛出相应的错误。
在报告死锁之前,Go 调度器会检查是否存在任何就绪或正在等待特定事件的 goroutine。如果有,就会继续执行;否则,确认无任何进展可能后,便上报死锁错误。