GO语言学习笔记-互斥锁

原文,互斥锁(Mutex)也是go并发系列最后一篇文章。

临界区

在说互斥锁前,理解并发编程中的临界区(Critical section)是十分重要的。当一个程序并发执行时,共享资源不应该在同一时刻被多个goroutine修改。这段修改共享资源的代码就叫做临界区。举个例子,我们有一个代码片段用于修改变量x自增1。

x = x+1

如果上面的代码在唯一的goroutine中执行,不会有任何问题。

让我们看看这段代码当多个goroutine并发执行时会有什么问题,为了简单我们假设有2个goroutine。操作系统执行上面代码时候有3步(其实更复杂,比如寄存器、加法如何进行,但这里我们简化成3步):

  1. 获取当前的x值
  2. 计算x+1
  3. 把上一步的结果赋值给x

只有一个goroutine时,一切正常。但有2个goroutine并发执行时,下图展示了可能出现的一种情形:

img1

我们假设初始值为0,goroutine1取得了这个值并计算x+1,但当把结果赋值给x前系统切换到了goroutine2,goroutine2也取得初始值0并计算x+1,然后系统切换到goroutine1,将计算的结果1赋值给x。接下来goroutine2继续执行,把其计算结果1赋值给x。因此,所有goroutine执行完成后x值为1。

下面让我们来看另一种情况:

img2

上述情况,goroutine1执行所有步骤后将x值变为1,然后goroutine2继续执行,最终x值为2。

所以最终x值为1还是2取决于context是如何切换的。这种结果取决于执行顺序的情况叫做竞争条件(Race Condition)。

上述场景中,如果同时刻只准许一个goroutine进入临界区,则竞争条件可以避免。可以使用互斥锁来达到这个目的。

互斥锁

互斥锁提供了一种锁机制来保证同一时刻只有一个goroutine访问临界区,这样就可以避免竞争条件了。

互斥锁位于sync包,提供了LockUnlock2个方法,任何被这2个方法包围在其中的代码同一时刻只能被一个goroutine执行,因此可以避免竞争条件了。

mutex.Lock()  
x = x + 1  
mutex.Unlock()  

如果某个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)
}

increment函数将x自增1然后调用wg.Done()来通知WaitGroup完成,然后通过循环生成1000个goroutine,每个goroutine都是并发执行并且并发获取x的值。多次运行程序,你会发现结果每次都不同,比如value of x 941final value of x 928final value of x 922等。

使用互斥锁解决问题

上面的代码我们生成1000个goroutine,如果每个自增1,结果应该是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)
}

互斥锁是一种struct类型,我们创建了一个默认值互斥锁m,并把其地址传递给了increment,同时把x=x+1这句代码放在m.Lock()m.Unlock()之间。这样就只有一个goroutine能在同一时刻执行这句代码了。程序输出如下:

final value of x 1000

使用channel解决问题

我们也可以使用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的带缓冲区的channel并且传递给函数increment,这个缓冲区channel用来确保同时刻只有一个goroutine能进入临界区操作x。先向channel中写入值,由于容量为1,所以其他goroutine将被阻塞。自增操作完成后在从channel中去读数据解除阻塞。这也是控制多个goroutine访问临界区的有效办法。

程序输出和上面一样。

互斥锁 vs Channel

(roy注:这段我就选重点翻译了)

互斥锁和Channel都能解决上述问题,那么什么时候用哪个呢?

如果各个goroutine之间需要通信,选择channel。否则,选择互斥锁。


另外下面有评论说goroutine数量多的时候互斥锁更快。