Golang锁-Mutex的用法

因为并发编程中有竞争条件和数据竞争的问题,我们才需要将代码片段设定为临界区,通过使用Mutex等同步原语将临界区保护起来。接下来,我们来熟悉Go标准库的Mutex 的使用方法,看看它是如何保护临界区,解决竞争条件和数据竞争的问题的。

Mutex的用法

一个并发问题

有时候,我们很清楚地知道临界区或者共享资源,能主动地发现数据竞争问题;但是有时候,数据竞争问题却不那么容易被发现,比如下面这段代码,你认为有数据竞争问题吗?

go
func TestCounter() { var counter int64 // 计数值 var wg sync.WaitGroup // 用來等待子goroutine全部执行完 for i := 0; i < 64; i++ { wg.Add(1) go func() { for i := 0; i < 1000000; i++ { // 循环100万次 counter++ // 计数值加1 } wg.Done() }() } wg.Wait() if counter != 64000000 { fmt.Printf("counter should be 64000000, but got %d\n", counter) } }

这段代码演示了并发修改一个类型为int64的计数器的并发问题,其中counter++存 在着数据竞争,它并不是一个原子操作。如果使用伪代码来表示counter++语句,它类似于下面的形式:

tmp := counter tmp = tmp + 1 counter = tmp

可见,counter++不是原子操作,会有数据竞争问题。运行上面的测试,基本会失败,最终的结果并不是我们预期的6400万。

counter should be 64000000, but got 1023172

其中的sync.WaitGroup是用来等待goroutine执行的同步原语,这里启动了64个goroutine来并发执行,我们需要等待这64个goroutine都执行完才能检查counter的结果,所以使用了WailGroup。

我们通过一个简单的例子就能自己看到数据竞争带来的“破坏性”,程序会得到一个意想不到的结果,那么如何使用Mutex修复它呢?下面是一个修复的例子。

go
func TestCounter() { var counter int64 // 计数值 var wg sync.WaitGroup // 用來等待子goroutine全部执行完 var mu sync.Mutex //使用互斥锁 for i := 0; i < 64; i++ { wg.Add(1) go func() { for i := 0; i < 1000000; i++ { // 循环100万次 mu.Lock() counter++ // 计数值加1 mu.Unlock() } wg.Done() }() } wg.Wait() if counter != 64000000 { fmt.Printf("counter should be 64000000, but got %d\n", counter) } }

运行上面的测试,运行成功,最终计数器的结果符合预期,计数器的值为6400万。

Mutex的使用

Mutex 使用起来特别简单,因为它本身只有三个方法。

  • Lock() 获取锁。
  • Unlock() 释放锁。
  • TryLock() bool 尝试获取锁,Go 1.18中才加入。

我们使用Mutex的实例m保护临界区。当一个goroutine想进入临界区时,它应该调用 m.Lock()获取锁。如果这个goroutine 获取到了锁,它就持有了锁m。如果此时m被其他goroutine所持有,这个请求的goroutine 就会被阻塞,等待其他goroutine 释放锁。

一个goroutine可以通过m.Unlock(释放锁,比如持有锁的goroutine 退出临界区时,它需要调用m.Unlock0释放锁。锁一旦被释放,其他等待这个锁的goroutine 才有机会获取锁。

和其他一些编程语言的锁的实现不同,在Go语言中,即使一个goroutine没有持有锁,它也可以释放一个Mutex。这是一个非常容易出现并发问题的场景,我们尽量不要这样做,最好的方式就是“谁持有,谁释放”。

如果m还没有被加锁,此时一个goroutine 调用m.Unlock()会怎样?

如果直接释放一个未加锁的Mutex,它会直接报panic。 这也是比较容易理解的,因为这种情况是逻辑设计的bug,程序也无法代替你自动处理这种情况,但也不能忽略这种情况,所以报panic了,错误信息是“sync: unlock of unlocked mutex”。

TryLock是Go 1.18中才加入的一个新方法,对于添加这个方法讨论了很多年,甚至对于这个方法的命名也有争议。尽管其他编程语言中的互斥锁早已实现了这个方法,但是这个方法迟迟没有被加入标准库的Mutex中,一些项目会自己实现这个方法,因为在标准库中实现这个方法也就是几行代码的问题。有时候,Go团队对于新功能的增加是偏于谨慎的,毕竟既要保持兼容性,又要保证新添加的方法确实能解决痛点问题,而不是随随便便加入搞得标准库很臃肿。不管怎样,最终这个方法还是被加入了,包括读写锁也添加了类似的方法。Go团队还在方法注释中好心提醒,这个方法的使用场景很少,容易出错。

那么,这个方法又有何用处呢?我们知道,当一个goroutine 调用Mutex.Lock()方法时,如果锁被其他goroutine所持有,这个goroutine就会被阻塞。在某些场景下,我们可能不想让此goroutine被阻塞,而是允许它放弃进入临界区去做其他的事情,这个时候就可以使用TryLock了。这个方法返回一个布尔类型的值,如果此goroutine成功获取到了锁,则返回 true;否则,返回false。

go
func TestTryLock() { var mu sync.Mutex // 加锁2s go func() { mu.Lock() time.Sleep(2 * time.Second) mu.Unlock() }() time.Sleep(time.Second) // 尝试获取锁,大概率获取不成功 if mu.TryLock() { println("try lock success") mu.Unlock() } else { println("try lock failed") } }

在上面这段代码中,一个goroutine获取到了锁,主goroutine 调用 TryLock尝试获取锁。因为此时锁已经被子goroutine持有了,所以主goroutine 尝试获取锁失败。

try lock failed

当然,这个例子不是那么严谨,因为利用Sleep编排goroutine的运行,理论上,有可能子 goroutine 晚于主 goroutine 对 TryLock进行调用。

如果我们只是在自己的机器上测试,机器的负载并不大,那么一般会按照我们期望的方式运行,所以不要在这个问题上纠结。

之所以使用Sleep这种简单的方式进行演示,而没有采用其他的同步原语对goroutine的运行进行编排,是因为不想在这个简单的例子中引入太多还没有介绍的内容,避免产生过多的干扰。

总体来说,Mutex的使用特别简单,使用Lock和Unlock 两个方法就可以进入临界区和退出临界区,实现对共享资源的并发保护,所以它也是使用广泛的同步原语之一。

Mutex 实现了 Locker接口。Locker接口定义了锁同步原语的方法集(自从Go实现了泛型之后,接口的定义就发生了变化,我们应该说Locker接口定义了类型集合,Locker 的类型集合的类型要实现下面的方法集):

go
type Locker interface { Lock() Unlock() }

可以看到,Go定义的Locker接口的方法集很简单,只有获取锁(Lock)和释放锁(Unlock)这两个方法,秉承了Go语言一贯的简洁风格。但是,这个接口在实际项目中的应用并不多,因为我们一般会直接使用具体的同步原语,比如Mutex,而不是通过接口。

地道的用法

Mutex 地道的使用方式是使用它的零值,而不是显式地初始化一个Mutex变量。比如下面是常用的写法:

go
var mu sync.Mutex // 使用零值 mu.Lock() // do something mu.Unlock()

这里声明了一个类型为Mutex的变量mu,默认使用零值。Go标准库的Mutex的零值代表锁未被任何goroutine所持有,也没有等待获取锁的goroutine,所以直接使用它即可。通常,我们很少会使用下面的写法:

go
mu := sync.Mutex{} //非常规的写法 mu.Lock() // do something mu.Unlock()

同样地,如果在一个struct类型中嵌入一个Mutex,或者说struct的某个字段是Mutex 类型的,则也不需要显式地初始化它---虽然显式地初始化它也没什么关系,但是地道的用法就是使用它的零值:

go
type T struct { mu sync.Mutex //嵌入互斥锁 m map[int]int } func main() { var t = &T{ //不需要初始化mu m: make(map[int]int), } t.mu.Lock() // do something t.mu.Unlock() }

而不是使用下面的方式:

go
type T struct { mu sync.Mutex m map[int]int } func main() { var t = &T{ mu: sync.Mutex{}, //没必要初始化mu m: make(map[int]int), } t.mu.Lock() // do something t.mu.Unlock() }