Part25 Mutex
Golang 系列教程 第 25 部分 - Mutex
在该教程,我们学习互斥锁,也学习如何使用 channels 和 互斥锁解决竞态条件。
临界区
在学习互斥锁之前,理解并发程序的临界区的概念是很重要的。当程序并发地运行,修改共享资源的部分代码不应该被多个 协程(Goroutines)同时访问。修改共享资源的这部分代码就被叫做临界区。例如,我们假设有一段将变量 x
增加 1 的代码。
x = x +1
只要上面的一段代码被单个协程访问,不应该有任何问题。
我们来看看为什么这段代码在多个协程并发地运行时会失败。为了简单,我们假设有 2 个协程并发地运行上面的一行代码。
上面的一行代码内部将被系统以下面的三步执行(有更多的技术细节如寄存器,如如何添加工作等等,本教程为了简单,假设有三步):
- 获取 x 的当前值。
- 计算 x+1
- 将第2步计算的值赋值给 2
当这三个步骤仅被一个协程执行,一切正常。
我们讨论当2个协程并发地运行这段代码将会发生什么。下面的这幅图片描绘了一个当两个协程并发地访问 x=x+1
将会发行什么的情况。
我们已经假设 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
。
现在我们来看一个可能发生不同情况
在上面的情况,Goroutine 1
开始执行,完成它的所有三个步骤,因此 x 的值为 1
。然后 Goroutine 2
开始执行,现在 x
的值是 1
,当 Goroutine 2
完成执行,x
的值是 2
。
所以从两个例子中你可以看出,x 的最终值是 1
或 2
取决于上下文切换的发生。这个程序的输出取决于协程执行的顺序的不良情况被称为竞态条件
在上面的场景中,如果在任何时间点,只允许一个协程访问临界区代码,可以避免竞态条件。这可以通过使用互斥锁实现。
互斥锁(Mutex)
Mutex用于提供锁定机制,以确保在任何时间点只有一个Goroutine运行代码的临界区,以防止发生竞争条件。
Mutex 可以从 sync包中获得。Mutex定义了两个名为 Lock和Unlock的方法。任何出现在调用 Lock
和 Unlock
之间的代码仅被一个协程执行,这样避免了竞态条件。
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 + 1
在 m.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菜鸟尝试使用通道解决所有的并发问题,通道确实是语言的一个非常酷的特性。这是错的,语言给我们提供了使用互斥锁或通道的选项供选择,这并没有错。
一般地当协程需要互相通道使用通道,互斥仅当一个协程应该临界区代码。
我们解决问题的示例中,我更期望使用互斥锁,因为这个问题并不需要与任何协程通信。因此互斥是一个更自然的选择。
我的建议是为问题选择工具,而不是为工具尝试解决问题。
**下一教程 - 结构体代替类 **