Golang RWMutex 原理解析

2,549 阅读6分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第19天,点击查看活动详情

此前的文章中我们了解了 Mutex 的设计思路,今天来看看实际应用中更高频的读写锁:sync.RWMutex。

什么是 RWMutex

  • A RWMutex is a reader/writer mutual exclusion lock.
  • The lock can be held by an arbitrary number of readers or a single writer.
  • The zero value for a RWMutex is an unlocked mutex.

翻译一下官方源码说明:

  • RWMutex 是一个读写互斥锁;
  • 这个锁可以被任意数量的 reader 或 一个 writer 所持有;
  • RWMutex 的零值是一个未上锁的锁。
func (rw *RWMutex) Lock()        // 写上锁
func (rw *RWMutex) Unlock()      // 写解锁
func (rw *RWMutex) RLock()       // 读上锁
func (rw *RWMutex) RUnlock()     // 读解锁

为什么有了 Mutex 之后还要有 RWMutex?

问题的核心在于锁的粒度,回顾一下,Mutex 提供的能力是:

func (m *Mutex) Lock()
func (m *Mutex) Unlock()

简言之,只有加锁和解锁两个能力。不区分读写。也就意味着,如果是两个读请求并发到达,也会阻塞住一个,只允许一个进行读操作,此时另一个goroutine 就会就如到 m.lockSlow() 的流程里面,开启它自旋,休眠,被唤醒的一系列流程,才可能拿到锁。

但这里只是【读请求】,并不会真正更改数据状态,为什么要阻塞呢?事实上你放过去也没问题的。关键在于不能出现【读】vs 【写】的并发。如果只是两个【读】,逻辑上讲应该是可以并行的。这就是潜在的性能优化点。

这个时候 RWMutex 就派上用场了。回顾一下上面的官方说明,其中最重要的一句在于:

The lock can be held by an arbitrary number of readers or a single writer.

锁的持有者只有两种情况:

  1. 任意数量的 reader(对应到读请求);
  2. 一个 writer(写请求)。

换句话说,reader 不需要互相等待,他们只需要等待 writer 释放锁就 ok。如果现在没有 writer 持有锁,大家可以一起上。

所以,对于【读多写少】的场景,使用 RWMutex 能够减少程序阻塞的时间,用细粒度的锁提升了性能。

用法示例

这里我们看一个使用 sync.RWMutex 的例子。逻辑很简单:针对一个 int 类型变量 n,启动三个协程:

  • 协程1: 读操作:校验 n 是否为偶数;
  • 协程2: 读操作:校验 n 是否为正数;
  • 协程3: 写操作:将 n 自增 1。

读协程用 RLock() RUnLock() 写协程用 Lock() UnLock()

package main

import (
  "fmt"
  "sync"
  "time"
)

func isEven(n int) bool {
  return n%2 == 0
}

func main() {
  n := 0
  var m sync.RWMutex

  // goroutine 1
  // Since we are only reading data here, we can call the `RLock` 
  // method, which obtains a read-only lock
  go func() {
    m.RLock()
    defer m.RUnlock()
    nIsEven := isEven(n)
    time.Sleep(5 * time.Millisecond)
    if nIsEven {
      fmt.Println(n, " is even")
      return
    }
    fmt.Println(n, "is odd")
  }()

  // goroutine 2
  go func() {
    m.RLock()
    defer m.RUnlock()
    nIsPositive := n > 0
    time.Sleep(5 * time.Millisecond)
    if nIsPositive {
      fmt.Println(n, " is positive")
      return
    }
    fmt.Println(n, "is not positive")
  }()

  // goroutine 3
  // Since we are writing into data here, we use the
  // `Lock` method, like before
  go func() {
    m.Lock()
    n++
    m.Unlock()
  }()

  time.Sleep(time.Second)
}

执行上面的代码多次,你会发现,前两个读协程可以并发读取 n,但是和写协程一定是隔离开的。

实现解析

