Dawn's Blogs

分享技术 记录成长

0%

GO专家编程读书笔记 (3) 常见控制结构实现原理之mutex rwmutex

Mutex

数据结构

mutex 互斥锁的数据结构如下:

  • state:互斥锁的状态
  • sema:信号量,协程阻塞等待该信号量,解锁的协程释放信号量从而唤醒等待信号量的协程。
1
2
3
4
type Mutex struct {
state int32
sema uint32
}

Mutex.state 是 32 位的整型变量,内部实现时把该变量分成四份,用于记录 Mutex 的四种状态

  • Locked:这个互斥锁是否被锁定。协程之间抢锁实际上是抢给 Locked 赋值的权利,能给 Locked 域置 1,就说明抢锁成功。
  • Woken:是否有协程正在加锁过程中。
  • Starving:互斥锁是否处于饥饿状态,饥饿状态说明有协程阻塞了超过 1ms。
  • Waiter:表示阻塞等待锁的协程个数,协程解锁时根据此值来判断是否需要释放信号量。

img

加锁解锁过程

加锁

协程 A 成功加锁,协程 B 再加锁被阻塞的过程如下:

img

解锁

协程 A 解锁,释放信号量唤醒协程 B 过程如下。协程 A 解锁过程分为两个步骤:

  • 一是把 Locked 位置 0。
  • 二是查看到 Waiter>0,所以释放一个信号量,唤醒一个阻塞的协程,被唤醒的协程 B 把 Locked 位置 1,于是协程 B 获得锁。

img

自旋过程

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

如果在自旋过程中发现锁已被释放,那么协程可以立即获取锁。此时即便有协程被唤醒也无法获取锁,只能再次阻塞。

自旋的好处是,当加锁失败时不必立即转入阻塞,有一定机会获取到锁,这样可以避免协程的切换。

自旋带来的问题就是,如果加锁的协程特别多,每次都通过自旋获得锁,那么之前被阻塞的进程将很难获得锁,从而进入饥饿状态。所以 Go 1.8 新增一个 starving 状态,这个状态下不会自旋,一旦有协程释放锁,那么一定会唤醒一个协程并成功加锁。

无限制的自旋也会浪费 CPU,必须满足以下所有条件,才会发生自旋:

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

starving 和 woken 状态

starving

自旋过程中能抢到锁,一定意味着同一时刻有协程释放了锁。释放锁时如果发现有阻塞等待的协程,还会释放一个信号量来唤醒一个等待协程,被唤醒的协程得到 CPU 后开始运行,此时发现锁已被抢占了,自己只好再次阻塞。不过阻塞前会判断自上次阻塞到本次阻塞经过了多长时间,如果超过 1ms 的话,会将 Mutex 标记为饥饿 starving 模式,然后再阻塞。

处于饥饿模式下,不会启动自旋过程,也即一旦有协程释放了锁,那么一定会唤醒协程,被唤醒的协程将会成功获取锁,同时也会把等待计数减 1。

woken

Woken 状态用于加锁和解锁过程的通信,如:

同一时刻,两个协程一个在加锁,一个在解锁,在加锁的协程可能在自旋过程中,此时把 Woken 标记为 1,用于通知解锁协程不必释放信号量了,好比在说:你只管解锁好了,不必释放信号量,我马上就拿到锁了。

RWMutex

数据结构

RWMutex 为读写锁,其数据结构如下:读写锁中有一个写锁,用于读写互斥。

1
2
3
4
5
6
7
type RWMutex struct {
w Mutex //用于控制多个写锁,获得写锁首先要获取该锁,如果有一个写锁在进行,那么再到来的写锁将会阻塞于此
writerSem uint32 //写阻塞等待的信号量,最后一个读者释放锁时会释放信号量
readerSem uint32 //读阻塞的协程等待的信号量,持有写锁的协程释放锁后会释放信号量
readerCount int32 //记录读者个数
readerWait int32 //记录写阻塞时读者个数
}

解锁解锁过程

Lock 过程

写锁定做两件事:

  • 首先获取互斥锁。
  • 阻塞等待,直到所有读操作结束。

Unlock 过程

解除写锁做两件事:

  • 唤醒所有因为读操作而阻塞的协程。
  • 解除互斥锁。

RLock 过程

读锁定做两件事:

  • 增加读操作计数,即 readerCount++。
  • 若有写锁定,则阻塞等待写操作结束。

RUnlock过程

解除读锁定做两件事:

  • 减少读操作计数,即readerCount–。
  • 如果当前是最后一个读者,且有写锁定,则唤醒等待写操作的协程。

为什么写锁定不会饿死

写操作要等待读操作结束后才可以获得锁,写操作等待期间可能还有新的读操作持续到来,如果写操作等待所有读操作结束,很可能被饿死。然而,通过 RWMutex.readerWait 可完美解决这个问题。写操作到来时:

  • 把 RWMutex.readerCount 值拷贝到 RWMutex.readerWait 中,用于标记排在写操作前面的读者个数
  • 前面的读操作结束后,除了会递减 RWMutex.readerCount,还会递减 RWMutex.readerWait 值,当 RWMutex.readerWait 值变为 0 时唤醒写操作

所以,写操作就相当于把一段连续的读操作划分成两部分,前面的读操作结束后唤醒写操作,写操作结束后唤醒后面的读操作。

img