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:
- The
Once
is struct to make sure that we run the initialization at most once. - The
GetOrInit()
method provides us with access to the value. The only argument it has is a function which returns anExpensiveCar
. 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’sDo()
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:
- We create a
Lazy
instance. - Then inside a goroutine we call the
GetOrInit()
method to initialize the expensive car to a default value. - 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.