看源码之前先要想清楚一些根本的设定。实现读写锁要解决的问题是什么?归纳起来就是三点:

  1. 写锁需要同时阻塞写锁和读锁:一个协程拥有写锁时,其他协程的无论【读锁】还是【写锁】都要阻塞;
  2. 读锁需要阻塞写锁:一个协程拥有读锁时,其他协程的【写锁】需要阻塞;
  3. 读锁不阻塞读锁:一个协程拥有读锁是时,其他协程也可以拥有【读锁】。

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
}

这里可以看出 RWMutex 也是基于 Mutex 的能力进行的包装,我们记得 Mutex 只包含了一个 state 代表状态,一个 sem 代表信号量。这里读写锁为了进一步拆分粒度。增加了下面几个变量:

  • writerSem: 写阻塞等待的信号量,最后一个读者释放锁时,会释放该信号量;
  • readerSem: 读阻塞的协程等待的信号量,持有写锁的协程释放锁后,会释放该信号量;
  • readerCount: 拿到锁的 readers 数量
  • readerWait: 正在写阻塞时的的 readers 数量。
const rwmutexMaxReaders = 1 << 30

这里我们也能看到,读写锁支持的最大并发读协程的数量是 1 << 30,也就是 2 的 30次方个,这已经非常大了,几乎不会有日常的业务开发能触及到这个量级。

信号量

针对 writerSem 和 readerSem 两个信号量,可能有些同学不熟悉到底怎么用。

其实这里只需要复用 runtime 提供的信号量机制来控制即可。注意下面两个 runtime 提供的函数:


// Semacquire waits until *s > 0 and then atomically decrements it.
// It is intended as a simple sleep primitive for use by the synchronization
// library and should not be used directly.
func runtime_Semacquire(s *uint32)


// Semrelease atomically increments *s and notifies a waiting goroutine
// if one is blocked in Semacquire.
// It is intended as a simple wakeup primitive for use by the synchronization
// library and should not be used directly.
// If handoff is true, pass count directly to the first waiter.
// skipframes is the number of frames to omit during tracing, counting from
// runtime_Semrelease's caller.
func runtime_Semrelease(s *uint32, handoff bool, skipframes int)

这两个函数分别对应到 wakeup 和 sleep 原语。

  • runtime_Semacquire: 持续等待,直到 *s 大于 0 为止,然后原子性地递减它。(sleep)
  • runtime_Semrelease: 原子性地递增 *s 并通知等待的 goroutine。(wakeup)

如果不存在并发,所有锁都已经被释放,信号量 sem 应该保持的是等于 0 的状态。

那问题来了,如果是默认的零值,那我直接 acquire 岂不是永远会卡住?一般不都是加锁 acquire,解锁 release 么?但是我就一个 goroutine,上来就 acquire,会怎么样?

这个是一个错误的理解,信号量本身是一种睡眠唤醒机制,如果没有出现并发阻塞的场景,预期是不需要 acquire 的。通常的处理流程是这样:

  1. goroutine A 拿到锁;
  2. goroutine A 执行业务逻辑;
  3. goroutine B 也来尝试拿锁,发现这时候已经被占用,需要阻塞,此时才去 acquire;
  4. goroutine A 业务逻辑执行完毕,发现还有 goroutine 在等待,此时 release,完成解锁。

若第四步 goroutine A 发现没有阻塞的协程,就直接返回了,无需release。

那这个 goroutine B 阻塞的状态是存到了哪儿?以及 goroutine A 怎么发现还有 goroutine 在等待呢?

这就是个根据场景而定的问题了,这里我们结合 Mutex 的源码简要说明:

在 Mutex 中, sema 是和 state 变量配合,通过改变 state 的值来让锁感知到是否需要 acquire 和 release。假设没有遇到任何并发,直接的加锁和解锁其实很简单:

const (
	mutexLocked = 1 << iota // mutex is locked
	mutexWoken
	mutexStarving
)

// 加锁
atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) 

// 解锁
atomic.AddInt32(&m.state, -mutexLocked) // 此处 mutexLocked 为 1

可以看到,加锁只是一个很简单的 CAS 原子操作,更新到 m.state。解锁也只是 -1。这样的 fast path 下,其实完全用不到信号量 m.sema。只有在并发的场景下,处理才会变化。

