- Go 教程系列笔记 Interface 第一部分
- Go 教程系列笔记 Interface 第二部分
- Go 教程系列笔记 并发介绍
- Go 教程系列笔记 goroutine(协程)
- Go 教程系列笔记 Channel 通道
- Go 教程系列笔记 缓冲通道和工作池
- Go 教程系列笔记 Select
- Go 教程系列笔记 Mutex(互斥锁)
- Go 教程系列笔记 结构而不是类-Go中的OOP
- Go 教程系列笔记 组合而不是继承-Go 中的 OOP
- Go 教程系列笔记 多态-Go 中的 OOP
在本教程中,我们将了解互斥锁。我们还将学习如何使用互斥锁和通道解决竞争问题。
临界区
在讲互斥锁之前,了解并发编程中临界区的概念非常重要。当程序同时运行时,多个 goroutine 同时访问修改共享资源,修改共享资源的这段代码称为临界区。例如,假设我们要将变量 x 递增1.
x = x + 1
只要上面的代码被一个 goroutine 访问,就不会有任何问题。
<!-- more -->
让我们看看为什么当有多个 goroutine 同时运行时,这段代码会失败。为简单起见,我们假设有2个 goroutine 同时运行上面的代码行。
在内部,上面的代码行将由系统按下面的步骤执行。
- 获取 x 的当前值
- 计算 x+1
- 将步骤2中计算的值分配给 x
当这三个步骤仅由一个 goroutine 进行时,一切都很顺利。
让我们讨论当2个 goroutine 同时运行此代码会发生什么。下图描绘了两个 goroutine 同时访问代码行时可能发生的情况。
图中,第一步协程1当前x值是0,计算x+1
,然后系统切换上下文到协程2,第二步,协程2当前x值是0,计算x+1
,这时系统又切换上下文到协程1,进行分配x值,然后又切换上下文到协程2,进行分配x值,最后,x的值还是1.
让我们再看看可能发生的不同情况:
在上面的场景中,协程1开始执行并完成3个步骤,这时x值是1,然后开始执行协程2,现在x的值已经是1了,在协程2执行完成,x的值就是2了。
因此,在这两种情况下,你可以看到 x 的最终值是1或2取决于上下文切换的方式。这种类型的不良情况,其中程序的输出取决于 goroutine 的执行顺序,称为竞争条件。
为了避免竞争条件,可以通过使用 Mutex 实现。
Mutex 互斥
Mutex 用于提供锁定机制,以确保在任何时间点只有一个 goroutine 在临界区运行,已防止发生竞争条件。
sync
包中提供了 Mutex。Mutex 定义了两个方法,即 Lock
和 Unlock
,在 Lock
和Unlock
之间将仅由一个 goroutine 被执行,从而避免了竞争条件。
mutex.Lock()
x = x + 1
mutex.Unlock()
在上面的代码中,x=x+1
将在任何时间点仅由一个 goroutine 执行,从而防止竞争条件。
如果一个 goroutine 已经 Lock
,如果一个新的 goroutine 试图 Lock
,新的 goroutine 将会阻塞,直到 Mutex Unlock.
有竞争条件的程序
我们将编写一个具有竞争条件的程序,在接下来的部分中我们将修复竞争条件。
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)
}
请在本地运行此程序,因为操作是确定性的,操作上不会出现比赛条件。在本地计算机上多次运行此程序,您可以看到由于竞争条件,每次输出都会有所不同。其中一些我所遇到的产出是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)
}
Mutex是一个结构类型,在第 15 行我们创建了一个零值的变量m的Mutex类型。在上面的程序中,我们更改了increment
函数,以便增加x的代码x = x + 1
在m.Lock()
和m.Unlock()
之间。现在这段代码没有任何竞争条件,因为在任何时候只允许一个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的通道,并将其传递给increment
的Goroutine。此缓冲通道用于确保只有一个Goroutine访问增加x的代码的关键部分。这是通过传递true到第 8行号中的缓冲通道来完成的,然后 x增加。由于缓冲通道的容量为1,所有其他尝试写入此通道的Goroutines都会被阻塞,直到在第9行增加x后从该通道读取该值。实际上,这只允许一个Goroutine访问临界区。
这个程序也打印
final value of x 1000
Mutex vs Channel
我们使用互斥锁和通道解决了竞争条件问题。那么我们如何决定何时使用呢?答案在于你要解决的问题。如果你解决的问题更合适互斥锁,那么请继续使用互斥锁。如果需要,请不要犹豫使用互斥锁。如果问题更适合通道,那么使用它:)(没有银弹)
大多数 Go 新手尝试使用通道解决每个并发问题,因为它是该语言的一个很酷的功能。这是错误的,语言为我们提供了使用 Mutex 和 Channel 的选择,并且选择任何一种都没有错。
一般情况下,当 goroutine 需要互相通信时使用通道,当只有一个 goroutine 应该访问代码的临界区时使用互斥。
在我们上面的问题情况下,我宁愿使用互斥锁,因为这个问题不需要 goroutine 之间任何通信。因此互斥锁是一种自然的选择。
我的建议是根据问题选择工具,不要试图让问题适应工具。
有疑问加站长微信联系(非本文作者)