Fork me on GitHub

版权声明 本站原创文章 由 萌叔 发表
转载请注明 萌叔 | https://vearne.cc

1.前言

在上一篇文章读写锁为什么那么快?(1)中,萌叔探讨了读写锁提高程序性能的原因。这一篇文章,萌叔将聊聊读写锁面临的2个问题。并看看标准库的是如何实现。

2.几个问题

Q1: 如何避免写锁饥饿或者读锁饥饿问题?

我们知道读写锁的特性:同一时刻允许多个线程(协程)对共享资源进行读操作, 也就是说多个线程(协程)可以多次加读锁。那么会出现这样一种情况,读锁还没有被完全释放的情况(读锁计数器大于0),又有新的读操作请求加读锁。这样写协程永远无法加上写锁,将会一直阻塞。

A1:

标准库实际上引入某种类似排队的机制

如上图所示,协程G1、G2、G3、G4并发访问共享资源。它们请求加锁的顺序为矩形的左边沿,耗时为矩形块的长度

  • t1时刻,G1加上了读锁,开始读操作
  • t2时刻,G2也加上了读锁,进行读操作
  • t3时刻,G3表示它要进行写操作
  • t4时刻,G4也想加读锁,并且现在共享资源上施加的读锁还没有完全释放(需要到t5时刻才能释放),但是由于G3已经表示它需要加写锁了,所以G4将会阻塞

所以实际的运行顺序是

Q2: 如何确保加读锁时,成本尽可能的低?

读写锁最适用的情况是读多写少的场景,降低读锁的加锁和释放锁的开销,才能提高整体的吞吐能力。

A2:

  • 读协程和写协程互斥仅用的到int32的整数
    萌叔虽然也使用condition实现了读写锁,但由于
    condition要求在判断条件是否满足,以及修改条件中相关联的变量时,都需要加互斥锁,因此萌叔的实现性能和标准库有一定的差距。
  • 写协程和写协程的互斥靠的是标准库的互斥锁

3.代码

标准库的实现很巧妙,阅读有一定的难度。萌叔将对部分代码进行注释,并讲解。

package sync

import (
    "internal/race"
    "sync/atomic"
    "unsafe"
)


type RWMutex struct {
    w           Mutex  // held if there are pending writers
    writerSem   uint32 // semaphore for writers to wait for completing readers
    /*
        用于标识
        1. 是否已经加了读锁或者写锁
        2. 有多少个协程想加读锁(包含已经加上读锁的)
    */
    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


func (rw *RWMutex) RLock() {
    ...
    /*
        rw.readerCount+1 表示有读协程想加读锁
        rw.readerCount < 0 
        就说明当前共享资源已经被加了写锁,或者排队的时候,读操作排在了某个写操作请求的后面,那么需要把自己挂起在rw.readerSem上
    */
    if atomic.AddInt32(&rw.readerCount, 1) < 0 {
        // A writer is pending, wait for it.
        runtime_SemacquireMutex(&rw.readerSem, false, 0)
    }
    ...
}


func (rw *RWMutex) RUnlock() {
    ...
    /* 
        rw.readerCount 小于0
        说明有写协程想要加写锁
        则需要唤醒写协程

    */
    if r := atomic.AddInt32(&rw.readerCount, -1); r < 0 {
        // Outlined slow-path to allow the fast-path to be inlined
        rw.rUnlockSlow(r)
    }
    ...
}

func (rw *RWMutex) rUnlockSlow(r int32) {
    // A writer is pending.
    if atomic.AddInt32(&rw.readerWait, -1) == 0 {
        // The last reader unblocks the writer.
        runtime_Semrelease(&rw.writerSem, false, 1)
    }
}


func (rw *RWMutex) Lock() {
    ...
    // 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.
    /*
        只要不等于0,就说明当前共享资源已经被加了读锁
        那就把自己挂起在rw.writerSem上
    */
    if r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0 {
        runtime_SemacquireMutex(&rw.writerSem, false, 0)
    }
    ...
}


func (rw *RWMutex) Unlock() {
    ...
    // Announce to readers there is no active writer.
    r := atomic.AddInt32(&rw.readerCount, rwmutexMaxReaders)
    ...
    // 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()
    ...
}

Golang的标准库的读写锁实现具有某种偏向性(偏向读操作)。
在施加写锁期间,只要有读协程表示想加读锁,等到写锁释放时。不管是否有其它写协程想要加写锁。都会优先把共享资源,让给读协程。

我们来看个示例。

如上图所示,请求加锁的顺序为矩形的左边沿,耗时为矩形块的长度。
G5加写锁的请求先于G4加读锁的请求。但是由于G5被G3的互斥锁所阻塞,所以它没有修改rw.readerCount。但是G4会修改rw.readerCount,表示有协程想加读锁。所以等到G3释放写锁(过程中也会释放互斥锁rw.w) 唤醒了G4。G5会发现G4其实已经插队了,排在它的前面。

实际执行顺序为

结论:标准库的读写锁实现对读操作有一定的偏向性

4. 总结

读写锁的有效代码还不到100行,但是隐藏的细节确非常多,推荐读者好好读读。


打赏我

微信支付码

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注