Go RWMutex 设计与实现

310 阅读9分钟

什么是 RWMutex

在 Go 语言中,sync.RWMutex(读写互斥锁)是一种用于对共享资源的读写访问的同步机制。底层基于 Mutex 实现的,可以说是 Mutex 的扩展,弥补了 Mutex 在读多写少的情况下的性能问题。

RWMutex 与 Mutex 的区别

在之前的文章 《揭秘 Go Mutex 实现与应用》中介绍过 Mutex 的作用,它可以确保在同一时刻只有一个 goroutine 可以访问共享资源,这种机制本质是防止并发读写对共享资源造成破坏,但是在实际编程中对共享资源的访问大多数是并发读或者是读多写少的场景,这种情况使用 Mutex 就会带来严重的性能问题,对于并发读和读多写少的场景 RWMutex 是比较好的解决方案。

RWMutex 的特点

  1. 支持并发读:允许多个 goroutine 同时读取共享资源,而不会相互阻塞。这对于读操作频繁的场景非常重要,可以提高程序的并发性能。

  2. 独占写:在进行写操作时,必须独占访问共享资源,即不允许其他 goroutine 同时进行读或写操作。这确保了在写操作期间数据的一致性。

  3. 高效性:在实现上要尽可能高效,减少锁竞争和开销,以提高程序的性能。

RWMutex 的方法:

  • RLock:获取读锁,读锁之间不会阻塞,读锁之间允许并发读,读锁和写锁之间会相互阻塞。
  • RUnlock:释放读锁。
  • Lock:获取写锁,如果有其他 goroutine 持有读锁或写锁,那么就会阻塞等待。
  • Unlock:释放写锁。

其他不常用的方法:

  • RLocker:返回一个读锁,该锁包含了 RLockRUnlock 方法,可以用来获取读锁和释放读锁。

  • TryLock: 尝试获取写锁,如果获取成功,返回 true,否则返回 false。不会阻塞等待。

  • TryRLock: 尝试获取读锁,如果获取成功,返回 true,否则返回 false。不会阻塞等待。

RWMutex示例

《深入理解 go map》中介绍过, map 并发读写会 panic,我们可以使用RWMutex 封装一个并发安全的 map:

type SafeMap struct {
    mu    sync.RWMutex
    dirty map[any]any
}

func (m *SafeMap) Load(key any) (value any, ok bool) {
    m.mu.RLock()
    value, ok = m.dirty[key]
    m.mu.RUnlock()
    return
}

func (m *SafeMap) Store(key, value any) {
    m.mu.Lock()
    if m.dirty == nil {
       m.dirty = make(map[any]any)
    }
    m.dirty[key] = value
    m.mu.Unlock()
}

实现原理

基于 Go 1.23

数据结构

type RWMutex struct {
    w           Mutex        // 互斥锁,上写锁时,会先加互斥锁
    writerSem   uint32       // writer 等待 reader 完成的信号量
    readerSem   uint32       // reader 等待 writer 完成的信号量
    readerCount atomic.Int32 // 所有 reader 的数量
    readerWait  atomic.Int32 // writer 等待完成的 reader 数量
}

字段含义:

  • w:互斥锁,用于保护读写锁的状态。RWMutex 的写锁是互斥锁,所以直接使用 Mutex 就可以了。

  • writerSem:writer 等待 reader 完成的信号量, 当加写锁时发现有没有完成的 reader 时 writer 就会阻塞。

  • readerSem:reader **等待 writer **完成的信号量, 当加读锁时发现有 writer 持有写锁,reader 就会阻塞。

  • readerCount:所有 reader 数量(包括已经获取读锁的和正在等待获取读锁的 reader)。

  • readerWait:writer 等待完成的 reader 数量,也就是获取写锁的这一时刻,已经获取到读锁的 reader 数量,writer 要等待 readerWait 变成 0 时才被唤醒真正的持有写锁。

上面 readerCount 其实有两种含义:

  1. readerCount 大于等于 0 时,代表所有 reader 的数量,就是加读锁且没有解锁的 goroutine 的数量。
  2. readerCount 小于 0 时,说明有 goroutine 正在争抢写锁,或者已经抢到写锁,此时readerCount 的值是 reader 的真正的数量减去 rwmutexMaxReaders
const rwmutexMaxReaders = 1 << 30

// 加写锁时 readerCount 会减去 rwmutexMaxReaders
r := rw.readerCount.Add(-rwmutexMaxReaders) + rwmutexMaxReaders

读锁(RLock)

func (rw *RWMutex) RLock() {
    if race.Enabled {
       _ = rw.w.state
       race.Disable()
    }
    
    // 增加 reader 的数量,如果数量小于 0 说明有 goroutine 
    if rw.readerCount.Add(1) < 0 {
       // A writer is pending, wait for it.
       runtime_SemacquireRWMutexR(&rw.readerSem, false, 0)
    }
    if race.Enabled {
       race.Enable()
       race.Acquire(unsafe.Pointer(&rw.readerSem))
    }
}