加锁时,发现 CAS 没有 swap 成功,goroutine 会进入到 m.lockSlow() 的流程里,判断锁的模式(normal or starvation),判断自旋条件等,在这里面阻塞到 runtime_SemacquireMutex(&m.sema, queueLifo, 1),等待 sema release。

解锁时,发现 AddInt32 原子操作之后结果不为0(正常流程下 m.state 通过加锁变为 1,解锁变回0),此时意味着已经有一些 goroutine 阻塞了,类似的会进入到 m.unlockSlow() 的流程中,在这里触发 release 操作:runtime_Semrelease(&m.sema, false, 1)

感兴趣的同学可以详细看一看 Mutex 源码在这部分的处理。

实现思路

在看具体的几个加锁解锁方法前,请思考一下,如果让我们来基于上面这样一个 RWMutex 结果,设计读写锁的方案,应该注意什么呢?

有了整体的思路以后,再看具体四个方法,你会豁然开朗。下面的部分,我们会一点点开始思考,最终看看关键的问题,Golang 是怎样解决的。

RWMutex 中的信号量实现

看到 RWMutex 定义了 readerSem 和 writerSem,分别代表读协程和写协程需要监听的信号量,可以想到,这是为了保证读写互斥而做的设计。这里的 acquire 和 release 应该是相互的。

  • 读协程在加锁的时候去 acquire readerSem, 解锁的时候去 release writerSem;
  • 写协程在加锁的时候去 acquire writerSem, 解锁的时候去 release readerSem。

naive 的思考

  • RLock 既然读锁可以同时被多个协程同时持有(readerCount 就是用来记录这里的数量),我们并不需要单独维护一个变量,类似互斥锁里的 m.state。只需要先把 readerCount 加一,说明有一个 reader 过来了即可。

相比于 Mutex 的 Lock,我们的 RLock 的 fast path 从 CAS atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) 变成了简单的 atomic.AddInt32(&rw.readerCount, 1)

在 CAS 的版本中,我们通过判断 swap 是否成功,来断定要不要走 slow path,也即上锁是否成功。那现在我们只是给 readerCount 加了 1,虽然读请求并发不成问题,但是怎么知道这时候有没有一个写协程在执行逻辑呢?这个关乎到要不要去 acquire 我们的 readerSem。

  • RUnlock 先把 readerCount 减一,说明有一个 reader 要跑路。但注意,同时可能有很多个 reader,说不好自己是不是最后一个,如果是,显然这个时候不能直接减一就溜,因为你还得去判断有没有 writer 在等,如果有,要唤醒 writerSem 那个信号量。告诉人家:reader 都收工了,你来写吧。

问题来了,怎么判断自己是最后一个 reader 呢?以及怎样判断是否有 writer 在后面等呢?

  • Lock 写操作加锁其实直接复用 Mutex 的能力即可,这也是 RWMutex 内置一个 Mutex 的原因。但是仅仅通过了 Mutex 的 Lock() 还不够,前面提到过,如果现在读锁被 readers 持有,是不能给写操作加锁的。所以需要监听信号量,等 writerSem 被 release 了才算持有。

但是和上面的问题类似,我们怎样判断是否当前读锁已经被持有呢?

另外我们还有一个设计的要求:写操作一旦加锁,按照 RWMutex 的预期要【阻塞读锁】。这一步怎么做?

  • UnLock 写操作解锁要做的事情也是明确的,首先,我们需要告知阻塞的读协程,你们可以继续了。然后调用 Mutex 的 UnLock() 即可。
// 释放 readerSem,注意可能等待的 goroutine 很多,这里需要多次释放
// 每次释放本质上就是允许一个协程 acquire
for i := 0;i < xxx; i++ {
    runtime_Semrelease(&rw.readerSem, false, 0)
}

// 允许其他写操作来获取写锁
rw.w.Unlock()

问题:阻塞的读协程的数量 xxx 应该怎么计算?

官方解法

上面我们自己过了一遍各个接口可能的实现,看过源码后才会理解,其实官方对这些问题的解答,其精髓在于 readerCount 和 readerWait 两个变量。

