在本教程中,我们将了解Mutex(互斥锁)。我们还将学习如何使用Mutex和信道解决竞争问题。
临界区
在学习Mutex之前,了解并发编程中关键概念非常重要。当程序并发运行时,多个协程不应该同时访问修改共享资源的代码。修改共享资源的这段代码称为临界区。例如,假设我们有一段代码,将一个变量 x 自增 1。
x = x + 1
如果只有一个协程访问上述代码,那么没有任何问题。
让我们来看看为什么在多个协程并发运行时代码会出问题。为简单起见,我们假设有2个协程会并发运行上面的代码。
实际上,x=x+1在系统底层是有多个步骤来完成的。有更多技术细节涉及到寄存器,以及加法的工作原理等。在这里我们简单归纳为三个步骤:
1、 获取x的当前值;
2、 计算x+1;
3、 将步骤2中的计算值分配给x;
当这三个步骤仅由一个协程执行时,一切都很顺利。
-24100601/25、Golang教程-Mutex-1711450119507.png)
-24100601/25、Golang教程-Mutex-1711450120125.png)
从上述两个例子,我们可以看到最终x的值是1或者2取决于上下文如何切换。程序结果无法预测,结果取决于协程的调度顺序,像这种情况,我们称之为竞争条件。
上述场景,只有一个协程在同一时刻访问临界区资源,竞争条件是不会发生的。我们可以使用Mutex来解决竞争条件。
Mutex
Mutex通常被用来提供一个锁定机制去确保在任何时刻只有一个协程访问临界区资源代码,避免发生竞争条件的情况。Mutex属于 sync包。Mutex定义了两种方法:Lock和Unlock。任何在Lock和Unlock之间的代码只能被一个协程执行,从而避免竞争条件发生。例如下面代码x=x+1,同一时刻只会被一个协程调用。
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,然后重新赋值给x,最后并调用 WaitGroup 的 Done(),通知该函数已结束。
在上述程序的第 15 行,我们创建了 1000 个 increment 协程。这些协程并发运行,并且在第 8 行试图增加 x 的值,当多个并发的协程同时访问 x 的值,就会发生竞争条件。
请在你的本地机器上多运行几次,由于竞态条件,每一次输出可能都不同。我遇到的几次输出是 final value of x 941、final value of x 928、final value of x 922 等。
使用Mutex解决竞争条件
在上面的程序中,我们产生了1000个协程。如果每个协程都将x的值递增1,则x的最终期望值应为1000.在本节中,我们将使用Mutex修复上述程序中的竞争条件。
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 的临界区。原理是:方法是在 x 增加之前(第 8 行),传入 true 给缓冲信道。由于缓冲信道的容量为 1,其他协程试图往通道写数据时被阻塞,当 x 增加后,信道的值被读取(第 10 行)。实际上这就保证了只允许一个协程访问临界区。
该程序也输出:
final value of x 1000
Mutex vs 信道
我们可以通过Mutex和信道 解决竞争条件的问题,那么我们如何决定使用哪一种方案?答案在于你要解决的问题。如果你尝试解决的问题更适合Mutex,那么就使用Mutex。如果问题更适合使用信道解决,那么就使用信道,而不要使用Mutex。
大多数新手喜欢使用信道来解决所有的并发问题,因为这种方法看起来更酷一些,这是错误的,语言给了我们使用Mutex和信道的选择,所以无论选择哪个都是没错的。
通常来讲,使用协程需要互相通信时那就选择信道,如果只是需要访问临界区资源代码的话就推荐使用Mutex。
我的建议是选择解决问题的工具,而不要试图让问题去适应工具。