跳转到主要内容

在编写高并发应用程序时使用 Go 并不会阻止您编写具有竞争条件的系统。 这些竞争条件可能会导致您的系统出现意外问题,这些问题既难以调试,有时甚至更难修复。

因此,我们需要能够编写能够以安全的方式并发执行而不影响性能的 Go 程序。 这就是互斥体发挥作用的地方。

在本教程中,我将向您展示一些您可以在自己的 Go 应用程序中遵循的基本方法,这些方法将帮助您保护您的代码免受这些讨厌的竞争条件的影响。

视频教程


本教程以视频格式提供:

https://youtu.be/cjMdUmfzQWs

理论


在深入研究代码之前,让我们快速了解一下理论以及为什么我们需要互斥锁。

因此,互斥锁或互斥是一种机制,它允许我们防止并发进程进入数据的关键部分,而它已经由给定进程执行。

让我们考虑一个例子,我们有一个银行余额和一个从该银行余额存入和提取资金的系统。在单线程同步程序中,这将非常容易。我们可以通过少量的单元测试有效地保证它每次都能按预期工作。

但是,如果我们开始引入多线程,或者在 Go 的案例中引入多个 goroutine,我们可能会开始在代码中看到问题。

  1. 想象一下,我们有一位余额为 1,000 英镑的客户。
  2. 客户将 500 英镑存入他的账户
  3. 一个 goroutine 会看到这笔交易,读取 1,000 英镑的价值并继续将 500 英镑添加到现有余额中。
  4. 然而,与此同时,他的账户被收取了 700 英镑的费用以支付他的抵押贷款。
  5. 第二个过程读取 1,000 英镑的帐户余额,然后第一个过程能够添加 500 英镑的额外存款并从他的帐户中减去 700 英镑。
  6. 客户第二天检查了他的银行余额,并注意到他的余额已降至 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!

希望您发现本教程很有用!如果您有任何意见或反馈,我很乐意在下面的评论部分听到它们。快乐编码!

文章链接