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.

In cases where many threads need to read a resource at one, and there are a few write-operations, the Read-Write Lock pattern can help. This pattern allows concurrent access for readers of the resource. In the case however of writing an exclusive lock is granted, and all read operations are blocked. This last part could be a source of a deadlock, in case the writer takes a long time, or in some bad cases, never finishes for some reason.

In Go, this pattern in implemented using the sync.RWMutex struct. This struct has a lock() method, which grants an exclusive lock, and a RLock() method which grants read access.

Implementation in Go

One of the areas where locks might come in handy, is if you are handing different versions in a version control system. In our example we will build an extremely simplified version control system.

Let’s start with our preliminaries:

package main

import (
	"fmt"
	"sync"
)

// Version represents a version with content
type Version struct {
	version string
	content string
}

// NewVersion creates a new Version with the given version and content
func NewVersion(version, content string) *Version {
	return &Version{
		version: version,
		content: content,
	}
}

func (v *Version) String() string {
	return fmt.Sprintf("Version: %s, Content: %s", v.version, v.content)
}

As you, in our example, a version just a version number and some content. The String() is used for automatic string conversion. It is in fact an implementation of the Stringer interface.

In this example we will go straight to the main function. You could of course wrap this pattern in a struct, if you want, but I want to keep things simple:

func main() {
	var wg sync.WaitGroup
	list := make([]*Version, 0)
	mu := sync.RWMutex{}

	for counter := 0; counter < 10; counter++ {
		wg.Add(1)
		go func(counter int) {
			defer wg.Done()
			mu.Lock()
			version := NewVersion(fmt.Sprintf("v0.%d", counter), fmt.Sprintf("content %d", counter*2))
			list = append(list, version)
			mu.Unlock()
		}(counter)
	}

	wg.Wait()

	mu.RLock()
	fmt.Printf("Result: %v\n", list)
	mu.RUnlock()
}

A short explanation:

  1. A sync.WaitGroup is defined, to synchronize completion of all the goroutines.
  2. We create slice to hold pointer to our Version instances.
  3. The sync.RWMutex is used to protect our list slice from concurrent access.

Next we create 10 goroutines, and each routine:

  1. We update the WaitGroup
  2. We create a new Version struct and add it to the list. This happens under the protection of the sync.RWMutex using the Lock() and the Unlock() methods.
  3. We notify that our task is done, using Done() method, which is called using the defer method, which means it is automatically called when the goroutine returns.

Now that we have a filled slice, we do the following:

  1. We use the Wait() method to wait for all goroutines to finish.
  2. Next we unlock the slice for reading using the RLock() method, and unlock it after printing the contents.

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 greater than the number of write-operations

Leave a Reply

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