Common pitfalls when using goroutines

In this post I am going to cover some common cases and incidents that you are likely to experience when using goroutines and how to deal with them.

Table of contents:

1. Waiting for the goroutines
2. Deadlocks
3. Getting unexpected results
4. Race conditions


First, what’s a goroutine?

Golang is concurrent by nature.
To achieve concurrency, Go uses goroutines — functions or methods that run concurrently with other functions or methods.
Yes, even Golang’s main function is a goroutine!

goroutines can be viewed as lightweight threads, but unlike threads, they are not managed by the operating system, but by Golang’s runtime.

It’s very common for a Go application to have hundreds and even thousands of goroutines running concurrently.

(More on goroutines here)

Let’s start with a quick example and create a dummy hello.go file:

and the output should be:

Cool, our goroutine executed successfully.

However, as you start adding more functionality to the goroutines, you may also end up facing one of these common cases below.

Part 1: Waiting………

Let’s start with a simple one:
As you may have noticed, the use of time.Sleep is very common when demonstrating the basic functionality of goroutines.

So why is the sleep necessary here? Let’s check it out without the time.Sleep function:

Hmm, now the goroutine’s output is missing. Why is that?

Because the program’s execution begins by initializing the main package and then invoking the function main. When that function invocation returns, the program exits. It does not wait for other (non-main) goroutines to complete.

It means that when the main function finishes it’s execution, it doesn’t wait for other goroutines to finish.

So now that we understand the necessity of waiting for other goroutines to finish, is there a more elegant and efficient way of waiting for goroutines to finish, instead of guessing how long it’s going to take the goroutine to finish?

Yes, there is! it’s called WaitGroups.

WaitGroups allow us to block until all goroutines within that waitgroup finish their execution.
An example WaitGroup implementation:

Run the code:

Better and faster, because we don’t have to wait a fixed amount of time.

Part 2: Deadlocks

You may have previously seen this scary error before

A deadlock happens when a group of goroutines are waiting for each other and none of them is able to proceed.
Remember, the main package is also goroutine.

1. wgrp.Done() marks the goroutine execution as finished. omitting this will also cause a deadlock.

2. wg.Add() receives the number of goroutines we should be waiting for.
possible values:
0 and the goroutine will not execute
1 will work as expected
2 and above will result in a deadlock

In both cases we’ll get a deadlock because the main function waits for the other goroutine to complete it’s execution:
Case 1: The goroutine will never mark it’s execution on the WorkGroup as done.
Case 2: wg.Add will continue waiting for more goroutines than expected to run.

Another case where you will get a deadlock is when there are no other goroutines to take what the sender sends, as this cannot happen in the same goroutine:

instead, do this:

Part 3: Unexpected results

Adding a for loop to the mix:

Huh? isn’t it supposed to print different names each iteration?
Well.. it is, but the goroutines created inside the for loop will not necessarily execute sequentially.
Each goroutine starts randomly.

The workaround is fairly simple, we’ll just pass the current item of the iteration:

That’s better.

Part 4: Race conditions and sharing data between goroutines

Now it’s becoming a bit more complex and interesting:

Imagine that you have a banking application, where a customer can deposit and withdraw money.
As long as the application is single threaded and synchronous, there shouldn’t be any problem, but what happens if your application spins up hundreds or thousands of goroutines?

Consider this scenario:
A customer has a balance of $100, and deposits $50 to his account.
One goroutines sees the transactions, reads the current balance of $100 and adds $50 to the balance.
But wait, at the exact same time there was also a charge of $80 applied to the customer’s account to pay his bill at the local bar.
The second goroutine would read the then-current balance of $100, subtract $80 from the account, and update the account balance.

The customer would then check his account balance and see that it’s only $20 instead of $70, because the second goroutine overwrote the balance value when it processed it’s transaction.

To workaround this, we can use a Mutex.
Mutex? a Mutex (mutual exclusion) is a method used as a locking mechanism to ensure that only one Goroutine is accessing the critical section of code at any point of time.
More on Mutexes here.

This is how it’s going to look:

Pay attention to the mutex.Lock() and mutex.Unlock() commands that make it happen.
We still use the workgroup the same way as explained earlier.

There is another way to solve it, this time using channels.
Channels are the pipes that connect concurrent goroutines. You can send values into channels from one goroutine and receive those values into another goroutine.
Remember, the main function is also a goroutine.
(More on channels here)

In this example, we use a buffered channel.
This buffered channel is used to ensure that only one goroutine can access the critical section of code, which is the part that modifies the balance.

We have created a buffered channel with the capacity of 1, because we want to modify the balance only once per operation, and it’s passed to the deposit/withdraw goroutines.

So which one should we choose?
Generally, use channels when Goroutines need to communicate with each other and mutexes when only one Goroutine should access the critical section of code.
In this case, the best practice would be to use a Mutex.

I hope you find this post useful.

Backend Engineer