Go Source Code Analysis RWmutex

Dancing on the Shoulders of Giants

 

If you haven’t read the mutex article, please read my article about mutex first. RWmutex is implemented based on mutex, just like dancing on the shoulders of giants.
https://medium.com/gitconnected/go-source-code-sync-mutex-3082a25ef092

This article was first published in the Medium MPP plan. If you are a Medium user, please follow me on Medium. Thank you very much.

In the process of using mutex, whether it is reading or writing, we use Mutex to ensure that only one goroutine accesses the shared resource. This is a bit “wasteful” in some cases. For example, in the case of few writes and many reads, even if there is no write operation for a period of time, a large number of concurrent read accesses have to be serialized under the protection of Mutex. At this time, using Mutex has a significant impact on performance. RWmutex is designed to optimize this problem.

What is RWMutex

Let’s explain the read-write lock RWMutex. The RWMutex in the standard library is a reader/writer mutex. At any given time, it can be held by any number of readers or by a single writer. This is the readers-writers problem, which can generally be divided into three categories based on the priority of read and write operations. The design and implementation of read-write locks are also divided into three categories based on the priority.

  • Read-preferring: A read-preferring design can provide high concurrency, but in highly competitive situations, it may lead to write starvation. This is because, if there are a large number of reads, this design will cause the write to only be able to acquire the lock after all the reads have released the lock.
  • Write-preferring: A write-preferring design means that if there is already a writer waiting for the lock, it will prevent new reader requests from acquiring the lock, thus prioritizing the writer. Of course, if some readers have already requested the lock, the new writer will also wait until the existing readers have released the lock before it can acquire it. Therefore, the priority in the write-preferring design is for new requests. This design mainly avoids writer starvation problems.
  • No specified priority: This design is relatively simple and does not distinguish between reader and writer priorities. In some scenarios, this design without specified priorities is more effective. The first type of priority can lead to write starvation, and the second type of priority may lead to read starvation. This access without specified priorities does not distinguish between reading and writing, and everyone has the same priority, solving the starvation problem.

The RWMutex design in the Go standard library is a write-preferring solution. A blocking Lock call excludes new reader requests from acquiring the lock.

Implementation of RWMutex

This article is based on go 1.21.4

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
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  
}  
const rwmutexMaxReaders = 1 << 30
// 
func (rw *RWMutex) Lock(){}
func (rw *RWMutex) Unlock() {}
func (rw *RWMutex) RLock(){}
func (rw *RWMutex) RUnlock(){}
func (rw *RWMutex) TryLock() bool{}
func (rw *RWMutex) TryRLock() bool{}
func (rw *RWMutex) RLocker() Locker{}
  • The field readerCount records the current number of readers (and whether there is a writer competing for the lock).
  • readerWait records the number of readers that need to wait for read completion when a writer requests the lock.

Implementation of RLock/RUnlock

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
func (rw *RWMutex) RLock() {
    if atomic.AddInt32(&rw.readerCount, 1) < 0 {
        // When rw.readerCount is negative, it means there is a writer waiting for the lock. Because the writer has a higher priority, the subsequent reader is blocked and put to sleep.
        runtime_SemacquireMutex(&rw.readerSem, false, 0)
    }
}
func (rw *RWMutex) RUnlock() {
    if r := atomic.AddInt32(&rw.readerCount, -1); r < 0 {
        rw.rUnlockSlow(r) // There are waiting writers
    }
}
func (rw *RWMutex) rUnlockSlow(r int32) {
    if atomic.AddInt32(&rw.readerWait, -1) == 0 {
        // The last reader, the writer finally has a chance to acquire the lock
        runtime_Semrelease(&rw.writerSem, false, 1)
    }
}

The second line adds 1 to the reader count. The readerCount field has a dual meaning: when there is no writer competing for or holding the lock, the readerCount is the same as the normal reader count we understand; if there is a writer competing for or holding the lock, the readerCount not only serves as the reader count, but also indicates whether there is a writer competing for or holding the lock (as explained later). In this case, the reader is blocked and waits for the writer to wake up (the fourth line).

When calling RUnlock, we need to subtract 1 from the reader count (the eighth line) because the number of readers has decreased by one. However, the return value of AddInt32 in the eighth line has another meaning. If it is negative, it means that there is a writer competing for the lock. In this case, the rUnlockSlow method is called to check whether all readers have released the read lock. If all readers have released the read lock, the waiting writer can be awakened. When one or more readers hold the lock, the writer competing for the lock waits for these readers to release the lock before it can hold the lock.

