在编写高并发应用程序时使用 Go 并不会阻止您编写具有竞争条件的系统。 这些竞争条件可能会导致您的系统出现意外问题,这些问题既难以调试,有时甚至更难修复。
因此,我们需要能够编写能够以安全的方式并发执行而不影响性能的 Go 程序。 这就是互斥体发挥作用的地方。
在本教程中,我将向您展示一些您可以在自己的 Go 应用程序中遵循的基本方法,这些方法将帮助您保护您的代码免受这些讨厌的竞争条件的影响。
视频教程
本教程以视频格式提供:
https://youtu.be/cjMdUmfzQWs
理论
在深入研究代码之前,让我们快速了解一下理论以及为什么我们需要互斥锁。
因此,互斥锁或互斥是一种机制,它允许我们防止并发进程进入数据的关键部分,而它已经由给定进程执行。
让我们考虑一个例子,我们有一个银行余额和一个从该银行余额存入和提取资金的系统。在单线程同步程序中,这将非常容易。我们可以通过少量的单元测试有效地保证它每次都能按预期工作。
但是,如果我们开始引入多线程,或者在 Go 的案例中引入多个 goroutine,我们可能会开始在代码中看到问题。
- 想象一下,我们有一位余额为 1,000 英镑的客户。
- 客户将 500 英镑存入他的账户
- 一个 goroutine 会看到这笔交易,读取 1,000 英镑的价值并继续将 500 英镑添加到现有余额中。
- 然而,与此同时,他的账户被收取了 700 英镑的费用以支付他的抵押贷款。
- 第二个过程读取 1,000 英镑的帐户余额,然后第一个过程能够添加 500 英镑的额外存款并从他的帐户中减去 700 英镑。
- 客户第二天检查了他的银行余额,并注意到他的余额已降至 300 英镑,因为第二个过程不知道第一笔存款并在完成后覆盖了价值。
显然,如果您是客户,您会非常生气银行以某种方式“丢失”了您的 500 英镑存款并且您会更换银行。
这就是竞争条件的一个例子,如果我们不小心,当我们不仔细保护代码中的关键部分时,我们的并发程序可能会开始出现问题。
一个简单的例子
所以,既然我们知道问题出在哪里,让我们看看如何在 Go 系统中使用互斥锁来修复它。为了在我们的代码中使用互斥锁,我们需要导入同步包。
main.go
package main
import (
"fmt"
"sync"
)
var (
mutex sync.Mutex
balance int
)
func init() {
balance = 1000
}
func deposit(value int, wg *sync.WaitGroup) {
mutex.Lock()
fmt.Printf("Depositing %d to account with balance: %d\n", value, balance)
balance += value
mutex.Unlock()
wg.Done()
}
func withdraw(value int, wg *sync.WaitGroup) {
mutex.Lock()
fmt.Printf("Withdrawing %d from account with balance: %d\n", value, balance)
balance -= value
mutex.Unlock()
wg.Done()
}
func main() {
fmt.Println("Go Mutex Example")
var wg sync.WaitGroup
wg.Add(2)
go withdraw(700, &wg)
go deposit(500, &wg)
wg.Wait()
fmt.Printf("New Balance %d\n", balance)
}
所以,让我们分解一下我们在这里做了什么。在我们的 deposit() 和withdraw() 函数中,我们已经指定第一步应该是使用 mutex.Lock() 方法获取互斥锁。
我们的每个函数都会阻塞,直到它成功获得锁。一旦成功,它将继续进入其读取并随后更新帐户余额的关键部分。一旦每个函数都执行了它的任务,它就会通过调用 mutex.Unlock() 方法来释放锁。
执行此代码时,您应该看到以下输出:
Go Mutex Example
Depositing 500 to account with balance: 1000
Withdrawing 700 from account with balance: 1500
New Balance 800
问题
判断对错:Go 中的数组是否内置了读/写并发保护措施?
避免死锁
在使用会导致死锁的互斥锁时,您需要注意几个场景。死锁是我们代码中的一个场景,由于每个 goroutine 在尝试获得锁时不断阻塞,所以没有任何进展。
确保调用 Unlock()!
如果您正在开发需要此锁的 goroutine,并且它们可以以多种不同的方式终止,那么请确保无论您的 goroutine 如何终止,它始终调用 Unlock() 方法。
如果您在错误时无法 Unlock(),那么您的应用程序可能会陷入死锁,因为其他 goroutine 将无法获得互斥锁上的锁!
调用 Lock() 两次
使用互斥锁进行开发时要记住的一个示例是 Lock() 方法将阻塞,直到它获得锁。您需要确保在开发应用程序时不要对同一个锁调用两次 Lock() 方法,否则您将遇到死锁情况。
deadlock_example.go
package main
import (
"fmt"
"sync"
)
func main() {
var b sync.Mutex
b.Lock()
b.Lock()
fmt.Println("This never executes as we are in deadlock")
}
当我们尝试运行它时,我们应该看到它抛出了一个致命错误:
$ go run deadlock_example.go
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [semacquire]:
sync.runtime_SemacquireMutex(0x40e024, 0x1174ef00, 0x1, 0x40a0d0)
/usr/local/go/src/runtime/sema.go:71 +0x40
sync.(*Mutex).lockSlow(0x40e020, 0x40c130)
/usr/local/go/src/sync/mutex.go:138 +0x120
sync.(*Mutex).Lock(...)
/usr/local/go/src/sync/mutex.go:81
main.main()
/tmp/sandbox563268272/prog.go:13 +0xe0
信号量与互斥量(Semaphore vs Mutex)
如果通道的大小设置为 1,那么您可以使用 Mutex 实现的所有操作都可以通过 Go 中的通道来完成。
但是,所谓的二进制信号量(大小为 1 的信号量/通道)的用例在现实世界中非常普遍,因此仅以互斥体的形式实现它是有意义的。
结论
因此,在本教程中,我们了解了竞态条件的乐趣,以及它们如何对毫无戒心的并发系统造成严重破坏。然后,我们研究了如何使用互斥锁来保护我们免受竞争条件的危害,并确保我们的系统按照我们预期的方式工作,而不管系统中存在多少 goroutine!
希望您发现本教程很有用!如果您有任何意见或反馈,我很乐意在下面的评论部分听到它们。快乐编码!
- 登录 发表评论