When you first start tinkering with concurrency in any language, what's the most sought after goal is extracting the maximum output from the hardware you have available.
That's why we use it and study it in the first place - to achieve optimal parallelism & get some significant speed-up for our apps out of the box.
However, a not so glamorous part of studying the subject is understanding how to write thread-safe code. The techniques and principles which will enable you to keep your application well-behaved, even after scaling it to dozens of threads.
Even though this is an important thing to consider while writing concurrent code, it is often overlooked in most learning resources about concurrency. This problem is even more relevant in the Go community due to two common misunderstandings I will cover in the article.
What does thread-safety mean anyways?
Let's first explore what thread-safety is exactly. One reasonable way to define the term is to think of it in terms of correctness.
If a component conforms to its specification, it is correct. Say you have an Interval struct which defines two limits - lower and higher. The important invariant instances of the struct have to maintain is lower < higher. If at any point, this invariant is not true, then this class is incorrect.
A thread-safe class, on the other hand is able to sustain its correctness even after being bombarded by a dozen of threads, accessing it concurrently.
Now, when designing thread-safe components, there are also other concerns one has to consider. One such concern is achieving reasonable performance - if a component performs worse in a multi-threaded environment, instead of a single-threaded one, it's obviously not a great solution.
However, for the scope of this article, we will just worry about making a component thread-safe - i.e. correct in a multi-threaded environment.
Why should you care about thread-safety?
Most people understand that thread-safety is an issue. When exploring the topic of concurrency in any language, they've probably seen at least a single thread-safety issue.
Consider this example:
When I run this code on my laptop, I get as result 9303. The second time, it was a different number. When you run it, it might even be correct.
This is an example of non-thread-safe code. The expected behaviour for this routine is to print 10000 when I increment the counter that many times, albeit concurrently.
Instead, when I ran it the first time I got 9303. The second time was a different number which was still incorrect. Try running it on your end and see what's your lucky number.
Given this example, most people acknowledge that writing thread-safe code is important as otherwise, you are subject to sporadic bugs which would be very hard to trace, debug and reproduce.
Even so, the concern of writing thread-safe code is still quite overlooked by many due to two main reasons:
- They think they don't need to worry about it because they don't directly spawn threads in their applications
- They think they don't need to worry because they use channels and there is no memory sharing when using them
Unfortunately both of these assumptions are incorrect for most people. Let's see why.
You are already writing multi-threaded applications
Let's first cover the subtle fact that you are already writing multi-threaded applications even if you haven't spawned a single goroutine in it.
Have you ever created an http handler using Go's standard http package?
Congratulations, you are already writing multi-threaded software!
Although you aren't spawning goroutines in your codebase directly, you are using a framework which spawns one for every incoming http request.
This means that there are already multiple threads, executing your code. Hence, it needs to be thread-safe to avoid subtle bugs, which would be quite hard to find.
By the way, this is applicable to any multi-threaded framework or library you might use. Even if you're simply spawning a timer to execute some background task, you're already dealing with a multi-threaded application.
Go channels are great, but not always applicable
There's this notorious technique, applied in Go, of not communicating by sharing memory but sharing memory by communicating.
You can achieve this via Go's channel primitive. It's a data structure which enables one to achieve thread-safe code in a multi-threaded environment. It's nice because you don't need any synchronisation whatsoever as long as you use it to share data across threads.
It, undoubtedly, is a great solution for avoiding thread-safety issues when writing concurrent code. However, it is not applicable in all situation.
Let's explore an example
Take this simple web chat implementation:
This looks like a perfectly reasonable application, where no goroutines are spawned. When you test it out via a rest client, it appears to work great!
However, if you deploy this code into production and enough users start using it, you will start receiving a flood of bug reports which you can hardly reproduce.
What's more, changing this code to work with channels in quite impractical as the resulting code will be hard to understand and reason about. A much more practical solution is sticking to the good old synchronisation primitives, implemented in the sync package.
And this is where the classic, well-known practices for achieving thread-safety are applicable in Go, despite the advent of channels.
Are you curious if you have any non-thread-safe classes in your own web application?
One common source of thread-safety bugs in web applications is having a struct field of type slice or map whose state you change in any function. If that struct instance is shared across multiple http requests and you don't have any synchronisation, then that's probably a non-thread-safe component.
Some good resources on thread-safety & concurrency
Most existing resources cover the subject of thread-safety in a very shallow way. I know this as after consuming many of them, I've still felt that I don't understand it at all.
For example, one of the only books on concurrency in go - Concurrency in Go, dedicates a single chapter on covering the problems of thread-safety. And it doesn't even go into enough detail exploring what options we have for solving them or even demonstrating all possible sources of thread-safety violations.
Once you read that chapter you would, at best, have a shallow understanding of the fact that you need to be careful when writing concurrent code. Oh, and that there is something called a lock which appears to be helpful for resolving that issue.
So far, I've found a single resource on concurrency which goes into enough depth on the subject, exploring why thread-safety issues happen and how to avoid them - Java Concurrency in Practice.
If you want to explore concurrency and thread-safety beyond this article, then I highly suggest you check out that book. My book notes can also greatly aid you in digesting it.
Oh, and I know it's a Java book, not a Go one but trust me. It's still better than all the Go books/courses on the subject.
Additionally, I'm planning on releasing several additional articles on the subject, covering the subject in more detail. If you want to get a notification once I release the next article in the series, make sure to subscribe.
Thread-safety is an important concern for stable & reliable applications, nowadays as most of us are already writing multi-threaded applications.
If you want to avoid the subtle bugs arising from thread-safety issues, you should make sure you sufficiently understand the topic.
In order to bridge the gaps in your knowledge, check out Java Concurrency in Practice. Also subscribe for the blog for the next articles in these series on thread-safety in Go.