Mutex:如何解决资源并发访问?
并发问题,会困扰很多开发者,比如多个Goroutine并发更新同一个资源,像同时更新用户的账户信息;秒杀系统等等。如果没有互斥控制,就会出现一些异常情况,比如用户的账户可能出现透支、秒杀系统出现超卖等等,后果都很可怕。
这些问题怎么解决呢?
用互斥锁,那在 Go 语言里,就是 Mutex。下面详细了解互斥锁的实现机制,以及 Go 标准库的互斥锁 Mutex 的基本使用方法。
互斥锁的实现机制
互斥锁是并发控制的一个基本手段,要理解互斥锁的本质,要先搞懂一个概念—-临界区。
在并发里,如果程序中的一部分会被并发访问或修改,那么,为了避免并发访问导致的不可预期的结果,这部分程序需要被保护起来,这部分被保护起来的程序,就叫做临界区。
临界区就是一个被共享的资源,比如对数据库的访问、对某一个共享数据结构的操作等等。
如果很多线程同步访问临界区,就会造成访问或操作错误,这当然是不对的,也是不可接受的。所以,我们可以使用互斥锁,限定临界区只能同时由一个线程持有。当临界区由一个线程持有的时候,其它线程如果想进入这个临界区,就会返回失败,或者是等待。直到持有的线程退出临界区,这些等待线程中的某一个才有机会接着持有这个临界区。

你看,互斥锁就很好地解决了资源竞争问题,有人也把互斥锁叫做排它锁。那在 Go 标准库中,它提供了 Mutex 来实现互斥锁这个功能。
Mutex 是使用最广泛的同步原语(Synchronization primitives)。但是同步原语,并没有一个严格的定义,你可以把它看作解决并发问题的一个基础的数据结构。
先看一下同步原语的适用场景:
1 共享资源—-并发地读写共享资源,会出现数据竞争(data race)的问题,所以需要 Mutex、RWMutex 这样的并发原语来保护。
2 任务编排—-需要 goroutine 按照一定的规律执行,而 goroutine 之间有相互等待或者依赖的顺序关系,常用 WaitGroup 或者 Channel 来实现。
3 消息传递—-信息交流以及不同的 goroutine 之间的线程安全的数据交流,常用 Channel 来实现。
Mutex 的基本使用方法
看Mutex 的使用方法之前,先看一下Locker 接口。在 Go 语言的标准库中,sync包提供了锁相关的一系列同步原语,这个包还定义了一个 Locker 的接口,Mutex 就实现了这个接口。Locker 的接口定义了锁同步原语的方法集:
type Locker interface {Lock()Unlock()}
上面的代码是Go语言定义的锁接口的方法集,是不是很简单?就是请求锁(Lock)和释放锁(Unlock)这两个方法,这是Go 语言一贯的简洁风格。
它在实际项目应用得不多,因为我们一般会直接使用具体的同步原语,而不是通过接口。
Mutex实现了 Locker 接口,所以首先我把这个接口介绍一下。
func(m *Mutex) Lock()func(m *Mutex) Unlock()
当一个 goroutine 通过调用 Lock 方法获得了这个锁的拥有权后, 其它请求锁的 goroutine 就会阻塞在 Lock 方法的调用上,直到锁被释放并且自己获取到了这个锁的拥有权。
说人话就是: 在火车站的任意一个窗口,1次只能让一个人买票或查询车票
看到上面的例子应该就知道为什么要加锁了吧!是的,如果不加锁,1个窗口对N个人买票和查询,很容易出错,甚至把车票给到错误的人。
看下面的代码:
func main() {var num= 0var wg sync.WaitGroupwg.Add(10)for i := 0; i < 10; i++ {go func() {defer wg.Done()for j := 0; j < 100000; j++ {num ++}}()}wg.Wait()fmt.Println(num)}
使用WaitGroup来等待所有的Goroutine执行完毕后,再最终打印结果。sync.WaitGroup是控制等待一组 goroutine 全部做完任务。
上面的代码,每次运行,可能得到不一样的结果,你可以试试,你想得到一百万的结果,几乎是不可能完成的任务。
你觉得非常疑惑?
这是因为,num++ 不是一个原子操作,它至少包含几个步骤,比如读取变量 num 的当前值,对这个值加 1,把结果再保存到 num 中。因为不是原子操作,就可能有并发的问题。
10 个 goroutine 同时读取到 num的值为 9999,接着各自按照自己的逻辑加 1,值变成了 10000,然后把这个结果再写回到 num 变量。但是,实际上,此时我们增加的总数应该是 10 才对,这里却只增加了 1,像这样的计数都被“吃”掉了。这是并发访问共享数据的常见错误。
这种并发问题,即使是有经验的人,也不太容易发现或者调试出来。
上面的共享资源是 num 变量,临界区是 num++,只要在临界区前面获取锁,在离开临界区的时候释放锁,就能完美地解决 data race 的问题了。
func main() {var mu sync.Mutexvar num = 0var wg sync.WaitGroupwg.Add(10)for i := 0; i < 10; i++ {go func() {defer wg.Done()for j := 0; j < 100000; j++ {mu.Lock()num++mu.Unlock()}}()}wg.Wait()fmt.Println(num)}
再运行一下程序,结果是 1000000
注意:Mutex 的零值是还没有 goroutine 等待的未加锁的状态,所以你不需要额外的初始化,直接声明变量即可。
Mutex还可以有如下使用方法:
type Num struct {mu sync.MutexNum int}
在初始化的 struct 时,也不必初始化这个 Mutex 字段,不会因为没有初始化出现空指针或者是无法获取到锁的情况。可以在这个struct上直接调用 Lock/Unlock 方法。
其他内容可以参考我的新书(有钱任性 5折,京东读书月活动)
