Demystifying the Read-Write Lock Pattern in Go: Simple Strategies for Easy Concurrency

Photo by Pixabay: https://www.pexels.com/photo/black-android-smartphone-on-top-of-white-book-39584/

Introduction

In another article we discussed the Lock pattern. In this we used the sync.Mutex struct. The problem with this struct is, is that it doesn’t distinguish between reading from a resource, like accessing an element in a vector, and writing to it.

However, if a resource is primarily read, having multiple readers waiting to acquire a lock is unneccsary, since reads do not cause data inconsistencies.

How does it work?

Usually, a read write lock provides two kinds of locks:

  1. A Read lock: This is intended for reading from a resource. Multiple threads can have a read lock on the same resource at the same time. Also note that a read lock does not block other read blocks.
  2. A Write lock: the main difference is that only one thread at a time can acquire a write lock. A write lock blocks all read locks and other write locks from being acquired. This ensures that no other thread can access the resource while it is being modified.

Benefits

There are several benefits to using a normal lock:

  1. Increased concurrency for reads: In read-heavy scenarios, many threads can read a resource, improving throughput.
  2. Data consistency: Because still only one thread at a time can modify the resource, but many can read it, data consistency is preserved.
  3. Reduced contention: The read write lock allows concurrent reads, thereby significantly reducing contention for the shared resource in many cases.

Drawbacks and considerations

Like with any other pattern there are some drawbacks and things to consider:

  1. Complexity: Implementing Read-Write Locks is more complex than a simple mutex.
  2. Overhead: Managing the state of Read-Write is a bit more complicated because of the different lock types. If you have a short critical section a simple mutex could actually be faster.
  3. Writer Starvation: If the lock is designed to prioritize readers, having many readers could prevent a waiting writer from ever acquiring a lock.
  4. Reader Starvation: If the lock prioritizes writers, new readers could be blocked if a writer is waiting.
  5. Deadlocks: If not implemented carefully, Read-Write Locks can lead to deadlocks, for example if two threads holding a read lock, trying to acquire a write lock.

When to use?

Use this pattern when:

  • Reads are more frequent than writes.
  • The cost of acquiring and releasing the lock can be justified by the performance gains from concurrent reads.

Implementation in Go

As an example use case we will implement a configuration. Configurations are usually modified very infrequently and read more often.

Let’s start by importing these:

package main

import (
	"fmt"
	"sync"
	"time"
)

Line by line:

  1. Start with the package declaration.
  2. The test program will printout some status information, so import the fmt package.
  3. In this example we will use Read-Write Locks as well as wait groups, and that is why the sync package imported.
  4. To simulate a real-world situation, each go routine will sleep for a certain amount of time.

The Config struct

Now it is time to define the Config struct:

type Config struct {
	settings map[string]string
	mu       sync.RWMutex
}

Line by line:

  1. The struct stores the actual settings in a map, with key type of string and a value type of string.
  2. To make access to these configuration values thread-safe we will use a sync.RWMutex which is an implementation of a Read Write Lock in Go’s standard library.

Note that the property names in this struct all start with lower case, which means that they are private and they can only be accessed from instance methods, like the Get() and the Set() method as we shall see. Limiting access to the internals of the struct this way is also a way of limiting the probability of data races.

The first thing to do is create a new config struct using the NewConfig() method:

func NewConfig() *Config {
	return &Config{
		settings: make(map[string]string),
	}
}

All this does is create the configuration values map.

The first thing we want to with our newly created config is get a value from it, using a Get() method:

func (c *Config) Get(key string) (string, error) {
	c.mu.RLock()
	defer c.mu.RUnlock()

	value, exists := c.settings[key]
	if !exists {
		return "", fmt.Errorf("key not found: %s", key)
	}
	return value, nil
}

