Go Sync or Go Home: WaitGroup
Introduction
Go’s goroutines, channels, and mutexes make it easy to develop complex concurrency systems. Most problems can be solved using these three mechanisms, but you might be asking yourself — what else is out there?
That’s what I was wondering when I stumbled upon the lesser-known features of the sync
and x/sync
packages. In this blog series, I will explore some of these niche features, focusing on practical use cases and how they can be used to boost performance and reduce latency.
To give some background before jumping into the more advanced concepts, let’s start off by delving into WaitGroup
.
WaitGroup
WaitGroup
can be used to wait for the completion of multiple concurrent tasks. Let’s take a look at its API:
Creation
WaitGroup
doesn’t have a special initializer or creator function, so to create one, simply make a struct of the type:
|
|
Add
Call the Add
method to add one or more tasks to wait for.
Done
Call the Done
method in the task goroutine after the task has been completed.
Wait
Call the Wait
method to block until all tasks have been completed.
Flow
Now that we’re familiar with the WaitGroup
methods, the flow to use them will always be something along these lines:
- Create a new
WaitGroup
Add
the number of tasks to be executed- In task goroutine: call
Done
after completing the task - In main goroutine:
Wait
for all tasks to finish
Example
Say we have an AgentController
that controls multiple Agents
:
The AgentController
sends each task it receives to all agents and waits for their response. Once all agents have completed the task, the controller can continue to the next task.
Using goroutines and channels, we can implement the controller’s logic:
|
|
While this implementation works, using WaitGroup
s will simplify and enhance the readability of the code:
|
|
Benchmarks
Another reason to use WaitGroup
instead of channels is improved performance. To demonstrate this, I created a benchmark test.
The Task
The sample task I used for the test is the sha1.Sum
function on the string "hello world"
:
The Test
I created two tests, one for WaitGroup
and one for channels. Each test ran the task concurrently runCount
times:
|
|
The runCount
variations
To check the results over varying runCount
values, I used an array of benchmark cases:
The Benchmarks
All that was left was to build the benchmarks. The benchmarks run a sub-benchmark for each benchmark case, running each test b.N
times.
|
|
The Results
|
|
The results show that the WaitGroup
tests performed consistently better than the channel tests. This isn’t surprising — WaitGroup
was built with this specific use case in mind. These benchmarks show us the significance of identifying the most appropriate synchronization technique for each situation and leveraging the versatile capabilities offered by Go’s concurrency primitives.
You can see the full benchmark tests here.
Summary
In summary, WaitGroup
has one very specific use, and that is to wait for concurrent tasks to be completed. Its API is simple, and I highly recommend using it when the need arises.
What’s Next?
While WaitGroup
is a great concurrency mechanism, sometimes it’s not enough. Stay tuned for the next post in this series, where we will explore ErrGroup
and see how it saves the day at times when you need a few extra features!