头图

foreword

Hello, everyone, my name is asong .

In the previous article: Interviewer: How much have you learned about the Go language mutex? We have learned how the mutual exclusion lock is implemented in the Go language. In this article, we will learn how the read-write lock in the Go language is designed. The mutual exclusion lock can ensure that there will be no competition when multiple threads access the same piece of memory. To ensure concurrency safety, because the mutex locks the critical section of the code, when the concurrency is high, the lock competition will be intensified, and the execution efficiency will become worse and worse; therefore, a more fine-grained lock is derived: read-write lock, It is suitable for the scenario of reading more and writing less. Next, we will look at the read-write lock in detail.

Golang version: 1.118

Introduction to Read-Write Locks

We all know that the mutual exclusion lock will lock the code critical section. When one goroutine acquires the mutex lock, any goroutine cannot acquire the mutex lock, and can only wait for goroutine Release the mutual exclusion lock, no matter the read and write operations, a large lock will be added, and the efficiency will be very low in the scenario of reading more and writing less, so the big guys have designed a read-write lock, as the name suggests A lock is divided into two parts: a read lock and a write lock. The read lock allows multiple threads to acquire at the same time, because the read operation itself is thread-safe, while the write lock is a mutual exclusion lock, which does not allow multiple threads to acquire writes at the same time. Locks, and write operations and read operations are also mutually exclusive. To sum up: read and read are not mutually exclusive, read and write are mutually exclusive, and write and write are mutually exclusive;

Why have read locks

Some friends may wonder why there is a read lock, and the read operation will not modify the data. It is safe for multiple threads to read the same resource at the same time. Why add a read lock?

For example, the assignment of variables in Golang is not concurrently safe. For example, if a variable of type int is executed, the count++ operation will be executed under concurrency. An unexpected result occurs because the count++ operation is divided into three parts: reading the value of count count adding the value of ---42dcab0265720bbaaf49ae71164b1320--- to ---0cbd36- 1 , and then assign the result to count , this is not an atomic operation, when multiple threads execute the variable at the same time without locking count++ operation will cause data inconsistency. Adding a write lock can solve this problem, but what if we don't add a read lock when reading? Write an example to see, only add write lock, no read lock:

 package main

import "sync"

const maxValue = 3

type test struct {
    rw sync.RWMutex
    index int
}

func (t *test) Get() int {
    return t.index
}

func (t *test)Set() {
    t.rw.Lock()
    t.index++
    if t.index >= maxValue{
        t.index =0
    }
    t.rw.Unlock()
}

func main()  {
    t := test{}
    sw := sync.WaitGroup{}
    for i:=0; i < 100000; i++{
        sw.Add(2)
        go func() {
            t.Set()
            sw.Done()
        }()
        go func() {
            val := t.Get()
            if val >= maxValue{
                print("get value error| value=", val, "\n")
            }
            sw.Done()
        }()
    }
    sw.Wait()
}

operation result:

 get value error| value=3
get value error| value=3
get value error| value=3
get value error| value=3
get value error| value=3
.....

The result of each operation is not fixed, because we do not have a read lock. If simultaneous reading and writing are allowed, the read data may be an intermediate state, so we can conclude that a read lock is necessary. Can prevent reading and writing of intermediate values.

Queue-cutting strategy for read-write locks

It is also thread-safe when multiple read operations are performed at the same time. After one thread acquires the read lock, another thread can also acquire the read lock, because the read lock is shared. If there is always a thread that adds a read lock, another thread adds a write later. The lock will not be able to obtain the lock all the time and cause blocking. At this time, some strategies are needed to ensure the fairness of the lock and avoid lock starvation. Then Go What is the queue-cutting strategy used by the read-write lock in the language to avoid it? What about hunger?

Here we use an example to illustrate the Go language's queue-cutting strategy:

假设现在有5个goroutine G1G2G3G4G5 , now G1 , G2 read lock was acquired successfully, but the read lock has not been released, G3 To perform a write operation, it will block if it fails to acquire the write lock Wait, the number of read locks currently blocking the write lock goroutine is 2:

Follow-up G4 comes in and wants to acquire a read lock, then she will judge if there is a write lock goroutine is blocking waiting, in order to avoid write lock starvation, then this G4 It will also enter the blocking wait, and G5 will come in and want to acquire the write lock, because G3 is occupying the mutex, so G5 will enter the spin /sleep blocking waiting;

Now G1 , G2 released the read lock. When releasing the read lock, it is judged that if the number of read lock goroutines blocking the write lock goroutine is 0 and there is a write lock waiting, it will wake up and block. The waiting write lock G3 , G3 got woken up:

G3 The write lock will be released after the write operation is processed. This step will also wake up the waiting read lock/write lock goroutine , as for G4 , G5 gets the lock first depends on who is faster, just like robbing a daughter-in-law.

Implementation of read-write lock

Next, let's analyze the source code in depth. Let's take a look at the RWMutex structure:

 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
}
  • w : the capability provided by the multiplexing mutex;
  • writerSem : write operation goroutine block waiting for the semaphore, when blocking the read operation of the write operation goroutine when the read lock is released, the blocked write operation is notified by this semaphore goroutine ;
  • readerSem : read operation goroutine block waiting for semaphore, when write operation goroutine release the write lock, the semaphore notifies the blocked read operation goroutine ;
  • redaerCount : The number of read operations currently being executed goroutine ;
  • readerWait : The number of read operations waiting when the write operation is blocked goroutine ;

read lock

The corresponding method of read lock is as follows:

 func (rw *RWMutex) RLock() {
  // 原子操作readerCount 只要值不是负数就表示获取读锁成功
    if atomic.AddInt32(&rw.readerCount, 1) < 0 {
        // 有一个正在等待的写锁,为了避免饥饿后面进来的读锁进行阻塞等待
        runtime_SemacquireMutex(&rw.readerSem, false, 0)
    }
}

The method of race state detection is simplified, and the read lock method has only two lines of code. The logic is as follows:

Use the atomic operation to update readerCount , add readercount to the value 1 , as long as the value after the atomic operation is not negative, it means the read lock is successful, if the value is negative A write lock has successfully acquired the mutex, and the write lock goroutine is waiting or running, so in order to avoid starvation, the read lock that comes in later needs to be blocked and waited, call runtime_SemacquireMutex blocking and waiting.

Non-blocking plus read lock

Go Language introduces the method of non-blocking read lock in 1.18 :

 func (rw *RWMutex) TryRLock() bool {
    for {
    // 读取readerCount值能知道当前是否有写锁在阻塞等待,如果值为负数,那么后面的读锁就会被阻塞住
        c := atomic.LoadInt32(&rw.readerCount)
        if c < 0 {
            if race.Enabled {
                race.Enable()
            }
            return false
        }
    // 尝试获取读锁,for循环不断尝试
        if atomic.CompareAndSwapInt32(&rw.readerCount, c, c+1) {
            if race.Enabled {
                race.Enable()
                race.Acquire(unsafe.Pointer(&rw.readerSem))
            }
            return true
        }
    }
}

Because the read lock is shared, multiple threads can acquire at the same time when there is no blocking wait for the write lock, so the atomic operation may fail. It is very clever to use the for loop to increase the number of attempts.

release read lock

The code for releasing the read lock is mainly divided into two parts, the first part:

 func (rw *RWMutex) RUnlock() {
  // 将readerCount的值减1,如果值等于等于0直接退出即可;否则进入rUnlockSlow处理
    if r := atomic.AddInt32(&rw.readerCount, -1); r < 0 {
        // Outlined slow-path to allow the fast-path to be inlined
        rw.rUnlockSlow(r)
    }
}

