导言
- 原文链接: Part 25: Mutex
- If translation is not allowed, please leave me in the comment area and I will delete it as soon as possible.
互斥锁
在这一部分,我们将讨论一下互斥锁。在之后,我们也会讨论:如何使用互斥锁和通道,去解决竞态条件。
竞态条件的英文是
race condition
。
临界区 (critical section)
在讨论互斥锁前,我们首先得理解,在并发编程中 临界区 的概念。
当程序并发运行时,修改共享资源的代码 不能同时 被多个协程执行。这一段修改共享资源的代码,被叫做临界区。
举个例子,下面有一段代码,它能让 x
增加 1
。
x = x + 1
单个协程执行这段代码时,并不会出现任何问题。
接下来,我们来看看,当协程并发时,为什么这段代码会出现错误。简单起见,我们假设这只有 2
个协程并发执行上面的代码。
对于上面的代码,系统将会通过下列几个步骤执行它。(这涉及到了一些附加知识,比如寄存器、加法器工作原理…但为了简单起见,我们假设该指令执行只需 3
个步骤)
- 获取
x
的当前值 - 计算
x + 1
- 将第
2
步的计算结果赋值给x
当只有 1
个协程执行上面的 3
个步骤时,这完全没问题。
接下来,我们来讨论 2
个协程并发执行的情况。下图描绘了 2
个协程并发执行时,可能出现的一种情况。
我们假设 x
的初始值为 0
。刚开始,协程1
获得了 x
的初始值,并计算 x + 1
,在把该值赋给 x
前,系统的上下文切换给了 协程2
。此时,协程 2
获得了 x
的初始值 — 此时 x
还为 0
,计算 x + 1
,之后系统上下文又切换回 协程1
。现在,协程1
把计算所得的值 1
赋给了 x
,因此,此时 x
变为了 1
。之后,协程2
重新开始运行,它也把计算所得的值 1
分配给 x
,此时 x
依旧等于 1
。因此,两个协程执行后,x
的值为 1
。
接下来,我们来看看另外一种可能发生的情况。
在上面的情况中,协程1
开始运行并完成 3
个步骤,此时, x
的值为 1
。之后,协程2
开始执行,也完成了 3
个步骤。最终,x
的值为 2
。
通过上面的两种情况,你就可以看出: x
的值可能是 1
,也可能是 2
,这取决于上下文的切换。
这一类无法确定的情况,被叫做竞态条件。(之所以无法确定,是因为程序的输出取决于协程的执行顺序)
在上面的情况中,竞态条件其实是可以避免的 — 在任意时间点,如果我们只允许一个协程执行临界区的代码,我们就能避免竞态条件。为了实现这个目的,我们可以使用 互斥锁
。
互斥锁介绍
互斥锁提供了一个锁机制,这个机制保证:在任意时间点,只有一个协程运行临界区的代码,从而避免竞态条件发生。
互斥锁的英文是:
mutex
Mutex
结构 位于 Go
语言 的 sync
包。Mutex
定义了 2
个操作: Lock
和 Unlock
。在任意时间点,在 Lock
与 Unlock
之间的代码,都只能被一个协程执行,从而避免竞态条件。
mutex.Lock()
x = x + 1
mutex.Unlock()
上面的代码可以保证:在任意时间点,只有一个协程执行x = x + 1
,从而避免竞态条件。
原理:如果 协程a
已经获得了锁,而 协程b
想要获得这个锁,那么 协程b
将会阻塞,直到该锁解锁。
具有竞态条件的程序
在这一节,我们将写一个具有竞态条件的程序,而在下一节,我们将修复它。
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
,并调用 Done
函数 — 表示已经完成任务。在第 15
行,我们创建了 1000
个协程。协程们是并发的,于是,这将会产生竞态条件,因为有多个协程访问 x = x + 1
,这是一段临界区代码。
在本地运行这段代码,你会发现结果不是确定的 — 因为出现了竞态条件。我自己运行的结果如下:
- 第一次: final value of x 941
- 第二次: final value of x 928
- 第三次: final value of x 922
使用互斥锁解决竞态条件
在上面的程序中,我们创建了 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 + 1
位于 m.Lock()
与 m.Unlock()
之间。此时,这段代码就不会出现竞态条件了,因为在任意时刻,只有一个协程能执行临界区代码 — x = x + 1
。
运行这个程序,它会输出:
final value of x 1000
注意:在第 18
行,我们传递的是 m
的指针,这是因为:假如我们传递的是 m
的值,那么每个协程都将拥有一份 m
的拷贝,这会使得竞态条件依旧存在。
使用通道解决竞态条件
我们也使用通道来解决竞态条件。我们看看怎么做。
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
函数。使用这个有缓冲的通道,我们可以保证:在任意时间点,只有 1
个协程访问临界区代码 — x = x + 1
。
我们的做法是,在增加 x
前,我们将 true
传入通道。因为这个通道的容量是 1
,所以如果有其他的协程,它们想向通道写入数据,它们都会阻塞,直到通道内的数据被读出。实际上,这个方法也可以保证只有一个协程位于临界区。
程序也会输出:
final value of x 1000
互斥锁 vs 通道
通过分别使用互斥锁和通道,我们解决了竞态条件。那我们在解决竞态条件时,要选择哪个呢?
这取决于你要解决的问题是什么。如果你要解决的问题更适用于互斥锁,那你就用互斥锁吧,不用犹豫。如果你要解决的问题更适用于通道,那你就用通道。
大多数的 Go
语言 新手,倾向于使用通道解决所有并发问题,因为他们认为:通道是 Go
语言 中一个很棒的特性,这其实是错误的。Go
语言 为我们提供了互斥锁和通道的选项,我们选择任意一个都没有错。
一般来说,当协程们需要通信时,这时可以采用通道,当协程们要访问临界区时,这时可以使用互斥锁。
对于我们上面解决的问题,我更倾向于使用互斥锁,因为此时协程们并不需要通信,互斥锁是很自然的选择。
我的建议是:选择合适的工具解决问题,而不是被工具本身所束缚。
源码透析
通道其实内置了一个互斥锁,通道结构如下:
// 文件位于: $GOROOT/src/runtime/chan.go
type hchan struct {
// ....
lock mutex
}
原作者留言
优质内容来之不易,您可以通过该 链接 为我捐赠。
最后
感谢原作者的优质内容。
这是我的第三次翻译,欢迎指出文中的任何错误。