我们来看看这些场景,官方到底是怎么解决的:

  1. 写操作互相阻塞 这一点和我们的预判相符,写操作之间的控制依赖于内置的 Mutex。

  2. 写操作阻塞读操作 这里是重点,一定要注意。

在不考虑写操作的前提下,我们可以认为每次 RLock 会将 readerCount + 1,每次 RUnLock 会将 readerCount - 1。加上我们此前提到的【最大reader数量】rwmutexMaxReaders = 1 << 30,即 2 的 30 次方。可以看出来,readerCount 的取值范围是[0, 1073741824]。

在执行写锁定,即 Lock() 时,会先将 readerCount 减去 1073741824,这样就小于0了。

再有读锁定到来的时候,检测 readerCount,发现为负数,就知道此时已经有写操作进行了,读需要阻塞。

而真实的【读操作的数量】,只需要 readerCount 再加上 1073741824 就拿到了。

一句话:写操作是将 readerCount 变成负值来阻止读操作的。此时,就会开始尝试 acquire readerSem,阻塞在这里。

由此可以看出,readerCount 语义上的确代表了【并发中所有读操作的数量】,只不过有可能被 writer 变成了减去 1073741824 之后的负数。如果小于0,记得加上 2的三十次方即可。

  1. 读操作阻塞写操作 写操作只要发现有正在进行的读操作,就应该停下来阻塞到这里。这个直接通过 readerCount 就可以判断。

  2. 如何保证写操作不会饿死 如果源源不断来【读操作】,会不会【写操作】一直无法执行?

答案是否定的,readerWait 就是为了解决这个问题才设计进去的。

原理:写操作上锁的 Lock() 函数中,会把 readerCount 的值复制到 readerWait,用来标记【排在写操作前面的 reader 个数】。

当排在前面的 reader 完事的时候,会递减 readerCount 的值,并同时递减 readerWait 的值。当 readerWait 等于 0 时会唤醒写操作。

一句话:写操作相当于把一段连续的读操作分成两个部分,前一部分完成的时候,通过 writerSem 唤醒写操作,实现插队的效果。

源码解析

好了,上面讨论了一大堆,我们来看看源码,看这些逻辑是怎样映射下来的。源码中涉及 race 的部分请先忽略,不影响我们对代码的理解。这次没有加注释,留下了官方源码,大家可以参考每段代码下面的分析。

Lock

// Lock locks rw for writing.
// If the lock is already locked for reading or writing,
// Lock blocks until the lock is available.
func (rw *RWMutex) Lock() {
	if race.Enabled {
		_ = rw.w.state
		race.Disable()
	}
	// First, resolve competition with other writers.
	rw.w.Lock()
	// Announce to readers there is a pending writer.
	r := atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders
	// Wait for active readers.
	if r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0 {
		runtime_SemacquireMutex(&rw.writerSem, false, 0)
	}
	if race.Enabled {
		race.Enable()
		race.Acquire(unsafe.Pointer(&rw.readerSem))
		race.Acquire(unsafe.Pointer(&rw.writerSem))
	}
}

  • 先用 Mutex 的 Lock() 阻断其他写协程进入;
  • 将 readerCount 转为负数(打个招呼,写协程准备拿锁了);
  • 将 readerCount 真正的正数值加到 readerWait,记录一下排在写操作前的 reader 有多少;
  • 若已经有读协程了,或排在前面的 reader 还没完,就老老实实待着,尝试 acquire writerSem,直到获取成功。
  • 若没有读协程了(readerCount 为 0),或没有排在前面的 reader 了(readerWait 为 0),就直接返回了,无需等待信号量。

UnLock

