本文是《Go系列教程》的第二十二篇文章。
在这篇文章中,我们要讲解关于mutex的内容。我们还会学习到如何使用mutex和channel解决资源竞争问题。
临界区
在讲临界区之前,我们必须理解临界区的概念在并发编程中,当一个程序并发运行时,不能有多个Goroutine在同一时间访问修改共享资源的代码段。此时的代码段就是临界区。
例如,假定我们有一段代码会使变量x自增1。
x = x + 1
只要上面的这段代码仅仅被一个Goroutine访问,那么就不会有任何问题。但我们需要知道,为什么当有多个Goroutine并发运行这段代码的时候,就会出问题呢。
为了简化起见,我们假定有2个Goroutine并发运行上面的代码。这段代码在操作系统内部的执行步骤如下,
-
1、获取x的当前值
-
2、计算X+1
-
3、把计算后的值赋值给X
当以上这3个步骤只有一个Goroutine,所有的都会运行良好。
我们讨论一下,当有2个Goroutine并发运行这段代码的时候,到底发生了什么呢。下面这个图展示了当有2个Goroutine并发访问x=x+1代码时的内部逻辑。
图1我们假定x的初始值为0。Goroutine 1先获取x的初始值,计算X+1,在把计算后的值赋值给X之前,系统上下文切换到Goroutine2。现在Goroutine2也获取了x的初始值,x的初始值还是0。
这是Goroutine2继续计算x+1。在系统上下文再次切换到Goroutine 1之后,现在Goroutine 1把计算后的值1赋值给x。因此,x就变成了1。之后,Goroutine 2再次启动运行,之后再次把
计算后的值赋值给x,即:又把1赋值给了x。因此,在这俩个Goroutine执行之后,x就变成了1。
现在,我们再来看一个不同的应用场景。
图2在上面的场景中,Goroutine1启动执行,并执行完所有的这3个步骤,因此,x的值变成了1。之后,Goroutine 2开始执行。现在x的值是1,当Goroutine 2运行结束时,x的值就是2。
因此,从上面这俩种场景,你可以看到x的最终值,有可能是1也有可能是2,它完全取决于系统的上下文是如何切换的。像这种情况,就称为 资源竞争。
在上面的程序中,如果在任何时间点,只允许一个Goroutine访问这段临界区代码的话,资源竞争是完全可以避免的。我们通过使用Mutex就可以做到这一点。
Mutex
Mutex主要用于提供一个加锁机制来确保在任何时间点上,只有一个Goroutine运行这段临界区的代码。从而避免了资源竞争的发生。
Mutex位于sync包中。在Mutex中定义了2个方法:Lock和Unlock。位于Lock和Unlock之间的代码只能被一个Goroutine执行,因此,避免了资源竞争。
mutex.Lock()
x = x + 1
mutex.Unlock()
在上面这段代码,在任何一个时间点上,只有一个Goroutine能运行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)
}
在上面的程序中,第7行的increment函数会把x的值加1。之后调用WaitGroup的Done()函数来通知它完成。
我们创建了1000个increment的Goroutine。这些Goroutine并发运行,当多个Goroutinet并发访问x的值,并试图增加x的值的时候,就会发生资源竞争。
在你的本地机器上,多次运行此程序,你就可以看到在每一次输出的时候,内容都是不一样的,这就是资源竞争产生的。我这边运行时的输出有这几个:
final value of x 941, final value of x 928, final value of x 922 等等。
使用mutex解决资源竞争
在上面的程序中,我们创建了1000个Goroutine。如果每一个Goroutine都把x的值加1的话,最终的值就是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是一个结构体类型,我们创建了一个零值的Mutex结构体类型的变量m。在上面的程序中,我们修改了increment函数,把x=x+1置于m.Lock和m.Unlock之间。
现在这段代码就有效地避免了资源竞争,因为在任何时间点上,只允许有一个Goroutine可以运行这段代码。
此时,如果运行程序的话,输出如下:
final value of x 1000
有一点非常重要的,那就是必须传入mutex的地址。如果传入的mutex不是地址而是值的话,每一个Goroutine将有一份自己的mutex副本,资源竞争依然会发生。
使用channel解决资源竞争
除了mutex之外,我们还可以使用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传递给缓冲区通道。由于缓冲区通道的容量为1,所以,其他试图向此通道执行写入操作的Goroutine都会被阻塞住,直到x的增加完之后,
可以从此通道中读取到数据为止。这样也可以有效地控制了只有一个Goroutine可以访问临界区代码。
程序的输出如下:
final value of x 1000
Mutex VS Channel
使用Mutex和Channel都可以解决资源竞争问题。那么我们在什么时候选择使用Mutex或Channel呢。答案取决于我们要解决的问题。如果我们要解决的问题更适合使用mutex解决的话,我们就使用
mutex。如果要解决的问题,适合使用channel解决的话,那就使用channel。
需要Go的初学者试图使用channel解决所有的并发问题,这是错误的。Go语言为我们提供了俩种解决并发问题的选项:Mutex和Channel。选择哪一个都行。
通常情况下,当Goroutine需要彼此通信时,我们可以使用Channel。当只允许一个Goroutine访问临界区代码的时候,我们可以使用Mutex。
在本例中,我更倾向于使用mutex来解决这个问题。因为该问题不需要Goroutine之间进行通信。因此,更适合使用mutex。
我们的建议是,为问题选择解决方法,而不是为解决方法找问题。
感谢您的阅读,请留下您珍贵的反馈和评论。Have a good Day!
备注
本文系翻译之作原文博客地址
网友评论