GO语言学习笔记-互斥锁
原文,互斥锁(Mutex)也是go并发系列最后一篇文章。
临界区
在说互斥锁前,理解并发编程中的临界区(Critical section)是十分重要的。当一个程序并发执行时,共享资源不应该在同一时刻被多个goroutine修改。这段修改共享资源的代码就叫做临界区。举个例子,我们有一个代码片段用于修改变量x自增1。
x = x+1
如果上面的代码在唯一的goroutine中执行,不会有任何问题。
让我们看看这段代码当多个goroutine并发执行时会有什么问题,为了简单我们假设有2个goroutine。操作系统执行上面代码时候有3步(其实更复杂,比如寄存器、加法如何进行,但这里我们简化成3步):
- 获取当前的x值
- 计算x+1
- 把上一步的结果赋值给x
只有一个goroutine时,一切正常。但有2个goroutine并发执行时,下图展示了可能出现的一种情形:
我们假设初始值为0,goroutine1取得了这个值并计算x+1,但当把结果赋值给x前系统切换到了goroutine2,goroutine2也取得初始值0并计算x+1,然后系统切换到goroutine1,将计算的结果1赋值给x。接下来goroutine2继续执行,把其计算结果1赋值给x。因此,所有goroutine执行完成后x值为1。
下面让我们来看另一种情况:
上述情况,goroutine1执行所有步骤后将x值变为1,然后goroutine2继续执行,最终x值为2。
所以最终x值为1还是2取决于context是如何切换的。这种结果取决于执行顺序的情况叫做竞争条件(Race Condition)。
上述场景中,如果同时刻只准许一个goroutine进入临界区,则竞争条件可以避免。可以使用互斥锁来达到这个目的。
互斥锁
互斥锁提供了一种锁机制来保证同一时刻只有一个goroutine访问临界区,这样就可以避免竞争条件了。
互斥锁位于sync
包,提供了Lock
和Unlock
2个方法,任何被这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 941
,final value of x 928
,final 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数量多的时候互斥锁更快。