Simple Double Checked Locking in Go for Effortless Concurrency Control

Photo by Jensen R: https://www.pexels.com/photo/padlock-on-gray-holder-1450506/

Introduction

Sometimes when locking data or objects it can be handy to reduce the overhead of acquiring a lock on such objects by first checking whether a lock is really necessary. This typically used in combination with lazy initialization in multi-threaded applications, sometimes as part of a Singleton pattern.

In Go we can implement this using the sync.Mutex and the sync.Once structs in the sync package.

Implementation in Go

We will start by defining an ExpensiveCar struct:

type ExpensiveCar struct {
	Name  string
	Price uint32
}

Next we define our Lazy struct in which we will implement our double checked locking:

type Lazy struct {
	once  sync.Once
	value *ExpensiveCar
	mu    sync.Mutex
}

func (l *Lazy) GetOrInit(initFunc func() *ExpensiveCar) *ExpensiveCar {
	l.mu.Lock()
	defer l.mu.Unlock()

	if l.value != nil {
		return l.value
	}

	l.once.Do(func() {
		l.value = initFunc()
	})
	return l.value
}

Some notes:

  1. The Once is struct to make sure that we run the initialization at most once.
  2. The GetOrInit() method provides us with access to the value. The only argument it has is a function which returns an ExpensiveCar. This function is called when we have not initialized the contained value
    • First we check if we have a value. If so, we return it, and unlock the mutex.
    • If no value has been initialized yet, we use the Once struct’s Do() method to call the initialization function and set the value.
    • Next we return the value and unlock the mutex.

As you can see there is a so-called ‘fast path’ and a ‘slow path’. Before we can enter either of them checks have been mad, hence the name double-checked locking. Because of the distinction between the two paths, you can see that it can improve performance in some case.

This was quite complicated code, let’s see it in action

Testing time

Now we can write a small app using this design pattern:

func main() {
	lazy := Lazy{}
	var wg sync.WaitGroup
	wg.Add(1)
	go func() {
		defer wg.Done()
		lazy.GetOrInit(func() *ExpensiveCar {
			return &ExpensiveCar{
				Name:  "Expensive Brand",
				Price: 100000,
			}
		})
		fmt.Printf("Value is %+v\n", lazy.value)
	}()

	wg.Wait()

}

What happens in the main function:

  1. We create a Lazy instance.
  2. Then inside a goroutine we call the GetOrInit() method to initialize the expensive car to a default value.
  3. We use the sync.WaitGroup to make main go routine wait until our go routine has finished.

Conclusion

Double checked locking is not a very hard pattern to implement. This may be partly due to the fact that Go was made with multithreading in mind. The distinction of slow- and fast paths is somehow reminiscent of the Singleton pattern.

The way we can use Mutex and Once to make sure our shared resource can be safely initialized and access makes life easier.

In a next article I will write a more elaborate example, and see if it is necessary to prevent for example data races.

Leave a Reply

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