LOCK

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func (rw *RWMutex) Lock() {
    // First, solve the problem of other writers competing for the lock
    rw.w.Lock()
    // Reverse the readerCount to tell the readers that there is a writer competing for the lock
    r := atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders
    // If there are readers holding the lock, we need to wait
    if r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0 {
        runtime_SemacquireMutex(&rw.writerSem, false, 0)
    }
}

RWMutex is a multiple writer and multiple reader read-write lock, so there may be multiple writers and readers at the same time. In order to avoid writer competition, RWMutex uses a Mutex to ensure writer mutual exclusion. Once a writer obtains the internal mutex, it reverses the readerCount field from the original positive integer readerCount (>=0) to a negative number (readerCount-rwmutexMaxReaders), so that this field has two meanings (both the number of readers and whether there is a writer). Let’s look at the following code. In the fifth line, it also records the current number of active readers. The so-called active readers are those readers who hold the read lock and have not released it yet.

If readerCount is not 0, it means that there are readers holding the read lock, and RWMutex needs to save the current readerCount to the readerWait field (seventh line). At the same time, this writer enters a blocking waiting state (eighth line). Each time a reader releases the read lock (when calling the RUnlock method), the readerWait field is reduced by 1 until all active readers release the read lock, and then this writer can be awakened.

Unlock

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func (rw *RWMutex) Unlock() {
    // Tell the readers that there are no active writers
    r := atomic.AddInt32(&rw.readerCount, rwmutexMaxReaders)
    
    // Wake up the blocked readers
    for i := 0; i < int(r); i++ {
        runtime_Semrelease(&rw.readerSem, false, 0)
    }
    // Release the internal mutex
    rw.w.Unlock()
}

When a writer releases the lock, it reverses the readerCount field again (from a negative number to a positive number), and then wakes up the blocked readers (the fourth to sixth lines). Before returning from Unlock in RWMutex, the internal mutex needs to be released. After releasing it, other writers can continue to compete for the lock.

TryLock & TryRLock

The implementations of TryRLock and TryLock are both simple. They attempt to acquire the read lock or write lock, and if they fail, they return false. If they succeed, they return true. These two methods do not block and wait. Let’s look at the source code directly.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func (rw *RWMutex) TryLock() bool {  
    if !rw.w.TryLock() {   // A goroutine holds the write lock
       return false  
    }  
    // The read lock is occupied
    if !rw.readerCount.CompareAndSwap(0, -rwmutexMaxReaders) {  
       rw.w.Unlock()  
       return false  
    }  
    return true  
}

// TryRLock attempts to lock rw for reading and reports whether it succeeds.
func (rw *RWMutex) TryRLock() bool { 
	for { 
		c := rw.readerCount.Load() 
		// A goroutine holds the write lock 
		if c < 0 { 
			return false 
		} 
		// Attempt to acquire the read lock 
		if rw.readerCount.CompareAndSwap(c, c+1) {
		 return true 
		} 
	} 
}

Controversial Points

The controversial points of Mutex also exist in RWMutex, such as non-reentrancy and non-copyability. When writing code, you need to consider them carefully.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// An incorrect usage example of RWmutex
func main() {
    var mu sync.RWMutex

    // writer, wait a little, and then create a scenario where Lock is called
    go func() {
        time.Sleep(200 * time.Millisecond)
        mu.Lock()
        fmt.Println("Lock")
        time.Sleep(100 * time.Millisecond)
        mu.Unlock()
        fmt.Println("Unlock")
    }()

    go func() {
        factorial(&mu, 10) // Calculate the factorial of 10, 10!
    }()
    
    select {}
}

// Recursive call to calculate factorial
func factorial(m *sync.RWMutex, n int) int {
    if n < 1 { // Factorial exit condition 
        return 0
    }
    fmt.Println("RLock")
    m.RLock()
    defer func() {
        fmt.Println("RUnlock")
        m.RUnlock()
    }()
    time.Sleep(100 * time.Millisecond)
    return factorial(m, n-1) * n // Recursive call
}

Summary

  1. RWMutex is implemented based on Mutex.
  2. In scenarios with many reads and few writes, we can use RWMutex instead of Mutex to improve performance.
  3. At the same time, multiple readers can hold RWMutex through RLock().
  4. When a writer locks RWMutex through Lock(), it first prevents new readers from locking, and then waits for all readers to release the lock before locking.
  5. When a writer releases the lock through ULock(), it wakes up the waiting readers.
true
Last updated on Jun 28, 2024 18:32 CST
Built with Hugo
Theme Stack designed by Jimmy