Using Readers-Writer Locks to Boost Concurrency in Go Applications
Imagine you’re tasked with designing an event feed for an IPL match between CSK and MI. Users will connect through a mobile app to receive real-time match updates. For this scenario, let’s assume the backend application is written in Golang and does not use any databases. Instead, the events will be persisted in the application memory itself, represented as a slice of strings.
we’ll have a match recorder goroutine responsible for updating the content of the feed store, whenever a new event occurs. Mobile clients connect to the server via web socket connections for real-time updates. Each client connection is managed in a separate goroutine. Although there could be millions of people interested in the match, the number of event updates will be relatively low (around 5–10 events per minute).
Let’s write the code that handles both the client connection and event feed update:
package main
import (
"fmt"
"strconv"
"sync"
"time"
)
func matchRecorder(matchEvents *[]string, mutex *sync.Mutex) {
for i := 0; ; i++ {
mutex.Lock()
*matchEvents = append(*matchEvents, "Match event "+strconv.Itoa(i))
mutex.Unlock()
time.Sleep(200 * time.Millisecond)
}
}
func copyAllEvents(matchEvent *[]string) []string {
allEvents := make([]string, 0, len(*matchEvent))
for _, e := range *matchEvent {
allEvents = append(allEvents, e)
}
return allEvents
}
func clientHandler(matchEvents *[]string, mutex *sync.Mutex, at time.Time) {
for i := 0; i < 100; i++ {
mutex.Lock()
allEvents := copyAllEvents(matchEvents)
mutex.Unlock()
timeTaken := time.Since(at)
fmt.Println(len(allEvents), "events copied in", timeTaken)
}
}
func main() {
mutex := sync.Mutex{}
var matchEvents = make([]string, 0, 10000)
for j := 0; j < 10000; j++ {
matchEvents = append(matchEvents, "Match event")
}
go matchRecorder(&matchEvents, &mutex)
start := time.Now()
for j := 0; j < 5000; j++ {
go clientHandler(&matchEvents, &mutex, start)
}
time.Sleep(100 * time.Second)
}
In the provided code, matchRecorder
updates the matchEvents
slice every 200 milliseconds. While updating the event, it acquires the lock and then releases it once the event is added. The clientHandler
manages active client connections and calls copyAllEvents
, which simulates response generation. This method also acquires the lock while generating the response.
However, there’s an issue with this implementation. The reader goroutines are merely reading the content of matchEvents
, but with the current setup, only one goroutine can read the shared data at a time. Other reader goroutines will wait for their turn until the lock becomes available again. This impacts the system's throughput, as multiple clients could be served simultaneously instead of just one.
To address this problem, we can utilize a Readers-Writer Mutex Lock instead of a standard Mutex lock. The readers-writer lock allows concurrent reading of shared memory but only permits a single writer to update the memory. Let’s understand the concept of a readers-writer lock and then modify our approach accordingly.
What is a Readers-Writer Lock?
A readers-writer lock is a synchronization primitive that solves problems where a data structure is read by multiple threads (or goroutines in the case of Go) and written to by one or few threads. This type of lock makes programs efficient when there is a high ratio of read operations to write operations.
Golang provides the sync.RWMutex type, which is a readers-writer mutex. This lock allows multiple readers to hold the lock simultaneously, provided no writer holds the lock. on the other hand, a writer can only obtain the lock if no other readers or writers are holding the lock. This design helps to maximize concurrent read access, while still providing safety and consistency when writing data.
Here’s how it works:
- Read Lock (RLock()) — This method is used when a goroutine needs to read data. Multiple goroutines can acquire the read lock (RLock()) simultaneously, as long as no goroutine holds a write lock. This allows multiple readers to access the data concurrently.
- Read Unlock (RUnlock()) — A goroutine that has finished reading and has previously acquired a read lock must release it by calling RUnlock(). It’s crucial that each RLock() call has a corresponding RUnlock() to avoid deadlocks or other synchronization issues.
- Write Lock (Lock()) — This method is used when a goroutine needs to write data. A write lock is exclusive, meaning that no other goroutine can hold either a read lock or a write lock. The writer will wait until all existing readers and writers have released their locks before it can proceed with obtaining the lock.
- Write Unlock (Unlock()) — After finishing the write operation, the goroutine must call Unlock() to release the write lock, allowing other readers or writers to acquire the lock.
Using the Readers-Writer Lock
Equipped with knowledge of the readers-writer lock, let’s modify our previous implementation. In the new approach, we’ll utilize RWMutex
instead of a standard mutex. This way, all clients can concurrently read from shared memory, thereby increasing overall read throughput.
package main
import (
"fmt"
"strconv"
"sync"
"time"
)
func matchRecorder(matchEvents *[]string, mutex *sync.RWMutex) {
for i := 0; ; i++ {
mutex.Lock()
*matchEvents = append(*matchEvents, "Match event "+strconv.Itoa(i))
mutex.Unlock()
time.Sleep(200 * time.Millisecond)
}
}
func copyAllEvents(matchEvent *[]string) []string {
allEvents := make([]string, 0, len(*matchEvent))
for _, e := range *matchEvent {
allEvents = append(allEvents, e)
}
return allEvents
}
func clientHandler(matchEvents *[]string, mutex *sync.RWMutex, at time.Time) {
for i := 0; i < 100; i++ {
mutex.RLock()
allEvents := copyAllEvents(matchEvents)
mutex.RUnlock()
timeTaken := time.Since(at)
fmt.Println(len(allEvents), "events copied in", timeTaken)
}
}
func main() {
mutex := sync.RWMutex{}
var matchEvents = make([]string, 0, 10000)
for j := 0; j < 10000; j++ {
matchEvents = append(matchEvents, "Match event")
}
go matchRecorder(&matchEvents, &mutex)
start := time.Now()
for j := 0; j < 5000; j++ {
go clientHandler(&matchEvents, &mutex, start)
}
time.Sleep(100 * time.Second)
}
In this article, we explored using a readers-writer lock in read-heavy scenarios. In a future article, I will guide you through creating a write-preferred readers-writer lock from the ground up.