We all know that the value of readerCount represents the number of currently executing read operations goroutine , and the value after the decrement operation is greater than or equal to 0 indicates that there are currently no abnormal scenarios or write lock blocking waiting, so directly Just exit, otherwise you need to deal with these two logics:

rUnlockSlow logic is as follows:

 func (rw *RWMutex) rUnlockSlow(r int32) {
  // r+1等于0表示没有加读锁就释放读锁,异常场景要抛出异常
  // r+1 == -rwmutexMaxReaders 也表示没有加读锁就是释放读锁
  // 因为写锁加锁成功后会将readerCout的值减去rwmutexMaxReaders
    if r+1 == 0 || r+1 == -rwmutexMaxReaders {
        race.Enable()
        throw("sync: RUnlock of unlocked RWMutex")
    }
    // 如果有写锁正在等待读锁时会更新readerWait的值,所以一步递减rw.readerWait值
  // 如果readerWait在原子操作后的值等于0了说明当前阻塞写锁的读锁都已经释放了,需要唤醒等待的写锁
    if atomic.AddInt32(&rw.readerWait, -1) == 0 {
        // The last reader unblocks the writer.
        runtime_Semrelease(&rw.writerSem, false, 1)
    }
}

Read this code:

  • r+1 Equal to 0 that the current goroutine Release the read lock without adding the read lock, which is an illegal operation
  • r+1 == -rwmutexMaxReaders Indicating that the write lock is successfully readerCount , the subtraction of rwmutexMaxReaders becomes a negative number. If there is no read lock before, then the read lock will be released directly. It will cause this equation to be established, and it is also an illegal operation to release the read lock without adding a read lock;
  • readerWait represents the number of goroutine 50d5d4d9b967647b36dc5689c74c6551---representing the read operation when the write operation is blocked. If there is a write lock waiting, it will update the value of readerWait , when the read lock releases the lock readerWait needs to be decremented. If the decrement is equal to 0 , it means that the read locks currently blocking the write lock have been released, and the waiting write lock needs to be awakened. (Look at the code for writing the lock below to echo it)

write lock

The methods corresponding to write locks are as follows:

 const rwmutexMaxReaders = 1 << 30
func (rw *RWMutex) Lock() {
    // First, resolve competition with other writers.
  // 写锁也就是互斥锁,复用互斥锁的能力来解决与其他写锁的竞争
  // 如果写锁已经被获取了,其他goroutine在获取写锁时会进入自旋或者休眠
    rw.w.Lock()
    // 将readerCount设置为负值,告诉读锁现在有一个正在等待运行的写锁(获取互斥锁成功)
    r := atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders
    // 获取互斥锁成功并不代表goroutine获取写锁成功,我们默认最大有2^30的读操作数目,减去这个最大数目
  // 后仍然不为0则表示前面还有读锁,需要等待读锁释放并更新写操作被阻塞时等待的读操作goroutine个数;
    if r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0 {
        runtime_SemacquireMutex(&rw.writerSem, false, 0)
    }
}

The amount of code is not very large, but it is still a bit complicated to understand. I try to parse it with text, which is mainly divided into two parts:

  • To acquire a mutex lock, a write lock is also a mutex lock. Here we reuse the locking ability of the mutex lock mutex . When the mutex lock is successfully locked, other write locks goroutine When trying to acquire the lock again, it will enter spin sleep waiting;
  • To judge whether the acquisition of the write lock is successful, there is a variable rwmutexMaxReaders = 1 << 30 representing the maximum support 2^30 concurrent reads, after the mutex is successfully locked, suppose 2^30 reads操作都readerCount读锁,通过原子操作将---7443e1275dedf45cdd46508a60923e0e 2^30r 0 Speaking that there is still a read operation in progress, the write lock needs to wait, and at the same time, the readerWait field is updated through an atomic operation, that is, the read operation waiting for the update write operation is blocked goroutine ; readerWait The above read lock will be judged when the lock is released, and it will be decremented. The current readerWait will wake up the write lock when it decreases to 0 .

