Go教程第二十二篇:Mutex

2020-05-16  本文已影响0人  大风过岗

本文是《Go系列教程》的第二十二篇文章。

在这篇文章中,我们要讲解关于mutex的内容。我们还会学习到如何使用mutex和channel解决资源竞争问题。

临界区

在讲临界区之前,我们必须理解临界区的概念在并发编程中,当一个程序并发运行时,不能有多个Goroutine在同一时间访问修改共享资源的代码段。此时的代码段就是临界区。
例如,假定我们有一段代码会使变量x自增1。

x = x + 1

只要上面的这段代码仅仅被一个Goroutine访问,那么就不会有任何问题。但我们需要知道,为什么当有多个Goroutine并发运行这段代码的时候,就会出问题呢。
为了简化起见,我们假定有2个Goroutine并发运行上面的代码。这段代码在操作系统内部的执行步骤如下,

当以上这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!

备注
本文系翻译之作原文博客地址

上一篇 下一篇

猜你喜欢

热点阅读