Golang Concurrency Patterns: Double Checked Locking
Golang is quite pecuiliar in the way that it approaches Object Oriented Programming. Almost all of us are familiar with some OO language that either has classes or plain objects with a delegation mechanism (I’m looking at you, JavaScript)
And yet it’s exhilarating to write OO style code in Golang. I never realised I could do so much (if not everything) without classes and generics. My code ends up being a lot more robust and malleable.
As with most other things in Programming, whenever you learn a new language and/or stack, you tend to bring with you your previous knowledge and preferences. Among these are design patterns, and in context of this blog post; concurrency patterns. One such pattern is the Check Lock Check (aka Double-checked locking) pattern.
Wikipedia defines Check Lock Check as:
In software engineering, double-checked locking (also known as “double-checked locking optimization”) is a software design pattern used to reduce the overhead of acquiring a lock by testing the locking criterion (the “lock hint”) before acquiring the lock. Locking occurs only if the locking criterion check indicates that locking is required.
Here is an example of this pattern (from wiki)
|
|
Double checked locking let’s you make lazy initialisations idempotent and thread safe. Though that sounds good on paper, is it really that practical in real applications?
Let’s think about what lazy initialisation is used for:
- to defer initialisation of expensive resources until they are actually required (virtual proxy)
- to defer initialisation until certain facts have been determined (factory)
Let’s look at an example of a virtual proxy:
|
|
Here we have a program that reads 4kb chunks from a shared source file and does something with it. Now this may look like a good idea, except it isn’t that great considering what you gain from it. Startup times are usually not a problem for networked applications (though they maybe an issue, for short lived services). Use this pattern for lazy initialisation where the gain from delaying the initialisation is worth the added maintaince cost. In most cases, initialising things at the start of the program should be more than enough. Use this pattern for lazy initialisations sparingly.
(side note: I’m not sure if reading a file from different goroutines sans synchronisation is a good idea. It works on Linux/Mac/Windows but I’m not familiar enough with threads and system calls to say this operation is safe. Stay safe kids, don’t rely on magic.)
Moving on to the next use: factories. Well to be exact, factories with caching/pooling. Why the distinction? Let’s look at a typical use pattern for a factory:
|
|
Do you see the problem? A service object will be constructed for each request even though we only have a few service implementations. Now this can be a valid use case in case each constructed service has to be constructed for every request, probably involving some form of request scoping. However what if you wanted to share them? Let’s rewrite the service factory implementation to use double checked locking.
|
|
There. Much better. This pattern can be applied anywhere where you need to construct an object lazily and save it for future re-use. Double check locking ensures that the bare minimum amount of work is done and that there are no duplicate initialisations.
But why does it check twice?
Consider this piece of code
|
|
People who know C++ or Java have probably seen this pattern a lot; this is the most simple method (pun intended) of lazy initialisation. Let’s try to make it a thread safe by adding in a mutex lock.
|
|
Better? well not necessarily. Let’s take a hypothentical example of a case where two goroutines call
this method at the same time. Both of them arrive at the predicate check obj.property == nil
and try
to acquire the lock. At this point one of them wins and goes ahead and initialises the property, while
the other goroutines waits. Once the mutex is unlocked, the second go routine comes and initialises
the property again. By adding a second check, we ensure that the trailing goroutine doesn’t re-initialise
the same property
|
|
Examples of double checked locking in Golang’s standard library
Golang has an excellent and well thought out standard library. The sync
package which contains synchronisation
primitives has a type called sync.Once
, which is meant to fix the double initialisation problem, and should
be used instead of double checked locking where possible. Here’s the source code of sync.Once
(from go 1.12):
|
|
As you can see, sync.Once
also uses double checked locking, albeit it uses atomic operations
for the checks, which makes it even better since at any time only one goroutine can actually
make those checks. It’s like a supercharged version of double checked locking.
Before you go and start (ab)using this pattern
As Donald Knuth said
premature optimization is the root of all evil
Double checked locking is a fairly advanced design pattern. And though it may sound really good on paper, I’d advise using against it, unless double initialisation is as an end of the world event for you, or if you construct and pool thousands or even millions of such object and the cost of construction is a bottle neck.
Double checked locking tends to end up hurting the readability of your code. Use this pattern after you know that using it would actually help with security or performance.