当一个 goroutine 调用RLock方法获取读锁时,它会增加读操作计数器。如果没有正在进行的写操作,并且没有等待的写操作,读操作可以立即成功。否则,读操作会被阻塞,直到所有的写操作完成并且没有等待的写操作。

解读锁(RUnlock)

func (rw *RWMutex) RUnlock() {
    // ...
    
    // 解锁时会 readerCount - 1
    if r := rw.readerCount.Add(-1); r < 0 {
       // 如果  readerCount < 0 则需要判断是否唤起 writer
       rw.rUnlockSlow(r)
    }
    // ...
}

func (rw *RWMutex) rUnlockSlow(r int32) {
    // 解锁未锁定的 RWMutex 会 panic 
    if r+1 == 0 || r+1 == -rwmutexMaxReaders {
       race.Enable()
       fatal("未锁定的 RWMutex 的解锁")
    }
    
    // 最后一个 reader 解锁时唤起 writer
    if rw.readerWait.Add(-1) == 0 {
       runtime_Semrelease(&rw.writerSem, false, 1)
    }
}

当一个 goroutine 调用RUnlock方法释放读锁时,它会将 readerCount 减 1。如果此时 readerCount < 0 说明有 writer 等待写锁,需要尝试唤醒 writer。

写锁(Lock)

func (rw *RWMutex) Lock() {
    // ...
    
    // 加互斥锁,确保只有一个 goroutine 加写锁
    rw.w.Lock()
    
    // 将 readerCount - rwmutexMaxReaders,此时是一个 < 0 的值,说明有 writer 要加写锁
    // 在加读锁的时候会判断 readerCount 是否小于 0,如果 小于 0 加读锁的 goroutine 就会被阻塞
    r := rw.readerCount.Add(-rwmutexMaxReaders) + rwmutexMaxReaders

    // r 的值是调用 Lock() 这一时刻 readerCount 的值,需要等待完成 reader 的数量
    if r != 0 && rw.readerWait.Add(r) != 0 {
       // 如果有未完成 reader 则需要阻塞 writer
       runtime_SemacquireRWMutex(&rw.writerSem, false, 0)
    }
    
    // ...
}

当一个 goroutine 调用Lock方法获取写锁时首先会获取互斥锁,确保只有一个 goroutine 加写锁,然后将当时的readerCount 赋值给 readerWait,readerWait 就是writer 等待未完成的 reader 数量。如果当前有正在进行的读操作或写操作,写操作会被阻塞,直到所有的读操作和写操作完成。一旦获取了互斥锁,写操作就可以独占访问共享资源。

解写锁(Unlock)

func (rw *RWMutex) Unlock() {
    // ...

    // readerCount 加上 rwmutexMaxReaders,说明写锁已经结束,此时 readerCount >= 0
    // 此时 r 的值是调用 Lock() 之后所有 reader 的数量,也就是等待写锁 reader 的数量
    r := rw.readerCount.Add(rwmutexMaxReaders)
    if r >= rwmutexMaxReaders {
       race.Enable()
       fatal("sync: Unlock of unlocked RWMutex")
    }
    
    // 唤醒所有因写锁而阻塞的 reader
    for i := 0; i < int(r); i++ {
       runtime_Semrelease(&rw.readerSem, false, 0)
    }
    
    // 释放互斥锁.
    rw.w.Unlock()
    // ...
}

这里有一个设计细节,所有对 readerCount 和 readerWait 的原子操作使用的都是 Add 方法而不是 Store 方法,是因为 readerCount 和 readerWait 的原子操作可能是并发进行的,如果使用 Store 方法可能会出现相互覆盖的问题。

使用场景

  1. 读多写少的场景:当共享资源的读操作远远多于写操作时,使用sync.RWMutex可以提高程序的并发性能。例如,一个配置文件在程序运行期间主要是被读取,偶尔被修改。

  2. 保护复杂数据结构:对于复杂的数据结构,如哈希表、链表等,如果读操作频繁,使用sync.RWMutex可以在不影响读性能的情况下,确保写操作的安全。

  3. 并发缓存:在构建并发缓存时,sync.RWMutex可以用于保护缓存的读写操作。读操作可以同时进行,而写操作需要独占访问缓存。

以下是一个使用sync.RWMutex的示例代码:

package main

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

type SafeMap struct {
    m    map[string]int
    lock sync.RWMutex
}

func NewSafeMap() *SafeMap {
    return &SafeMap{
        m: make(map[string]int),
    }
}

func (sm *SafeMap) Put(key string, value int) {
    sm.lock.Lock()
    sm.m[key] = value
    sm.lock.Unlock()
}

func (sm *SafeMap) Get(key string) (int, bool) {
    sm.lock.RLock()
    value, ok := sm.m[key]
    sm.lock.RUnlock()
    return value, ok
}

