在使用golang开发中,常常碰到一些并发问题,比如多个 goroutine 并发更新同一个资源,像计数器更新,如果没有互斥控制,就会出现一些异常情况,比如计数器的计数不准确。 这些问题怎么解决呢? 用互斥锁,那在Go语言里,就是Mutex
互斥锁的实现机制
互斥锁是并发控制的一个基本手段,是为了避免竞争而建立的一种并发控制机制。在学习它的具体实现原理前,我们要先搞懂一个概念,就是临界区
在并发编程中,如果程序中的一部分会被并发访问或修改,那么,为了避免并发访问导致的意想不到的结果,这部分程序需要被保护起来,这部分被保护起来的程序,就叫做临界区。
可以说,临界区就是一个被共享的资源,或者说是一个整体的一组共享资源,比如对数据库的访问、对某一个共享数据结构的操作、对一个/0 设备的使用、对一个连接池中的连接的调用,等等。
如果很多线程同步访问临界区,就会造成访问或操作错误,这当然不是我们希望看到的结果。所以,我们可以使用互斥锁,限定临界区只能同时由一个线程持有。
当临界区由一个线程持有的时候,其它线程如果想进入这个临界区,就会返回失败,或者是等待。直到持有的线程退出临界区,这些等待线程中的某一个才有机会接着持有这个临界区
互斥锁就很好地解决了资源竞争问题,有人也把互斥锁叫做排它锁。那在 Go标准库中,它提供了Mutex 来实现互斥锁这个功能 Mutex 是使用最广泛的同步原语,关于同步原语,并没有一个严格的定义,你可以把它看作解决并发问题的一个基础的数据结构。
我先说一下同步原语的适用场景
-
共享资源。并发地读写共享资源,会出现数据竞争 (data race) 的问题,所以需要Mutex、RWMutex 这样的并发原语来保护
-
任务编排。需要 goroutine 按照一定的规律执行,而 goroutine 之间有相互等待或者依赖的顺序关系,我们常常使用 WaitGroup 或者 Channel 来实现。
-
消息传递。信息交流以及不同的 goroutine 之间的线程安全的数据交流,常常使用Channel 来实现。
Mutex的基本使用方法
在正式看Mutex用法之前呢,我想先给你交代一件事: Locker 接口。
在Go的标准库中,package sync 提供了锁相关的一系列同步原语,这个 package 还定义了一个 Locker 的接口,Mutex 就实现了这个接口 Locker 的接口定义了锁同步原语的方法集:
type Locker interface {
Lock()
Unlock()
}
可以看到,Go 定义的锁接口的方法集很简单,就是请求锁 (Lock) 和释放锁 (Unlock) 这两个方法,秉承了 Go语言一贯的简洁风格
简单来说,互斥锁 Mutex 就提供两个方法 Lock 和 Unlock: 进入临界区之前调用 Lock 方法,退出临界区的时候调用 Unlock 方法
func(m *Mutex)Lock()
func(m *Mutex)Unlock()
当一个goroutine 通过调用 Lock 方法获得了这个锁的拥有权后,其它请求锁的 goroutine就会阻塞在 Lock 方法的调用上,直到锁被释放并且自己获取到了这个锁的拥有权。
为何一定要加锁呢,举个例子
import (
"fmt"
"sync"
)
func main() {
var count = 0
// 使用waitGroup等待10个goroutine完成
var wg sync.WaitGroup
wg.Add(10)
for i:= 0;i<10; i++ {
go func() {
defer wg.Done()
// 对变量count执行10次加1
for j := 0; j < 100000; j++ {
count++
}
}
// 等待10个goroutine完成wg.wait()
fmt.PrintIn(count)
}
在这段代码中,我们使用 sync.WaitGroup 来等待所有的 goroutine 执行完毕后,再输出最终的结果。sync.WaitGroup 这个同步原语我会在后面的课程中具体介绍,现在你只需要知道,我们使用它来控制等待一组 goroutine 全部做完任务 但是,每次运行,你都可能得到不同的结果,基本上不会得到理想中的一百万的结果
其实,这是因为,count++ 不是一个原子操作,它至少包含几个步骤,比如读取变量 count的当前值,对这个值加 1,把结果再保存到 count 中。因为不是原子操作,就可能有并发的问题。
比如,10个 goroutine 同时读取到 count 的值为 9527,接着各自按照自己的逻辑加1,值变成了9528,然后把这个结果再写回到 count 变量。但是,实际上,此时我们增加的总数应该是10才对,这里却只增加了1,好多计数都被“吞”掉了。这是并发访问共享数据的常见错误。
这个问题,有经验的开发人员还是比较容易发现的,但是,很多时候,并发问题隐藏得非常深,即使是有经验的人,也不太容易发现或者 Debug 出来。
针对这个问题,Go提供了一个检测并发访问共享资源是否有问题的工具: race detector,它可以帮助我们自动发现程序有没有 data race 的问题
Go race detector 是基于 Google 的 C/C++ @sanitizers 技术实现的,编译器通过探测所有的内存访问,加入代码能监视对这些内存地址的访问(读还是写)。在代码运行的时候race detector 就能监控到对共享变量的非同步访问,出现 race 的时候,就会打印出警告信息。
这个技术在 Google 内部帮了大忙,探测出了 Chromium 等代码的大量并发问题。Go 1.1中就引入了这种技术,并且一下子就发现了标准库中的42 个并发问题。现在,race detector已经成了Go持续集成过程中的一部分
我们来看看这个工具怎么用
在编译(compile)、测试(test) 或者运行 (run)Go 代码的时候,加上race 参数,就有可能发现并发问题。比如在上面的例子中,我们可以加上race 参数运行,检测一下是不是有并发问题。如果你 go run -race counter.go,就会输出警告信息
这个警告不但会告诉你有并发问题,而且还会告诉你哪个 goroutine 在哪一行对哪个变量有写操作,同时,哪个 goroutine 在哪一行对哪个变量有读操作,就是这些并发的读写访问,引起了 data race。
虽然这个工具使用起来很方便,但是,因为它的实现方式,只能通过真正对实际地址进行读写访问的时候才能探测,所以它并不能在编译的时候发现 data race 的问题。而且,在运行的时候,只有在触发了 data race 之后,才能检测到,如果碰巧没有触发(比如一个 data race 问题只能在2月14 号零点或者11 月 11 号零点才出现),是检测不出来的。
而且,把开启了 race 的程序部署在线上,还是比较影响性能的。
在编译的代码中,增加了 runtime.racefuncenter、 runtime.raceread.runtime.racewrite、runtime.racefuncexit 等检测 data race 的方法。通过这些插入的指令,Go race detector 工就能够成功地检测出 data race 问题了
总结一下,通过在编译的时候插入一些指令,在运行时通过这些插入的指令检测并发读写从而发现 data race 问题,就是这个工具的实现机制 既然这个例子存在 data race 问题,我们就要想办法来解决它。这个时候,我们这节课的主角Mutex 就要登场了,它可以轻松地消除掉 data race。
具体怎么做呢?下面,我就结合这个例子,来具体给你讲一讲 Mutex 的基本用法 我们知道,这里的共享资源是 count 变量,临界区是 count++,只要在临界区前面获取锁在离开临界区的时候释放锁,就能完美地解决 data race 的问题了
import (
"fmt"
"sync"
)
func main() {
var mu sync.Mutex
var count = 0
// 使用waitGroup等待10个goroutine完成
var wg sync.WaitGroup
wg.Add(10)
for i:= 0;i<10; i++ {
go func() {
defer wg.Done()
// 对变量count执行10次加1
for j := 0; j < 100000; j++ {
mu.Lock()
count++
mu.Unlock()
}
}
// 等待10个goroutine完成wg.wait()
fmt.PrintIn(count)
}
如果你再运行一下程序,就会发现,data race 警告没有了,系统干脆地输出了 1000000:
怎么样,使用 Mutex是不是非常高效?效果很惊喜 这里有一点需要注意: Mutex 的零值是还没有 goroutine 等待的未加锁的状态,所以你不需要额外的初始化,直接声明变量 (如 var mu sync.Mutex) 即可。
那Mutex还有哪些用法呢? 很多情况下,Mutex 会嵌入到其它struct 中使用,比如下面的方式
type Counter struct {
mu sync.Mutex
Count uint64
}
在初始化嵌入的struct时,也不必初始化这个 Mutex字段,不会因为没有初始化出现空指针或者是无法获取到锁的情况。 有时候,我们还可以采用嵌入字段的方式。通过嵌入字段,你可以在这个struct 上直接调用Lock/Unlock 方法。
type Counter struct {
sync.Mutex
Count uint64
}
如果嵌入的 struct 有多个字段,我们一般会把 Mutex 放在要控制的字段上面,然后使用空格把字段分隔开来。即使你不这样做,代码也可以正常编译,只不过,用这种风格去写的话逻辑会更清晰,也更易于维护
type Counter struct {
Name string
mu sync.Mutex
count uint64
}