Mutex

Mutex 结构:

type Mutex struct {
	state int32		// 互斥锁的状态
	sema  uint32	// 信号量,阻塞的协程等待此信号量,执行解锁的协程释放信号量、唤醒其它等待此信号量的协程
}
const (
	mutexLocked = 1 << iota 	// Mutex 是否已被锁定,0 表示未锁定,1 表示锁定
	mutexWoken					// 是否有协程已被唤醒,0 表示没有,1 表示有(正在加锁过程中)
	mutexStarving				// Mutex 是否处于饥饿状态,0 表示没有饥饿,1 表示饥饿状态(有协程阻塞时间超过 1ms)
	mutexWaiterShift = iota		// 阻塞中的等待锁的协程个数
)

Mutex.state 内存布局:

29 bit 1bit 1bit 1bit
Waiter Starving Woken Locked

加解锁

协程抢锁是抢 Locked 位的赋值权,能将 Locked 置为 1 就说明抢锁成功。抢不到锁当前协程就会阻塞,Waiter 位加 1,等待 Mutex.sema 信号量,一旦持有锁的协程释放锁(Locked 变回 0),(若存在阻塞中的协程)就会释放信号量,阻塞协程会依次被唤醒,每唤醒一个协程 Waiter 位就减 1。

自旋

加锁时,如果当前 Locked 位为 1,说明当前该锁由其他协程持有,尝试加锁的协程并不是马上转入阻塞,而是会持续地探测 Locked 位是否变为 0,这个过程为自旋

自旋对应于 CPU 的 PAUSE 指令,CPU 对该指令什么都不做,相当于 CPU 空转,对程序而言相当于 sleep 了一小段时间。PAUSE 不同于 sleep,不需要将协程转为睡眠状态。

自旋是一种多线程同步机制,当前的进程在进入自旋的过程中会一直保持 CPU 的占用,持续检查某个条件是否为真。在多核的 CPU 上,自旋可以避免 goroutine 的切换,使用恰当会对性能带来很大的增益,使用不当会拖慢整个程序。

加锁时程序会自动判断是否可以自旋,自旋必须满足以下所有条件:

  • 自旋次数要足够少,通常为 4,即自旋最多 4 次;
  • CPU 核数要大于 1,否则自旋没有意义,因为此时不可能有其他协程释放锁;
  • 协程调度机制中的 Process 的数量要大于 1,比如使用 GOMAXPROCS() 将处理器设置为 1 就不能启用自旋;
  • 协程调度机制中的可运行队列必须为空,否则会延迟协程调度。

自旋目的是更充分地利用 CPU,尽量避免协程切换。因为当前申请加锁的协程占据了 CPU,如果经过短时间的自旋可以获得锁,则当前协程可以继续运行,不必进入阻塞状态。

自旋持续时间很短,自旋过程中发现锁被释放,协程可以立即获取锁,其它之前被阻塞的协程将无法获得锁。如果后来的执行加锁的协程特别多,而且常常都能通过自旋获得锁(插队),那么之前被阻塞的协程将很难获得锁,进入到“饥饿”状态。为了避免这一情况,1.8 版本增加了 Starving 位,有 Normal 和 Starving 两种状态。Starving 状态下不会自旋,一旦有协程释放锁,一定会唤醒一个协程并成功加锁。

释放锁时如果发现有阻塞协程,会释放一个信号量来唤醒一个阻塞协程,被唤醒的协程得到 CPU 后开始运行,此时若发现锁已被抢占(其它协程自旋),自己只好再次阻塞,阻塞前会判断自上次阻塞到本次阻塞经过了多长时间,若超过 1ms,则会将 Mutex 标记为“饥饿”模式,然后阻塞。

其它

第一次使用后,Mutex.state 变为 mutexLocked,此时复制得到的 Mutexstate 也是 mutexLocked,复制这一动作造成 Mutex 初始状态不明确。
https://go.dev/play/p/UW82-TA-BYU

Woken 状态用于加锁和解锁过程中的通信:同一时刻,两个协程一个在加锁一个在解锁,在加锁的协程可能在自旋过程中,此时把 Woken 标记为 1,用于通知解锁协程不必释放信号量,因为自旋中的加锁协程马上可以拿到锁,避免了其它之前的阻塞协程被唤醒却又拿不到锁带来的损耗。

重复解锁触发 panic 的考虑:Unlock 分为将 Locked 置为 0 和 判断 Waiter 值两个过程,Waiter > 0 则释放信号量。如果允许多次执行 Unlock,有可能每次都会释放一个信号量,导致多个协程的唤醒,多个协程唤醒后会继续在 Lock 逻辑里抢锁,这样会增加 Lock 实现的复杂度,也会引起不必要的协程切换。

RWMutex

结构:

type RWMutex struct {
	w           Mutex  // held if there are pending writers
	writerSem   uint32 // semaphore for writers to wait for completing readers
	readerSem   uint32 // semaphore for readers to wait for completing writers
	readerCount int32  // number of pending readers
	readerWait  int32  // number of departing readers
}

读写锁实现的功能:

  • 写锁阻塞写锁;
  • 写锁阻塞读锁;
    • const rwmutexMaxReaders = 1 << 30
    • 进行写锁定时,会先将 readerCount减去 2^30readerCount 变成负值,此时再有读锁定到来时检测到 readerCount 为负值,便知道有写操作在进行,于是阻塞等待。真实的读操作个数只需将 readerCount 加上 2^30
  • 读锁阻塞写锁;
    • 判断 readerCount 是否大于 0。
  • 读锁不阻塞读锁。

Lock 的写锁定逻辑:

  • 获取互斥锁 w,获取不到则排队等待;
  • readerCount 大于 0,则阻塞等待所有读操作结束。

Unlock 的写解锁逻辑:

  • readerCount 大于 0,唤醒阻塞的读操作(readerSem);
  • 释放互斥锁,若有其它阻塞的写操作则会进行唤醒(w.sema)。

RLock 读锁定逻辑:

  • readerCount++
  • 若有写操作,阻塞等待写操作结束,等待 readerSem 信号量。

RUnlock 读解锁逻辑:

  • readerCount--
  • 若有写锁定且当前是最后一个 reader,唤醒阻塞的写操作(writerSem)。

写操作要等待读操作结束后才可以获得锁,写操作等待期间可能还有新的读操作持续到来,如果写操作等待所有读操作结束,则很可能被“饿死”,于是引入了 readerWait。获得写锁的协程(由于 read 大于 0,仍被阻塞),会把 readerCount 的值复制到 readerWait 中,用于标记排在写操作前面的读者个数。前面的读操作结束后,除了会递减 readerCount 的值,也会递减 readerWait 的值,当 readerWait 的值变为 0 时唤醒写操作。

一次写操作把连续的读操作划分成写操作之前和之后的两部分,写操作之前的读操作结束后唤醒写操作,写操作结束后唤醒后面的读操作。