在本教程中,我们将了解互斥锁。我们还将学习如何使用互斥锁和 channel 来解决竞争条件。

临界区

在进入到互斥锁之前,了解并发编程中 critical section(临界区)的概念非常重要。当程序同时运行时,多个 Goroutines 不能同时访问修改共享资源的代码部分。修改共享资源的这部分代码称为临界区。例如,假设我们有一段代码将变量 x 递增 1。

  1. x = x + 1

上面的代码被一个 Goroutine 访问,就不会有任何问题。

让我们看看为什么当有多个 goroutine 同时运行时,这段代码会失败。为了简单起见,我们假设有两个 goroutine 同时运行上述代码。

上面的代码行将由系统执行以下步骤(其实有更多技术细节,比如寄存器、加法是如何工作的这些。但是出于本教程的考虑,我们假设这只有三个步骤)

  1. 获取x的当前值

  2. 计算 x + 1

  3. 将步骤2中的计算值赋给 x

当这三个步骤只由一个 Goroutine 执行时,一切都是正常的。

让我们讨论一下当两个 goroutine 同时运行这段代码时会发生什么。下图描述了两个goroutine 并发访问代码 x = x + 1 行时可能发生的情况。

第二十五部分:互斥锁 - 图1

我们假设 x 的初值为0。Goroutine _1 获取 x 的初始值,计算 x + 1,在将计算值赋给 x 之前,系统上下文切换到 Goroutine 2Goroutine 2 得到 x 的初始值仍然是 0,计算 x + 1。之后,系统上下文再次切换到 _Goroutine _1。现在 Goroutine 1 将它的计算值 1 赋给 x,因此 x 变为 1。然后 Goroutine 2 再次开始执行,然后将它的计算值赋值给 x,也就是 1 到 x,因此在两个 _Goroutine 执行之后 x 都是 1。

现在让我们看看可能发生的另一种情况。

第二十五部分:互斥锁 - 图2

在上面的场景中,Goroutine 1 开始执行并完成所有三个步骤,因此 x 的值变为 1。然后 Goroutine 2 开始执行。现在 x 的值是 1 当Goroutine 2 执行完毕时,x的值是2。

因此,在这两种情况下,可以看到 x 的最终值为 1 或 2,具体取决于上下文切换的发生方式。程序的输出取决于 Goroutines 的执行顺序,称为 race condition(竞争条件)。

在上面的场景中,如果在任何时间点只允许一个 Goroutine 访问代码的临界区,则可以避免竞争条件。这可以通过使用互斥锁实现。

互斥锁

互斥锁用于提供一种锁定机制,以确保在任何时刻只有一个 Goroutine 在运行代码的临界区,以防止发生竞争情况。

互斥锁在 sync 包中导入。Mutex 上定义了 LockUnlock 两种方法。在锁和解锁调用之间出现的任何代码将只由一个 Goroutine 执行,从而避免了竞争条件。

  1. mutex.Lock()
  2. x = x + 1
  3. mutex.Unlock()

在上面的代码中,x = x + 1 在任何时间点只由一个 Goroutine 执行,从而避免了竞争条件。

如果一个 Goroutine 已经上锁,并且一个新的 Goroutine 试图获取锁,那么这个新的 Goroutine 将被阻塞,直到互斥锁被解锁。

竞争条件的程序

在本节中,我们将编写一个具有竞态条件的程序,在下一节中,我们将修复竞态条件。

  1. package main
  2. import (
  3. "fmt"
  4. "sync"
  5. )
  6. var x = 0
  7. func increment(wg *sync.WaitGroup) {
  8. x = x + 1
  9. wg.Done()
  10. }
  11. func main() {
  12. var w sync.WaitGroup
  13. for i := 0; i < 1000; i++ {
  14. w.Add(1)
  15. go increment(&w)
  16. }
  17. w.Wait()
  18. fmt.Println("final value of x", x)
  19. }

在上面的程序中,increment 函数在第 11 行将 x 的值增加 1,然后调用 WaitGroup 上的 Done() 来完成通知。

我们从第 15 行产生 1000 个 increment Goroutines。这些 Goroutines 中的每一个都同时运行,当多个 Goroutines 尝试同时访问x的值并且在第 11 行尝试增加 x 时会发生竞争条件。

