前言

在Go语言的并发编程世界中,锁机制是确保数据一致性和线程安全的重要工具。随着应用程序对并发性能要求的不断提升,传统的互斥锁(Mutex)虽然简单有效,但在某些场景下可能会成为性能瓶颈。

为了优化这种情况,Go语言引入了读写锁(sync.RWMutex),它允许多个goroutine同时读取数据,但写入时则保持独占访问,从而在不牺牲线程安全的前提下,提高了程序的并发性能。

读写锁的设计思想源于对数据访问模式的细致观察:在大多数并发应用场景中,数据的读取操作远多于写入操作。因此,读写锁通过允许多个读者同时访问数据,而在有写入者时阻塞所有其他读写操作,来实现对共享资源的更高效管理。这种设计既保证了数据的一致性,又最大化了资源的利用率。

读写锁的原理和数据结构

读写锁的原理可以概括为:

  • 每个锁分为读锁和写锁,读锁为共享锁,写锁是互斥锁。
  • 没有加写锁时,多个协程都可以加读锁
  • 加了写锁时,无法加读锁,读协程排队等待
  • 加了读锁时,无法加写锁,写协程排队等待

Go语言中的读写锁RWMutex的数据结构在sync/rwmutex.go中:

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 atomic.Int32 // number of pending readers  
    readerWait  atomic.Int32 // number of departing readers  
}

各个字段的解释如下:

  • w:被写协程持有的互斥锁
  • writerSem:写协程的等待队列
  • readerSem:读协程的等待队列
  • readerCount
    • 正值:正在读的协程。
    • 负值:加了写锁后,要加读锁的协程数量
  • readerWait:写锁应该等待的读协程个数

读写锁的操作

加写锁

没有读协程的情况

状态readerCount = 0

此时加写锁的步骤为:

  1. 竞争互斥锁w
  2. readCount=0变成readerCount = -rwmutexMaxReaders常量。

有读协程的情况

状态:readerCount > 0。例如,此时readCount=3

此时加写锁的步骤为:

  1. 竞争互斥锁w
  2. readCount = 3-rwmutexMaxReaders表示后面协程无法加读锁,并且前面有3个读写在等待。
  3. 计算需要等待多少个读协程释放:readerWait = 3
  4. 如果需要等待读协程释放,陷入writerSem休眠。

源码分析

查看sync/rwmutex.go,省略部分逻辑:

func (rw *RWMutex) Lock() {  
    // First, resolve competition with other writers.  
    rw.w.Lock()  
    // Announce to readers there is a pending writer.  
    r := rw.readerCount.Add(-rwmutexMaxReaders) + rwmutexMaxReaders  
    // Wait for active readers.  
    if r != 0 && rw.readerWait.Add(r) != 0 {
	   //  r!=0,进入写协程休眠队列
       runtime_SemacquireRWMutex(&rw.writerSem, false, 0)  
    } 
    // r = 0,直接加写锁成功
}

解写锁

状态w被持有,readerCount为负数,readerWait=0,读协程休眠队列存在若干协程。

  1. readerCount + rwmutexMaxReaders,允许读锁获取。
  2. 释放readerSem中等待的读协程。
  3. 解锁mutex

源码分析

Go里面的源码注释已经很清晰:

func (rw *RWMutex) Unlock() {    
    // Announce to readers there is no active writer.  
    r := rw.readerCount.Add(rwmutexMaxReaders)  
    if r >= rwmutexMaxReaders {  
       race.Enable()  
       fatal("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()  
}

加读锁

readerCount > 0

状态:readerCount > 0,例如readerCount = 2

此时加读锁的步骤为:

  • readerCount + 1
  • 如果readerCount > 0,加锁成功

readerCount < 0

状态:readerCount < 0,例如readerCount = 2- rwmutexMaxReaders

  • 情况1:写锁在排队。
  • 情况2:写锁在操作。

此时加读锁的步骤为:

  • readerCount + 1
  • 如果readerCount < 0,说明被加了写锁,此时协程陷入readerSem

源码分析

func (rw *RWMutex) RLock() {  
	// readerCount + ,如果 > 0表示加锁成功
    if rw.readerCount.Add(1) < 0 {  
       // A writer is pending, wait for it.  
       runtime_SemacquireRWMutexR(&rw.readerSem, false, 0)  
    }  
}

解读锁

算法步骤:

  • readerCount - 1
  • 如果readerCount>0,解锁成功。
  • 如果readerCount < 0,表示是负数,有写锁在排队。
    • 如果这个协程是readerWait 的最后一个,则唤醒写协程。

源码分析:

func (rw *RWMutex) RUnlock() {  
	// readerCount - 1
    if r := rw.readerCount.Add(-1); r < 0 {  
       // 有写协程在排队,需要进一步判断
       rw.rUnlockSlow(r)  
    }  
}
func (rw *RWMutex) rUnlockSlow(r int32) {  
    if r+1 == 0 || r+1 == -rwmutexMaxReaders {  
       race.Enable()  
       fatal("sync: RUnlock of unlocked RWMutex")  
    }  
    // 给readerWait-1,如果自己是最后一个读者,唤醒写协程
    if rw.readerWait.Add(-1) == 0 {  
       runtime_Semrelease(&rw.writerSem, false, 1)  
    }  
}

小结

通过对Go语言读写锁(sync.RWMutex)工作机制的深入剖析,我们了解到它如何在保持线程安全的同时,通过允许并发读取来提升程序的性能。读写锁内部复杂的锁降级、锁升级以及状态转换逻辑,共同构建了一个既灵活又高效的并发控制机制。

在实际编程中,合理使用读写锁对于提升程序的并发能力和响应速度至关重要。然而,也需要注意到读写锁并非万能的,它在某些极端情况下(如写入操作非常频繁)可能会退化为互斥锁,从而失去其优势。因此,在设计并发程序时,开发者应根据实际的应用场景和需求,综合考虑各种锁机制的特点,选择最合适的同步原语。