// Unlock unlocks rw for writing. It is a run-time error if rw is
// not locked for writing on entry to Unlock.
//
// As with Mutexes, a locked RWMutex is not associated with a particular
// goroutine. One goroutine may RLock (Lock) a RWMutex and then
// arrange for another goroutine to RUnlock (Unlock) it.
func (rw *RWMutex) Unlock() {
	if race.Enabled {
		_ = rw.w.state
		race.Release(unsafe.Pointer(&rw.readerSem))
		race.Disable()
	}

	// Announce to readers there is no active writer.
	r := atomic.AddInt32(&rw.readerCount, rwmutexMaxReaders)
	if r >= rwmutexMaxReaders {
		race.Enable()
		throw("sync: Unlock of unlocked RWMutex")
	}
	// Unblock blocked readers, if any.
	for i := 0; i < int(r); i++ {
		runtime_Semrelease(&rw.readerSem, false, 0)
	}
	// Allow other writers to proceed.
	rw.w.Unlock()
	if race.Enabled {
		race.Enable()
	}
}
  • 给 readerCount 还原回去,变成正数(打个招呼:写协程这边完事了);
  • 看现在 readerCount 有几个,就释放几次信号量,这边 release,reader 那边就能 acquire 到了;
  • 调用 Mutex 的 UnLock() 彻底解锁,其他写协程可以进来了。

RLock

// Happens-before relationships are indicated to the race detector via:
// - Unlock  -> Lock:  readerSem
// - Unlock  -> RLock: readerSem
// - RUnlock -> Lock:  writerSem
//
// The methods below temporarily disable handling of race synchronization
// events in order to provide the more precise model above to the race
// detector.
//
// For example, atomic.AddInt32 in RLock should not appear to provide
// acquire-release semantics, which would incorrectly synchronize racing
// readers, thus potentially missing races.

// RLock locks rw for reading.
//
// It should not be used for recursive read locking; a blocked Lock
// call excludes new readers from acquiring the lock. See the
// documentation on the RWMutex type.
func (rw *RWMutex) RLock() {
	if race.Enabled {
		_ = rw.w.state
		race.Disable()
	}
	if atomic.AddInt32(&rw.readerCount, 1) < 0 {
		// A writer is pending, wait for it.
		runtime_SemacquireMutex(&rw.readerSem, false, 0)
	}
	if race.Enabled {
		race.Enable()
		race.Acquire(unsafe.Pointer(&rw.readerSem))
	}
}
  • 给 readerCount 加 1;
  • 如果 readerCount 为负数(记得前面的结论么),说明此时已经有写协程在准备拿锁了,读这边等信号量吧;
  • 如果 readerCount > 0,直接返回,说明此时没有写协程拿着锁,可以正常读。

RUnlock

// RUnlock undoes a single RLock call;
// it does not affect other simultaneous readers.
// It is a run-time error if rw is not locked for reading
// on entry to RUnlock.
func (rw *RWMutex) RUnlock() {
	if race.Enabled {
		_ = rw.w.state
		race.ReleaseMerge(unsafe.Pointer(&rw.writerSem))
		race.Disable()
	}
	if r := atomic.AddInt32(&rw.readerCount, -1); r < 0 {
		// Outlined slow-path to allow the fast-path to be inlined
		rw.rUnlockSlow(r)
	}
	if race.Enabled {
		race.Enable()
	}
}

func (rw *RWMutex) rUnlockSlow(r int32) {
	if r+1 == 0 || r+1 == -rwmutexMaxReaders {
		race.Enable()
		throw("sync: RUnlock of unlocked RWMutex")
	}
	// A writer is pending.
	if atomic.AddInt32(&rw.readerWait, -1) == 0 {
		// The last reader unblocks the writer.
		runtime_Semrelease(&rw.writerSem, false, 1)
	}
}
  • 给 readerCount -1;
  • 如果 readerCount 为负数,说明有 writer 准备拿锁了,正在阻塞,走到 rUnlockSlow 路径上。
  • 将 readerWait 也减去1,看看轮没轮到 writer,如果还没减到0,说明writer 排位前还有别的 reader,那就轮不到这次给他机会了,直接返回。如果减到 0了,说明到人家 writer 了,释放 writerSem 信号量后再退出。
  • 如果 readerCount >= 0,说明一切正常,writer 没动静,解读锁成功,返回就行。

总结

可以看出来,RWMutex 的确是基于 Mutex 之上,通过信号量和计数来提供了对【读操作】额外拆出来一个锁粒度的效果。对于 readerCount 和 readerWait 的联动设计还是很巧妙的,建议大家好好看看。

因为 maxReaderCount 这个上限是一个固定的数,进而我们就可以基于 readerCount 的正负,额外传递出 writer 的状态,还是很厉害的。