在本教程中,我们将了解互斥锁。我们还将学习如何使用互斥锁和 channel 来解决竞争条件。
临界区
在进入到互斥锁之前,了解并发编程中 critical section(临界区)的概念非常重要。当程序同时运行时,多个 Goroutines 不能同时访问修改共享资源的代码部分。修改共享资源的这部分代码称为临界区。例如,假设我们有一段代码将变量 x 递增 1。
x = x + 1
上面的代码被一个 Goroutine 访问,就不会有任何问题。
让我们看看为什么当有多个 goroutine 同时运行时,这段代码会失败。为了简单起见,我们假设有两个 goroutine 同时运行上述代码。
上面的代码行将由系统执行以下步骤(其实有更多技术细节,比如寄存器、加法是如何工作的这些。但是出于本教程的考虑,我们假设这只有三个步骤)
获取x的当前值
计算 x + 1
将步骤2中的计算值赋给 x
当这三个步骤只由一个 Goroutine 执行时,一切都是正常的。
让我们讨论一下当两个 goroutine 同时运行这段代码时会发生什么。下图描述了两个goroutine 并发访问代码 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
再次开始执行,然后将它的计算值赋值给 x,也就是 1 到 x,因此在两个 _Goroutine 执行之后 x 都是 1。
现在让我们看看可能发生的另一种情况。
在上面的场景中,Goroutine 1
开始执行并完成所有三个步骤,因此 x 的值变为 1
。然后 Goroutine 2
开始执行。现在 x 的值是 1 当Goroutine 2
执行完毕时,x的值是2。
因此,在这两种情况下,可以看到 x 的最终值为 1 或 2,具体取决于上下文切换的发生方式。程序的输出取决于 Goroutines 的执行顺序,称为 race condition(竞争条件)。
在上面的场景中,如果在任何时间点只允许一个 Goroutine 访问代码的临界区,则可以避免竞争条件。这可以通过使用互斥锁实现。
互斥锁
互斥锁用于提供一种锁定机制,以确保在任何时刻只有一个 Goroutine 在运行代码的临界区,以防止发生竞争情况。
互斥锁在 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
函数在第 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。我们将使用互斥锁修复上述程序中的竞争条件。
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)
}
互斥锁是一种结构类型,我们在第 15 行创建了一个零值的 Mutex
变量 m
。 在上面的程序中,我们修改了 increment
函数,使递增 x = x + 1
的代码位于 m.Lock()
和 m.Unlock()
之间。现在这段代码没有任何竞争条件,因为在任何时间点只允许一个Goroutine 执行这段代码。
如果程序运行时,它会输出
final value of x 1000
值得注意的在 22 行传参的是互斥锁的地址。如果互斥锁是通过值传递的,而不是传递地址,每个 Goroutine 都有其自己互斥锁的副本,竞争条件仍然会发生。
利用 channel 解决竞态条件
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
的缓冲 channel ,并在 22 行中将其传递给increment
Goroutine。此缓冲 channel 用于确保只有一个 Goroutine 访问增加 x 的代码的临界区。 这是通过在 x 递增之前将 true
传递给缓冲 channel 来完成的。 由于缓冲 channel 的容量为 1,因此在尝试写入此 channel 的所有其他 Goroutines 都会被阻塞,直到在递增 x
后从该 channel 读取值为止。这实际上只允许一个 Goroutine 访问临界区。
程序将输出
final value of x 1000
互斥锁 vs channel
我们同时使用互斥锁和 channel 解决了竞争条件问题。那么我们如何决定什么时候使用什么。答案在于你要解决的问题。如果试图解决的问题更适合互斥锁,那么继续使用互斥锁。如果真的需要,不要犹豫使用互斥锁。如果这个问题似乎更适合 channel 来解决,那么也可以使用它:)。
大多数 Go 新手尝试使用 channel 来解决每个并发问题,因为 channel 是该语言的一个很酷的特性。这是错误的。该语言让我们可以选择使用互斥锁或 channel ,选择这两种方式都没有错。
一般来说,当 Goroutine 需要彼此通信时使用 channel ,当只有一个 Goroutine 应该访问代码的临界区时使用互斥锁。
在我们上面解决的问题中,我宁愿使用互斥锁,因为这个问题不需要 goroutine 之间的任何通信。因此互斥锁是一个适合的选择。
我的建议是,为问题选择工具,不要试图将问题适应于工具:)