Line by line:

  1. This method receives the key of the configuration value as its single parameter, and it returns either the value if one is found or an error if none is found.
  2. This method will only read, so it will only need to acquire a read lock, using the RLock() method. This call will block execution until such a read lock can be acquired.
  3. Before exiting the method, this lock needs to be released. This is done using the defer keyword, and the RUnlock() method.
  4. Next the method determines whether the value exists in the map. If not an error is returned, with an empty string for the value.
  5. If a value has been found it is returned with nil for the error.

The Set() method makes sure that values can be found:

func (c *Config) Set(key, value string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.settings[key] = value
	fmt.Printf(("Setting '%s' to '%s'\n"), key, value)
}

Line by line:

  1. This method receives two parameters: the key, i.e. the name of the configuration value, and the value itself.
  2. Try to acquire a write lock by call the Lock() method. This method will block execution until a write lock has been acquired.
  3. Modify the settings map by changing or adding the key to it.
  4. Notify the user of the result.

Simulating a worker

The next function will simulate a worker. Note that a worker will only read from the configuration:

func worker(id int, cfg *Config, wg *sync.WaitGroup) {
	defer wg.Done()
	fmt.Printf("Worker %d starting\n", id)
	for i := 0; i < 5; i++ {
		dbHost, err := cfg.Get("db_host")
		if err != nil {
			fmt.Printf("Worker %d: %s\n", id, err)
			continue
		}
		apiTimeout, err := cfg.Get("api_timeout")
		if err != nil {
			fmt.Printf("Worker %d: %s\n", id, err)
			continue
		}
		logLevel, err := cfg.Get("log_level")
		if err != nil {
			fmt.Printf("Worker %d: %s\n", id, err)
			continue
		}
		fmt.Printf("Worker %d: db_host=%s, api_timeout=%s, log_level=%s\n", id, dbHost, apiTimeout, logLevel)
		time.Sleep(time.Duration(50+id*10) * time.Millisecond)
	}
}

Line by line:

  1. The function will receive three parameters: the id of the worker, a Config struct, and a sync.WaitGroup
  2. Regardless how this function exits, the wait group counter should be decremented. Using the defer statement and a call to the Done() method take care of that.
  3. Next we print a message to the user.
  4. Now in a loop, the function tries and get several config values, and print them to the screen. It also handles any errors which might occur.
  5. After each iteration, the function sleeps for a certain amount of milliseconds.

Simulating an admin

The admin() function simulates setting the different config values. After setting them it sleep for a certain amount of time. This is done to simulate someone using an admin interface for example to change configuration values. Due to the fact this function uses the Set() method, it will acquire and release some write locks.

func admin(cfg *Config) {
	cfg.Set("db_host", "localhost")
	cfg.Set("api_timeout", "3000")
	cfg.Set("log_level", "INFO")

	time.Sleep(2 * time.Second)
	cfg.Set("api_timeout", "5000")
	time.Sleep(500 * time.Millisecond)
}

Testing time

We can now test this setup:

func main() {
	cfg := NewConfig()
	var wg sync.WaitGroup

	go admin(cfg)
	time.Sleep(200 * time.Millisecond)
	numWorkers := 5
	wg.Add(numWorkers)
	for i := 1; i <= numWorkers; i++ {
		go worker(i, cfg, &wg)
	}

	wg.Wait()
}

Line by line:

  1. Create a new Config struct.
  2. In this test setup we use a sync.WaitGroup to make sure our worker go routines all have ended.
  3. First call the admin() function in a go routine to set up the values.
  4. Set the number of workers.
  5. Start the worker() function in the loop, passing it the loop variable, the config, and the wait group.
  6. Wait for the worker go routines to terminate.

Conclusion

Implementation of the Read-Write lock is relatively painless in the Go, using the provided structures from the standard library.

Using this pattern can improve performance if the number of read operations on a resource is significantly greater than the number of write-operations

The Code Nomad
The Code Nomad
Articles: 167

Leave a Reply

Your email address will not be published. Required fields are marked *