Part25 Mutex

Golang 系列教程 第 25 部分 - Mutex


在该教程,我们学习互斥锁,也学习如何使用 channels 和 互斥锁解决竞态条件。

临界区

在学习互斥锁之前,理解并发程序的临界区的概念是很重要的。当程序并发地运行,修改共享资源的部分代码不应该被多个 协程(Goroutines)同时访问。修改共享资源的这部分代码就被叫做临界区。例如,我们假设有一段将变量 x 增加 1 的代码。

x = x +1

只要上面的一段代码被单个协程访问,不应该有任何问题。

我们来看看为什么这段代码在多个协程并发地运行时会失败。为了简单,我们假设有 2 个协程并发地运行上面的一行代码。

上面的一行代码内部将被系统以下面的三步执行(有更多的技术细节如寄存器,如如何添加工作等等,本教程为了简单,假设有三步):

  1. 获取 x 的当前值。
  2. 计算 x+1
  3. 将第2步计算的值赋值给 2

当这三个步骤仅被一个协程执行,一切正常。

我们讨论当2个协程并发地运行这段代码将会发生什么。下面的这幅图片描绘了一个当两个协程并发地访问 x=x+1 将会发行什么的情况。
Part25 Mutex
我们已经假设 x 的初始值是 0 。Goroutine 1获取了 x 的初始值,计算 x + 1 ,在它将计算结果赋值给 x 之前,系统上下文切换到 Goroutine 2。现在 Goroutine 2获取 x 的初始值,它仍然是 0,计算 x + 1,在这之后,系统上下文再次切换到 Goroutine 1。现在 Goroutine 1 将它的计算值 1赋值给 x,因此 x 的值成为 1。然后 Goroutine 2 再将开始执行,然后将它的计算值赋值,它又是 1,给 x。因此 x 在两个协程执行后是 1

现在我们来看一个可能发生不同情况
Part25 Mutex
在上面的情况,Goroutine 1开始执行,完成它的所有三个步骤,因此 x 的值为 1。然后 Goroutine 2开始执行,现在 x 的值是 1,当 Goroutine 2完成执行,x 的值是 2

所以从两个例子中你可以看出,x 的最终值是 12 取决于上下文切换的发生。这个程序的输出取决于协程执行的顺序的不良情况被称为竞态条件

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

互斥锁(Mutex)

Mutex用于提供锁定机制,以确保在任何时间点只有一个Goroutine运行代码的临界区,以防止发生竞争条件。

Mutex 可以从 sync包中获得。Mutex定义了两个名为 LockUnlock的方法。任何出现在调用 LockUnlock 之间的代码仅被一个协程执行,这样避免了竞态条件。

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

在上面代码中,x = x + 1 在任何时间点仅被一个协程执行,这样避免了竞态条件。

如果一个协程已经拥有了锁,如果一个新的务程试图请求一个锁,则新的协程将被阻塞直到互斥锁被释放。

含有竞态条件的程序

在这个部分,我们将写一个拥有竞态条件的程序,在接下来的程序中我们将修复这个竞态条件。

package main  
import (  
    "fmt"
    "sync"
    )
var x  = 0  
func increment(wg *sync.WaitGroup) {  
    x = x + 1
    wg.Done()
}
func main() {  
    var w sync.WaitGroup
    for i := 0; i < 1000; i++ {
        w.Add(1)        
        go increment(&w)
    }
    w.Wait()
    fmt.Println("final value of x", x)
}

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

在第 15 行我们生成了 1000 个 increment 协程,这个协程中的每个并发地运行,在第 8 行试图增加 x 时,多个协程并发地尝试访问 x 发生竞态条件。

请在你本地运行这个程序,由于playground 是确定的,在 playground 不会发生竞态条件。在你本地机器上多次运行这个程序,你会看到,由于竞态条件,每次运行的输出是不同的。

使用互斥锁解决竞态条件

在上面程序中,我们发出了 1000 个协程,如果每个将 x 值增加 1,最后 x 的期望值应该是 1000。在这个部分,我们在程序中使用互斥锁解决竞态条件。

package main  
import (  
    "fmt"
    "sync"
    )
var x  = 0  
func increment(wg *sync.WaitGroup, m *sync.Mutex) {  
    m.Lock()
    x = x + 1
    m.Unlock()
    wg.Done()   
}
func main() {  
    var w sync.WaitGroup
    var m sync.Mutex
    for i := 0; i < 1000; i++ {
        w.Add(1)        
        go increment(&w, &m)
    }
    w.Wait()
    fmt.Println("final value of x", x)
}

Mutex是一个结构体类型,我们在第 15 行创建一个空的类型为 Mutex 的变量 m。在上面的程序中我们修改了 increment 函数,这样增加 x 的代码 x = x + 1m.Lock()m.Unlock() 之间,现在在任何时间,史允许一个协程执行这段代码 ,避免了竞态条件。

如果该程序运行,将输出

final value of x 1000

在第 18 行传递 mutex 的地址是非常重要的。如果 mutex 通过值传递代替地址传递,第个协程将拥有 mutex 的一个副本,竞态条件依然会出现。

使用通道解决竞态条件

我们也可以使用通道来解决竞态条件

package main  
import (  
    "fmt"
    "sync"
    )
var x  = 0  
func increment(wg *sync.WaitGroup, ch chan bool) {  
    ch <- true
    x = x + 1
    <- ch
    wg.Done()   
}
func main() {  
    var w sync.WaitGroup
    ch := make(chan bool, 1)
    for i := 0; i < 1000; i++ {
        w.Add(1)        
        go increment(&w, ch)
    }
    w.Wait()
    fmt.Println("final value of x", x)
}

在上面的程序,我们创建一个容量为1带缓冲的通道,在第 18 行将它传递给 increment 协程。这个带缓冲的通道被用来确保仅有一个协程访问临界区代码 - 增加 x 。这在第 8 行在增加 x 之前通过向带缓冲的通道传递 true 完成。由于带缓冲的通道容量为 1,其他所有试图写该通道的协程被阻塞,直到第 10 行增加 x 之后该值被从该通道上读走。这有效地允许一个通道访问临界区。

这个程序也打印

final value of x 1000

Mutex vs Channels

我们使用 Mutex 和 通道都解决了竞态条件。那么我们如何决定何时用哪个。答案在于你所要解决的问题。如果你尝试解决的问题对于互斥更容易解决,那就使用互斥锁吧。如果需要,毫不犹豫地使用互斥锁。如果问题看上去使用通道更好地解决,那就使用它。

很多Go菜鸟尝试使用通道解决所有的并发问题,通道确实是语言的一个非常酷的特性。这是错的,语言给我们提供了使用互斥锁或通道的选项供选择,这并没有错。

一般地当协程需要互相通道使用通道,互斥仅当一个协程应该临界区代码。

我们解决问题的示例中,我更期望使用互斥锁,因为这个问题并不需要与任何协程通信。因此互斥是一个更自然的选择。

我的建议是为问题选择工具,而不是为工具尝试解决问题。

**下一教程 - 结构体代替类 **