non-blocking write lock

Go语言 Introduced a non-blocking locking method in 1.18 :

 func (rw *RWMutex) TryLock() bool {
  // 先判断获取互斥锁是否成功,没有成功则直接返回false
    if !rw.w.TryLock() {
        if race.Enabled {
            race.Enable()
        }
        return false
    }
  // 互斥锁获取成功了,接下来就判断是否是否有读锁正在阻塞该写锁,如果没有直接更新readerCount为
  // 负数获取写锁成功;
    if !atomic.CompareAndSwapInt32(&rw.readerCount, 0, -rwmutexMaxReaders) {
        rw.w.Unlock()
        if race.Enabled {
            race.Enable()
        }
        return false
    }
    return true
}

release write lock

 func (rw *RWMutex) Unlock() {
    // Announce to readers there is no active writer.
  // 将readerCount的恢复为正数,也就是解除对读锁的互斥
    r := atomic.AddInt32(&rw.readerCount, rwmutexMaxReaders)
    if r >= rwmutexMaxReaders {
        race.Enable()
        throw("sync: Unlock of unlocked RWMutex")
    }
    // 如果后面还有读操作的goroutine则需要唤醒他们
    for i := 0; i < int(r); i++ {
        runtime_Semrelease(&rw.readerSem, false, 0)
    }
    // 释放互斥锁,写操作的goroutine和读操作的goroutine同时竞争
    rw.w.Unlock()
}

The logic of releasing the write lock is relatively simple. Releasing the write lock will wake up both the read and write operations in the goroutine , and then they will compete;

Summarize

Because we have shared the implementation of mutual exclusion locks above, it is much easier to look at read-write locks. At the end of the article, let's summarize the read-write locks:

  • The read-write lock provides four operations: read-lock, read-unlock, write-lock, and write-unlock; the locking rules are read-read sharing, write-write mutual exclusion, read-write mutual exclusion, and write-read mutual exclusion;
  • The read lock in the read-write lock must exist, and its purpose is to avoid the atomicity problem. Only the write lock without the read lock will cause us to read the intermediate value;
  • The read-write lock of Go language also avoids the problem of write lock starvation in design. It is controlled by the fields readerCount , readerWait , when the write lock goroutine is controlled by When blocked, those who come in later and want to acquire the read lock goroutine will also be blocked. When the write lock is released, the subsequent read operations will be goroutine , and the write operations goroutine wake them all up, let them compete for the rest;
  • Read lock acquisition lock process:

    • When the lock is idle, the read lock can be acquired immediately
    • If a write lock is currently blocked, the one who wants to acquire the read lock goroutine will be put to sleep
  • Release the read lock process:

    • If there is no abnormal situation or the write lock is blocked and waiting to appear, the read lock will be released directly.
    • If the read lock is released without the read lock, an exception is thrown;
    • In the scenario where the write lock is blocked by the read lock, the value of readerWait will be decremented, readerWait represents the number of read operation goroutines that block the write operation goroutine, when readerWait When reduced to 0 , the blocked write operation can be woken up goroutine ;
  • Write lock acquisition lock process

    • The write lock is multiplexed mutex the ability of mutex, first try to acquire the mutex, if the acquisition fails, it will enter spin/sleep;
    • The success of acquiring the mutex does not mean that the write lock is successfully added. If there is still a read lock goroutine , then it will be blocked, otherwise the write lock will be added successfully.
  • Release write lock process

    • Releasing the write lock will turn the negative value readerCount into a positive value, releasing the mutual exclusion of the read lock
    • Wake up all currently blocked read locks
    • release mutex

The code amount of the read-write lock is not much, because it reuses the design of the mutex lock, and has done some work on the function of the read-write lock. It is much easier to understand than the mutex lock. Have you learned it? Baby~.

Well, this article ends here, I'm asong , see you next time.

Welcome to the public account: Golang Dream Factory


asong
605 声望906 粉丝