Go 并发编程 — 第一部分:理解死锁及其解决方法

科技   2024-11-21 18:32   广东  

在当今时代,并发无处不在。从我们的手机到网页浏览器,再到手表,每一项技术都在某种程度上依赖于并发。那么,什么是并发呢?它是指同时执行多个线程(或进程)。这些进程通常需要共享资源,并可能因此产生竞争,因此我们必须引入一些同步机制,以确保它们能够顺利协作。

Go语言提供了一些出色的工具来处理并发问题,比如Goroutines、Channels和Mutexes(互斥锁),这些将在第二部分中详细介绍。在本文中,我们将讨论理解并发问题时最重要的挑战,展示一个错误的Go程序示例,并解释如何修复它。

并发问题背后的理论

什么是数据竞争?

数据竞争是指多个进程或线程在没有足够同步的情况下访问共享资源,导致行为未定义。更具体地说,数据竞争发生在以下情况下:

  • 并发线程访问共享资源
  • 至少有一个访问是写操作
  • 没有足够的同步机制来保护共享资源

为了理解这一点,我们来看一个现实世界的例子。假设你,Alice(A),在银行账户中有1000美元。你的父亲Bob(B)想要查看你的余额,因为你需要支付大学学费,他想确保你有足够的钱。与此同时,A想要提取500美元,因为她的车坏了,需要修理。由于没有同步机制,余额这一共享资源可能同时成为两者操作的目标。A发起提款操作,同时B读取余额,他看到的是1000美元。操作结束后,B的余额显示为1000美元,而A的余额为500美元。

这是一个数据竞争,因为我们进行了两次访问,其中一次是写操作,并且没有同步机制来保护两者。

什么是死锁?

死锁是指一组线程无限期地等待,因为每个线程都在等待另一个线程持有的资源释放。发生死锁需要满足四个必要条件,称为Coffman条件:

  1. 互斥:两个或多个资源只能被一个线程持有
  2. 持有并等待:一个线程持有一个资源并等待另一个资源释放
  3. 不可抢占:线程不能抢占(中断)另一个线程的执行
  4. 循环等待:一组进程以循环方式相互等待(A -> B, B -> C, C -> A)

Go语言中的死锁程序

var mu1, mu2 sync.Mutex

// Goroutine 1
go func() {
    fmt.Println("Goroutine 1: Locking mu1")
    mu1.Lock()
    defer mu1.Unlock()
    fmt.Println("Goroutine 1: Locked mu1, now locking mu2")
    mu2.Lock()
    defer mu2.Unlock()
    fmt.Println("Goroutine 1: Locked mu2")
}()

// Goroutine 2
go func() {
    fmt.Println("Goroutine 2: Locking mu2")
    mu2.Lock()
    defer mu2.Unlock()
    fmt.Println("Goroutine 2: Locked mu2, now locking mu1")
    mu1.Lock()
    defer mu1.Unlock()
    fmt.Println("Goroutine 2: Locked mu1")
}()

在这段代码中,我们有两个goroutine(轻量级线程,我们将在第二部分详细介绍)将获取两个资源mu1和mu2。这些资源可以通过调用Lock()获取,并通过调用Unlock()释放。

在这个例子中,第一个goroutine尝试获取mu1然后是mu2,而第二个goroutine则尝试先获取mu2然后是mu1。

如果我们运行这段代码,输出将是:

Goroutine 1: Locking mu1
Goroutine 2: Locking mu2
Goroutine 2: Locked mu2, now locking mu1
Goroutine 1: Locked mu1, now locking mu2
fatal error: all goroutines are asleep - deadlock!

如你所见,Golang检测到所有goroutine都处于休眠状态,并抛出一个死锁致命错误。这是因为我们有一个循环依赖:Goroutine 1需要获取mu2以释放mu1,而Goroutine 2需要获取mu1以释放mu2。这是一个循环依赖,会导致死锁。然而,并发程序非常难以调试,因为它们的行为可能会根据执行情况而改变。如果Goroutine 1(或2)执行得非常快并运行完其主体,则不会发生死锁。这种依赖于执行的特性称为竞争条件。

从Coffman条件的角度分析问题:

  • 循环依赖:如上所述,这一点满足
  • 不可抢占:没有实现机制来抢占线程
  • 互斥:mu1和mu2只能被一个线程持有
  • 持有并等待:Goroutine 1和Goroutine 2都在持有一个锁的同时等待另一个锁释放

解除死锁

要解除死锁,必须打破至少一个Coffman条件。例如,我们可以通过确保Goroutine获取线程的顺序相同来打破循环依赖。

var mu1, mu2 sync.Mutex

// Goroutine 1
go func() {
    fmt.Println("Goroutine 1: Locking mu1")
    mu1.Lock()
    defer mu1.Unlock()
    fmt.Println("Goroutine 1: Locked mu1, now locking mu2")
    mu2.Lock()
    defer mu2.Unlock()
    fmt.Println("Goroutine 1: Locked mu2")
}()

// Goroutine 2
go func() {
    fmt.Println("Goroutine 2: Locking mu1")
    mu1.Lock()
    defer mu1.Unlock()
    fmt.Println("Goroutine 2: Locked mu1, now locking mu2")
    mu2.Lock()
    defer mu2.Unlock()
    fmt.Println("Goroutine 2: Locked mu2")
}()

现在这段代码运行得很好!

Goroutine 2: Locking mu1
Goroutine 2: Locked mu1, now locking mu2
Goroutine 2: Locked mu2
Goroutine 1: Locking mu1
Goroutine 1: Locked mu1, now locking mu2
Goroutine 1: Locked mu2

结论

竞争条件和数据竞争可能非常难以调试,这就是为什么在我们的系统中使用并发时需要格外注意的原因。这是我写的第二篇文章,希望你喜欢。如果有任何不对的地方,请随时反馈,如果一切都很好并且你有一些改进的想法,也欢迎在这里分享!

感谢你的关注,我们下期并发系列再见!

源自开发者
专注于提供关于Go语言的实用教程、案例分析、最新趋势,以及云原生技术的深度解析和实践经验分享。
 最新文章