在本教程中,我们将了解互斥体。我们还将学习如何使用互斥体和通道解决竞争条件。
关键部分
在跳转到互斥体之前,了解并发编程中临界区的概念非常重要。当程序并发运行时,修改共享资源的代码部分不应被多个Goroutines同时访问。这部分修改共享资源的代码称为临界区。例如,假设有一段代码将变量 x 加 1。
x = x + 1
只要上面的代码是由单个 Goroutine 访问的,就不会有任何问题。
让我们看看为什么当有多个 Goroutines 并发运行时这段代码会失败。为了简单起见,我们假设有 2 个 Goroutine 同时运行上面的代码行。
在内部,系统将按以下步骤执行上述代码行(还有更多涉及寄存器、加法工作原理等技术细节,但为了本教程,我们假设是下面三个步骤),
- 获取 x 的当前值
- 计算 x + 1
- 将步骤 2 中的计算值赋给 x
当这三个步骤仅由一个 Goroutine 执行时,一切都很好。
让我们讨论一下当 2 个 Goroutine 同时运行这段代码时会发生什么。下图描述了当两个 Goroutine 同时访问代码行时可能发生的一种情况x = x + 1
。
我们假设 x 的初始值为 0。Goroutine 1获取 x 的初始值0,计算 x + 1,在将计算值分配给 x 之前,系统上下文切换到Goroutine 2
。现在Goroutine 2
获取x
其初始值为l 0
,进行计算x + 1
。之后,系统上下文再次切换到Goroutine 1。现在Goroutine 1将其计算值1赋给x,因此 x 变为 1。然后Goroutine 2再次开始执行,然后将其计算值1赋给x ,因此1
是x
在个2
Goroutine 执行之后的结果
现在让我们看看可能发生的不同情况。
在上述场景中,Goroutine 1
开始执行并完成所有三个步骤,因此 x 的值变为1
。然后Goroutine 2
开始执行。此时 x的值为1,Goroutine 2
执行完毕后, 的x
值为2
。
所以从这两种情况,你可以看到x的最终值是1
或2
取决于上下文切换是如何发生的。这种程序的输出结果取决于 Goroutine 的执行顺序称为**竞争条件**。
在上述场景中,如果在任何时间点只允许一个 Goroutine 访问代码的关键部分,则可以避免竞争条件。这是通过使用互斥体来实现的。
Mutex
Mutex 用于提供一种锁定机制,以确保在任何时间点只有一个 Goroutine 运行代码的关键部分,以防止竞争条件的发生。
[sync](https://golang.org/pkg/sync/)包中可用。Mutex上定义了两种方法,即Lock和Unlock。调用之间存在的任何代码将仅由一个 Goroutine 执行,从而避免竞争条件
mutex.Lock()
x = x + 1
mutex.Unlock()
在上面的代码中,x = x + 1
在任何时间点都只会由一个 Goroutine 执行,从而防止竞争情况。
如果一个 Goroutine 已经持有锁,并且新的 Goroutine 正在尝试获取锁,则新的 Goroutine 将被阻塞,直到互斥锁被解锁。
具有竞争条件的程序
在本节中,我们将编写一个具有竞争条件的程序,在接下来的部分中,我们将修复竞争条件。
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)
}
在上面的程序中,increment
的函数x
增加1
,然后调用WaitGroup的Done()
通知其完成。
我们从第 10 行生成 1000 个Goroutine。这些 Goroutine 中的每一个都同时运行,当尝试x增加1 时,就会出现竞争条件。 因为多个 Goroutine 尝试同时访问 x 的值。
您可以看到由于竞争条件,每次的输出都会不同。我遇到的一些输出是
final value of x 981
final value of x 980
使用Mutex解决竞争条件
在上面的程序中,我们生成了 1000 个 Goroutines。如果每次将 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)
}
Run in playground
Mutex是一种结构类型,我们创建了一个Mutex
类型的零值变量m。在上面的程序中,我们更改了increment
函数,使 x 递增的代码x = x + 1
位于m.Lock()
和 m.Unlock()
之间。现在这段代码没有任何竞争条件,因为在任何时间点都只允许一个 Goroutine 执行这段代码。
现在如果运行这个程序,它将输出
final value of x 1000
在第 21 行传递互斥锁的地址非常重要。如果互斥量是按值传递而不是地址传递,则每个 Goroutine 将拥有自己的互斥量副本,并且竞争条件仍然会发生。
使用通道解决竞争条件
我们也可以使用通道来解决竞争条件。让我们看看这是如何完成的。
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)
}
Run in playground
在上面的程序中,我们创建了一个容量为1
的缓冲通道,并将其传递给increment
。这个缓冲通道用于确保只有一个 Goroutine 访问递增 x 的代码的关键部分。通过缓冲通道传递true
到完成的。冲通道的容量为1
,所有其他试图写入该通道的 Goroutine 都会被阻塞,直到在第 1 3行读出。实际上,这仅允许一个 Goroutine 访问临界区。
该程序还打印
final value of x 1000
Mutex与通道
我们已经使用Mutex和通道解决了竞争条件问题。那么我们如何决定何时使用什么?答案就在于你试图解决的问题。如果您尝试解决的问题更适合Mutex,那么请毫不犹豫地使用Mutex。如果问题似乎更适合渠道,那么就使用它。
大多数 Go 新手尝试使用通道来解决每个并发问题,因为这是该语言的一个很酷的功能。这是错误的。该语言为我们提供了使用 Mutex 或 Channel 的选项,选择任何一个都没有错误。
一般来说,当 Goroutine 需要相互通信时使用通道,而当只有一个 Goroutine 应该访问代码的关键部分时使用Mutex。
对于我们上面解决的问题,我更喜欢使用Mutex,因为这个问题不需要 goroutine 之间的任何通信。因此Mutex是一个很好的选择。
我的建议是选择适合问题的工具,而不是试图让问题适合该工具:
本教程到此结束。祝你有美好的一天。