请在自己机器上运行此程序,因为 playground 是确定性的,playground 上不会出现竞争条件。在本地计算机上多次运行此程序,您可以看到由于竞争条件,每次输出都会有所不同。我自己跑出来的输出有 final value of x 941, final value of x 928, final value of x 922 等等。

使用互斥锁解决竞争条件

在上面的程序中,我们产生了 1000 个 Goroutines。如果每个都将 x 的值递增 1,则 x 的最终期望值应为 1000。我们将使用互斥锁修复上述程序中的竞争条件。

  1. package main
  2. import (
  3. "fmt"
  4. "sync"
  5. )
  6. var x = 0
  7. func increment(wg *sync.WaitGroup, m *sync.Mutex) {
  8. m.Lock()
  9. x = x + 1
  10. m.Unlock()
  11. wg.Done()
  12. }
  13. func main() {
  14. var w sync.WaitGroup
  15. var m sync.Mutex
  16. for i := 0; i < 1000; i++ {
  17. w.Add(1)
  18. go increment(&w, &m)
  19. }
  20. w.Wait()
  21. fmt.Println("final value of x", x)
  22. }

Run in playground

互斥锁是一种结构类型,我们在第 15 行创建了一个零值的 Mutex 变量 m。 在上面的程序中,我们修改了 increment 函数,使递增 x = x + 1 的代码位于 m.Lock()m.Unlock() 之间。现在这段代码没有任何竞争条件,因为在任何时间点只允许一个Goroutine 执行这段代码。

如果程序运行时,它会输出


  1. final value of x 1000

值得注意的在 22 行传参的是互斥锁的地址。如果互斥锁是通过值传递的,而不是传递地址,每个 Goroutine 都有其自己互斥锁的副本,竞争条件仍然会发生。

利用 channel 解决竞态条件


  1. package main
  2. import (
  3. "fmt"
  4. "sync"
  5. )
  6. var x = 0
  7. func increment(wg *sync.WaitGroup, ch chan bool) {
  8. ch <- true
  9. x = x + 1
  10. <- ch
  11. wg.Done()
  12. }
  13. func main() {
  14. var w sync.WaitGroup
  15. ch := make(chan bool, 1)
  16. for i := 0; i < 1000; i++ {
  17. w.Add(1)
  18. go increment(&w, ch)
  19. }
  20. w.Wait()
  21. fmt.Println("final value of x", x)
  22. }

Run in playground

在上面的程序中,我们创建了一个容量为 1 的缓冲 channel ,并在 22 行中将其传递给increment Goroutine。此缓冲 channel 用于确保只有一个 Goroutine 访问增加 x 的代码的临界区。 这是通过在 x 递增之前将 true 传递给缓冲 channel 来完成的。 由于缓冲 channel 的容量为 1,因此在尝试写入此 channel 的所有其他 Goroutines 都会被阻塞,直到在递增 x 后从该 channel 读取值为止。这实际上只允许一个 Goroutine 访问临界区。

程序将输出


  1. final value of x 1000

互斥锁 vs channel

我们同时使用互斥锁和 channel 解决了竞争条件问题。那么我们如何决定什么时候使用什么。答案在于你要解决的问题。如果试图解决的问题更适合互斥锁,那么继续使用互斥锁。如果真的需要,不要犹豫使用互斥锁。如果这个问题似乎更适合 channel 来解决,那么也可以使用它:)。

大多数 Go 新手尝试使用 channel 来解决每个并发问题,因为 channel 是该语言的一个很酷的特性。这是错误的。该语言让我们可以选择使用互斥锁或 channel ,选择这两种方式都没有错。

一般来说,当 Goroutine 需要彼此通信时使用 channel ,当只有一个 Goroutine 应该访问代码的临界区时使用互斥锁。

在我们上面解决的问题中,我宁愿使用互斥锁,因为这个问题不需要 goroutine 之间的任何通信。因此互斥锁是一个适合的选择。

我的建议是,为问题选择工具,不要试图将问题适应于工具:)

原文链接

https://golangbot.com/mutex/