Mutex系列是根据我对晁岳攀老师的《Go 并发编程实战课》的吸收和理解整理而成,如有偏差,欢迎指正~
目标
本系列除了希望彻底学习和了解 golang 中 sync.Mutex 的原理和使用,更希望借 golang 中 Mutex 的发展和演变,了解并发场景下锁的设计与实现方法以及不同业务场景下的一些特殊考虑。
Mutex 简介
Mutex 是什么
Mutex 是 golang 标准库的互斥锁,主要用来处理并发场景下共享资源的访问冲突问题。
Mutex 定义
尽管 Mutex 的实现经历了多次的重大改版,但是因为设计的巧妙,使用上并没有发生任何变化。
package sync // import "sync"
type Mutex struct {
state int32
sema uint32
}
A Mutex is a mutual exclusion lock. The zero value for a Mutex is an
unlocked mutex.
A Mutex must not be copied after first use.
func (m *Mutex) Lock()
func (m *Mutex) Unlock()
从 Mutex 的定义一眼就能看出来如何使用,加锁使用 Lock 函数,解锁使用 Unlock 函数。
package sync
package sync // import "sync"
type Locker interface {
Lock()
Unlock()
}
A Locker represents an object that can be locked and unlocked.
Mutex 实现了 Locker 接口。除了互斥锁 Mutex,像之后会介绍的读写锁 RWMutex,也实现了 Locker 接口。
golang 中 Mutex 演变的4个阶段
现在去看 go1.14 中 Mutex 的实现,是比较复杂和精巧的,但是 Mutex 的复杂和精巧不是一蹴而就的。从初版的 Mutex,到现在的 Mutex,大致经过了以下4个阶段的演变:
接下来会通过这4个阶段对应的 Mutex 源码来理解 golang 在互斥锁的设计思路上的逐渐进化的过程。
希望通过这样一个学习,不仅能更好的掌握 Mutex 这个工具,还能学习到如何设计一个兼顾公平和性能的互斥锁。
初版 Mutex 实现
初版 Mutex 的具体实现如下:
// CAS操作,当时还没有抽象出atomic包
func cas(val *int32, old, new int32) bool
func semacquire(*int32)
func semrelease(*int32)
// 互斥锁的结构,包含两个字段
type Mutex struct {
key int32 // 锁是否被持有的标识
sema int32 // 信号量专用,用以阻塞/唤醒goroutine
}
// 保证成功在val上增加delta的值
func xadd(val *int32, delta int32) (new int32) {
for {
v := *val
if cas(val, v, v+delta) {
return v + delta
}
}
panic("unreached")
}
// 请求锁
func (m *Mutex) Lock() {
if xadd(&m.key, 1) == 1 { //标识加1,如果等于1,成功获取到锁
return
}
semacquire(&m.sema) // 否则阻塞等待
}
func (m *Mutex) Unlock() {
if xadd(&m.key, -1) == 0 { // 将标识减去1,如果等于0,则没有其它等待者
return
}
semrelease(&m.sema) // 唤醒其它阻塞的goroutine
}
理解代码之前,先简单介绍下 cas、semacquire 和 semrelease。
compare and setcompare and swap
semacquire 和 semrelease 利用信号量 sema 实现了阻塞和唤醒功能。
接下来我们开始分析上面的代码。
初版 Mutex 的定义
首先看 Mutex 的定义。这个初版的定义其实和最新版的定义的区别在 key 这个字段上。初版中,key 的含义比较简单,就是一个标志位,等于0表示锁未被持有,1表示被某个 goroutine 持有,等于 n 表示还有 n-1 个等待者。
加锁
加锁(Lock)的过程首先是给 key 加1。
如果 key 返回1,则表示当前 goroutine 占有了这把锁,其它 goroutine 只能做候选者。
如果 key 返回n(n > 1),这说明当前有其它 gorutine 正在占用这把锁,所以接下来需要通过信号量机制将当前 goroutine 挂起,加到等待队列,进入阻塞状态。
解锁
解锁(Unlock)的过程是给 key 减1。
如果 key 返回0,表示当前没有其它 goroutine 在等待,可以直接返回;如果 key 返回 n (n > 0),说明还有其它 goroutine 在等待,因此需要通过信号量机制将等待队列中的其它 goroutine 唤醒。
初版 Mutex 的问题
初版 Mutex 在实现的时候,有两个问题:1)Unlock 调用无限制;2)goroutine 唤醒机制性能低下。
Unlock 调用无限制问题
Mutex 本身并没有包含当前 goroutine 的任何信息,因此 Unlock 方法能被任意的 goroutine 调用。这样会导致一个问题,如果某个 goroutine 不按套路来,随便调用 Unlock 函数,让标志位 key 清零,那么数据竞争的问题还是会出现。
Mutex 的这个特性一直保留至今。因此使用 Mutex 的时候,一定要遵循 “谁加锁,谁解锁” 的原则。
goroutine 唤醒机制性能低下
初版 Mutex 唤醒 goroutine 的机制是按排队顺序,谁在前面就先唤醒谁。这样看着很公平,但是从性能上看,并不是最优。因为沉睡的 goroutine 唤醒之后,还需要进行上下文的切换,如果把唤醒机会给当前正占用 CPU 时间片的 goroutine,那么高并发的时候,可能会有更好的性能。
这也是下一个版本的 Mutex 重点解决的问题。
结尾
初版的 Mutex 通过 标志位 key 实现了互斥锁的基本功能。在下一个版本的 Mutex 中,我会重点阐述 Mutex 是如何解决 goroutine 唤醒机制性能低下的问题。
附:
1、初版 Mutex 的详细代码见 https://github.com/golang/go/blob/d90e7cbac65c5792ce312ee82fbe03a5dfc98c6f/src/pkg/sync/mutex.go
2、初版 cas 的详细代码见 https://github.com/golang/go/blob/weekly.2011-01-20/src/pkg/sync/asm_amd64.s
3、初版 semacquire 和 semrelease 的详细代码见 https://github.com/golang/go/blob/d90e7cbac65c5792ce312ee82fbe03a5dfc98c6f/src/pkg/runtime/sema.c