func main() {
    safeMap := NewSafeMap()

    // 模拟并发写入
    go func() {
        for i := 0; i < 10; i++ {
            safeMap.Put(fmt.Sprintf("key%d", i), i)
            time.Sleep(time.Millisecond * 100)
        }
    }()

    // 模拟并发读取
    for i := 0; i < 10; i++ {
        go func(id int) {
            for {
                value, ok := safeMap.Get(fmt.Sprintf("key%d", id%10))
                if ok {
                    fmt.Printf("Goroutine %d: Value for key%d is %d\n", id, id%10, value)
                }
                time.Sleep(time.Millisecond * 50)
            }
        }(i)
    }

    time.Sleep(time.Second * 5)
}

在这个示例中,SafeMap使用sync.RWMutex来保护对map的读写操作。并发的写入和读取操作可以同时进行,提高了程序的并发性能。

注意事项

避免死锁

  1. 正确的锁顺序:如果在代码中同时使用了sync.RWMutex和其他互斥锁或同步原语,要确保以固定的顺序获取锁,以避免死锁。例如,如果一个函数先获取了sync.RWMutex的读锁,然后又尝试获取另一个互斥锁,而另一个函数先获取了那个互斥锁,然后又尝试获取sync.RWMutex的写锁,就可能导致死锁。

    package main
    
    import (
        "fmt"
        "sync"
    )
    
    var rwMutex sync.RWMutex
    var mutex sync.Mutex
    
    func badExample1() {
        rwMutex.RLock()
        mutex.Lock()
        // 这里可能导致死锁,如果另一个 goroutine 以相反的顺序获取锁
        fmt.Println("Inside badExample")
        mutex.Unlock()
        rwMutex.RUnlock()
    }
    
    func badExample2() {
        mutex.Lock()
        rwMutex.Lock()
        // 这里可能导致死锁,如果另一个 goroutine 以相反的顺序获取锁
        fmt.Println("Inside badExample")
        rwMutex.Unlock()
        mutex.Unlock()
    }
    
    func main() {
        go badExample1()
        go badExample2()
    }
    
  2. 不要在持有写锁时尝试获取读锁或写锁:在持有sync.RWMutex的写锁时,不要尝试再次获取读锁或写锁,否则会导致死锁。同样,在持有读锁时,也不要尝试获取写锁,除非先释放了读锁。

    package main
    
    import (
        "fmt"
        "sync"
    )
    
    var rwMutex sync.RWMutex
    
    func badExample() {
        rwMutex.Lock()
        // 错误:在持有写锁时尝试获取读锁
        rwMutex.RLock()
        fmt.Println("Inside badExample")
        rwMutex.Unlock()
        rwMutex.RUnlock()
    }
    
    func main() {
        go badExample()
    }
    

避免频繁的锁切换

频繁地在读锁和写锁之间切换可能会导致性能下降。如果可能的话,尽量减少锁的获取和释放次数,以提高程序的性能。

例如,如果一个函数在短时间内多次获取读锁和写锁,可能会导致锁的竞争和开销增加。可以考虑将多个读操作合并为一个,或者将写操作提前规划好,减少锁的切换次数。

package main

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

var value int
var rwMutex sync.RWMutex

func badExample() {
    for i := 0; i < 10; i++ {
        rwMutex.RLock()
        fmt.Println("Read value:", value)
        rwMutex.RUnlock()
        rwMutex.Lock()
        value++
        fmt.Println("Wrote value:", value)
        rwMutex.Unlock()
    }
}

func main() {
    go badExample()
    time.Sleep(time.Second * 5)
}

合理使用读锁和写锁的数量

根据实际的并发需求,合理地使用读锁和写锁的数量。如果读操作非常频繁,而写操作相对较少,可以适当增加读锁的数量,以提高并发性能。但要注意不要过度使用读锁,以免影响写操作的性能。

例如,如果一个共享资源主要是被读取,而写操作很少发生,可以考虑使用多个 goroutine 同时持有读锁,以提高读取的并发度。但如果写操作也比较频繁,过多的读锁可能会导致写操作被长时间阻塞。

package main

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

var value int
var rwMutex sync.RWMutex

func manyReaders() {
    for i := 0; i < 10; i++ {
        go func() {
            rwMutex.RLock()
            fmt.Println("Read value:", value)
            time.Sleep(time.Second)
            rwMutex.RUnlock()
        }()
    }
}

func oneWriter() {
    time.Sleep(time.Second * 2)
    rwMutex.Lock()
    value = 100
    fmt.Println("Wrote value:", value)
    time.Sleep(time.Second)
    rwMutex.Unlock()
}

func main() {
    manyReaders()
    oneWriter()
    time.Sleep(time.Second * 5)
}

总之,sync.RWMutex是 Go 语言中一种非常有用的同步机制,它可以在支持并发读的同时,确保写操作的独占性,适用于各种需要对共享资源进行读写同步的场景。