Ranked #2 on Hacker News with 160 points and 49 comments.
This mini-book provides a brief overview of many concurrency topics in Go. Each topic comes with interactive examples — feel free to experiment with them by changing the code and clicking Run. There's also a PDF version with static examples.
This is a quick refresher on Go concurrency, not a beginner's guide. If you want to learn concurrency from the ground up with practical exercises, check out my other book — Gist of Go: Concurrency.
The book is AI-free.
Goroutines • Channels • Select • Pipelines • Time • Context • Wait groups • Data races • Race conditions • Mutexes • Semaphores • Signaling • Run once • Object pool • Atomics • Testing • Scheduling • Diagnostics • Final thoughts
# Goroutines
The foundation of concurrency in Go is goroutines – functions started with the go keyword:
The Go runtime juggles these goroutines and distributes them among operating system threads running on CPU cores. Compared to OS threads, goroutines are lightweight, so you can create hundreds or thousands of them.
Goroutines are completely independent. The main function is also a goroutine, but it starts implicitly when the program starts. When main ends, other goroutines also shut down.
We use a wait group (sync.WaitGroup) to wait for goroutines to finish in the example above. A wait group has a counter inside. Calling Add(n) increments it by n, while Done() decrements it by one. Wait() blocks the calling goroutine (in this case, main) until the counter reaches zero. This way, main waits for both workers to finish before it exits.
WaitGroup.Go automatically increments the wait group counter, runs a function in a goroutine, and decrements the counter when it's done:
# Channels
Goroutines can pass values to each other through channels. A channel is like a window where one goroutine can throw something and another can catch it:
Sending a value through a channel is a synchronous operation. When the sending goroutine writes a value to the channel (ch <- val), it blocks and waits for someone to receive that value (<-ch). Only then does it continue.
Output channel
Returning an output channel from a function and filling it within an internal goroutine is a common pattern in Go. This allows the caller to receive values through the channel while the owning function retains control of it:
Closing a channel
To signal readers that all data has been sent, the writer goroutine closes the channel with close():
The reader checks the channel's status with a second value ("comma OK") when reading:
While the channel is open, the reader receives the next value and a true status. If the channel is closed, the reader gets a zero value and a false status.
A channel can only be closed once. Closing it again or writing to a closed channel causes a panic.
The only reason to close a channel is to signal to its readers that all data has been sent. If this isn't important to the readers, then you don't need to close it. When a channel is no longer used, Go's garbage collector will free its resources, whether it's closed or not.
Channel iteration
range automatically reads the next value from the channel and checks if it's closed. If the channel is closed, it exits the loop:
Range over a channel returns a single value, not a pair, unlike range over a slice.
Directional channels
You can protect yourself from accidental write/close errors by setting the channel direction. Channels can be:
- chan (bidirectional): for reading and writing (default);
- chan<- (send-only): for writing only;
- <-chan (receive-only): for reading only.
You can't read from a send-only channel or write to a receive-only channel (nor can you close it).
Channels are usually initialized for both reading and writing, and specified as directional in function parameters. Go automatically converts a regular channel to a directional one:
Buffered channels
Buffered channels work like a FIFO queue with a fixed-size buffer for storing values.
As long as the buffer has free space, writing to the channel doesn't block the goroutine. Similarly, as long as the buffer contains values, reading from the channel doesn't block the goroutine:
By default, if you don't specify a buffer size, a channel is unbuffered (buffer size equals zero).
Buffered channels work with the built-in len() and cap() functions:
Reading from a closed buffered channel returns values from the buffer and a true status. Once all values are taken, it returns a zero value and a false status, like a regular channel:
nil channel
Like any type in Go, channels have a zero value, which is nil.
Writing to or reading from a nil channel blocks the goroutine indefinitely:
Closing a nil channel causes a panic:
# Select
The select statement is somewhat like switch, but specifically designed for channels. Here's what it does:
- Checks which cases are not blocked.
- If multiple cases are ready, randomly selects one to execute.
- If all cases are blocked and there is a default case, executes it.
- If all cases are blocked and there is no default case, waits until one is ready.
Select is used to manage data flow in pipelines:
To cancel goroutines:
For non-blocking operations:
And for much more.
# Pipelines
A pipeline is a sequence of operations where each step takes input data, processes it in a specific way, and outputs it. The input and output of each operation is a channel.
A typical pipeline looks like this:
- Reader: Reads input data from a file, database, or network.
- N processors: Transform, filter, aggregate, or enrich data using external sources.
- Writer: Writes the processed data to a file, database, or network.
Output channel
A goroutine can signal other goroutines that it has finished its work using an output channel:
Done channel
If a goroutine doesn't need to return results, it can signal completion using a done channel:
Cancel channel
To terminate a goroutine early, a calling goroutine can use a cancel channel:
Error handling
There are three approaches to error handling in concurrent pipelines.
➊ Return on the first error:
➋ Use a result type:
➌ Collect errors separately:
# Time
Besides handling date and time, the time package offers tools for managing time-sensitive operations in concurrent programs.
After
time.After() returns a channel that is initially empty, but receives a value after the timeout period. It's useful for timing out operations:
withTimeout() waits for fn() to complete, but thanks to time.After(), it won't wait longer than the timeout duration:
Timer
A timer (time.Timer) is a structure with a C channel to which it sends the current time when it triggers (expires). Timers are useful for planning future executions:
Stop() stops the timer and returns true if it hasn't expired yet, and false otherwise:
It's often more convenient to use the time.AfterFunc() wrapper function. It waits for duration d and then executes function f:
time.AfterFunc() returns a timer that you can cancel before execution starts:
If a timer is used in a loop, it's better to create a single timer and reset it instead of creating a new instance on each iteration:
Ticker
A ticker is like a timer, but it keeps firing until you stop it. Tickers are useful for executing periodic tasks:
NewTicker(d) creates a ticker that sends the current time to the channel C at interval d. You must stop the ticker eventually with Stop() to free up resources.
If the channel reader can't keep up with the ticker, the ticker will skip ticks.
# Context
The main purpose of context is to cancel operations, either manually or by timeout/deadline.
The function accepts a context and uses its Done() channel to listen for cancellation:
Cancel manually (context.Canceled error):
Cancel by timeout (context.DeadlineExceeded error):
Cancel by deadline (context.DeadlineExceeded error):
Context is layered. A context object is immutable. To add new properties to a context, a new (child) context is created based on the old (parent) context. The shorter timeout between the parent and child contexts always wins. The child context can only shorten the parent's timeout, not extend it:
Multiple cancels are safe. You can call cancel() on the context as many times as you want. The first cancel will work, and the rest will be ignored.
You can specify a custom cancellation cause using context.WithCancelCause(), context.WithTimeoutCause() and context.WithDeadlineCause(). This cause is accessible through context.Cause():
You can register a function to execute when the context is canceled with context.AfterFunc():
Context can pass additional information about a call using context.WithValue(), which creates a context with a value for a specific key. But it's generally better to avoid passing values in context. It's better to use explicit parameters or custom structs instead.
# Wait groups
The sync.WaitGroup type lets you wait for one or more goroutines to finish:
A WaitGroup doesn't know anything about the goroutines it manages. It works with an internal counter. Calling wg.Add(1) increments the counter by one, while wg.Done() decrements it. wg.Wait() blocks the calling goroutine until the counter reaches zero.
The Go method combines Add, starting a goroutine, and Done:
All methods are safe to use from multiple goroutines.
Normally, all Add calls happen before Wait. But technically, there's nothing stopping you from doing some of the Add calls before Wait and some after (from another goroutine).
You can call Wait from multiple goroutines. They will all block until the group's counter reaches zero.
# Data races
A data race happens when multiple goroutines access shared data, and at least one of them modifies it. We need to protect the data from this kind of concurrent access.
A data race doesn't always cause a runtime panic. That's why Go provides a special tool called the race detector. You can turn it on with the race flag, which works with the test, run, build, and install commands.
Channels are safe for concurrent reading and writing, and they don't cause data races.
Ways to prevent data races:
- Avoid concurrent data modification (typically by using channels).
- Synchronize access with mutexes.
- Use only atomic operations.
Race conditions
A race condition happens when an unpredictable order of operations from multiple goroutines leads to an incorrect system state:
If individual operations are concurrent-safe, Go's race detector won't find any issues. Because of this, it doesn't catch race conditions:
You can't fully eliminate uncertainty in a concurrent environment. Events will happen in an unpredictable order — that's just how concurrency works. However, you can prevent a race condition — often by protecting a composite operation with a mutex:
Compare-and-set
Sometimes you can prevent a race condition without using mutexes by applying an atomic compare-and-set operation or one of its flavors:
The idea is always the same:
- Check if the assumed (old) state matches reality.
- If it does, change the state to new.
- If not, do nothing.
# Mutexes
The sync.Mutex type protects shared data and parts of your code from being accessed concurrently:
The mutex guarantees that only one goroutine can run the code between Lock() and Unlock() at a time.
A mutex is used in these situations:
- When multiple goroutines are modifying the same data.
- When one goroutine is modifying the data and others are reading it.
If all goroutines are only reading the data, you don't need a mutex.
TryLock
The TryLock method tries to lock the mutex, just like a regular Lock. But if it can't, it returns false right away instead of blocking the goroutine:
RWMutex
The sync.RWMutex type distinguishes between readers and writers. It provides two sets of methods:
- Lock / Unlock lock and unlock the mutex for both reading and writing.
- RLock / RUnlock lock and unlock the mutex for reading only.
Here's how it works:
- If a goroutine locks the mutex with Lock(), other goroutines will be blocked if they try to use Lock() or RLock().
- If a goroutine locks the mutex with RLock(), other goroutines can also lock it with RLock() without being blocked.
- If at least one goroutine has locked the mutex with RLock(), other goroutines will be blocked if they try to use Lock().
This creates a "single writer, multiple readers" setup.
Locker
Both sync.Mutex and sync.RWMutex implement the same sync.Locker interface:
By using Locker instead of a specific mutex type, you can build components that don't depend on a specific lock implementation. This lets the client decide which lock to use.
Channel as mutex
You can use a channel instead of a mutex to protect shared data:
# Semaphores
A semaphore is like a container with N available slots and two operations: acquire to take a slot and release to free a slot. Here are the semaphore rules:
- Calling acquire takes a free slot.
- If there are no free slots, acquire blocks the goroutine that called it.
- Calling release frees up a previously taken slot.
- If there are any goroutines blocked on acquire when release is called, one of them will immediately take the freed slot and unblock.
You can implement a simple semaphore with a buffered channel, where N is the channel's size. To acquire the semaphore, send a value into the channel. To release it, take a value from the channel:
For more complex situations, use the golang.org/x/sync/semaphore package.
Rendezvous
A rendezvous lets two goroutines wait for each other:
- There are two goroutines — G1 and G2 — and each one can signal that it's ready.
- If G1 signals but G2 hasn't yet, G1 blocks and waits.
- If G2 signals but G1 hasn't yet, G2 blocks and waits.
- When both have signaled, they both unblock and continue running.
You can implement a simple rendezvous with a wait group:
Barrier
A barrier is a general case of a rendezvous. It lets N goroutines wait for each other:
- The barrier has a counter (starting at 0) and a threshold N.
- Each goroutine that reaches the barrier increases the counter by 1.
- The barrier blocks any goroutine that reaches it.
- Once the counter reaches N, the barrier unblocks all waiting goroutines.
You can implement a simple barrier with a wait group:
# Signaling
The sync.Cond (conditional variable) type lets one goroutine signal to another that it's ready, and lets the other goroutine wait for that signal.
A Cond includes a mutex and has two methods — Wait and Signal.
- Wait unlocks the mutex and suspends the goroutine until it receives a signal.
- Signal wakes the goroutine that is waiting on Wait.
- When Wait wakes up, it locks the mutex again.
If there are multiple waiting goroutines when Signal is called, only one of them will be resumed. If there are no waiting goroutines, Signal does nothing.
You can also use the Broadcast method. While Signal wakes up only one goroutine waiting on Cond.Wait, the Broadcast method wakes up all such goroutines.
You can signal with a channel:
And broadcast too:
Broadcasting with a condition variable is limited: it only sends a signal, not the actual data, and it only works once. With channels, you can build a publish/subscribe system that doesn't have these limitations:
# Run once
The sync.Once type makes sure that the given function runs only once. If multiple goroutines call Once.Do at the same time, only one will run the function, while the others will wait until it returns:
Once is perfect for one-time initialization or cleanup in a concurrent environment.
Besides the Once type, the sync package also includes three convenience once-functions:
# Object pool
The sync.Pool type helps reuse memory instead of allocating it every time, which reduces the load on the garbage collector:
Get takes an item from the pool. If there are no available items, it creates a new one using New (which we have to define ourselves, since the pool doesn't know anything about the items it creates). Put returns an item back to the pool.
Things to keep in mind:
- New should return a pointer, not a value, to reduce memory copying and avoid extra allocations.
- The pool has no size limit. If you start 1000 more goroutines that all call Get at the same time, 1000 more buffers will be allocated.
- After an item is returned to the pool with Put, you shouldn't use it anymore (since another goroutine might already have taken and started using it).
# Atomics
An operation without synchronization can only be truly atomic if it translates to a single processor instruction. Such operations don't need locks and won't cause issues when called concurrently (even the write operations).
There are only a few atomics, and they're all found in the sync/atomic package:
Each atomic type provides the following methods:
- Load reads the value of a variable.
- Store sets a new value.
- Swap sets a new value (like Store) and returns the old one.
- CompareAndSwap sets a new value only if the current value is still what you expect it to be.
Numeric types also provide an Add method that increments the value by the specified amount.
All methods are either translated into a single CPU instruction or are otherwise guaranteed to be atomic, so they are safe to use from multiple goroutines.
The composition of atomics is always non-atomic:
A bulletproof way to make a composite operation atomic and prevent race conditions is to use a mutex:
Sometimes you can use an atomic type instead of a mutex to exit early:
# Testing
If your concurrent program uses channels or custom types with synchronization methods like Wait, you can use those in your tests. This way, your tests won't be much more complicated than if the code were synchronous:
If there aren't any suitable synchronization "handles" in the code you're testing, you can use the synctest package. It exports two functions:
synctest.Test runs an isolated bubble. The bubble uses a fake clock, and you can manually control goroutine synchronization with synctest.Wait.
synctest.Wait blocks until all goroutines in the bubble — except the one that called Wait — have either finished or are durably blocked. This lets you wait for a specific goroutine to finish or get blocked, so you can check the program's state:
The fake clock in synctest.Test move forward only if: ➊ all goroutines in the bubble are durably blocked; ➋ there's a future moment when at least one goroutine will unblock; and ➌ synctest.Wait isn't running. Thanks to this, time-dependent tests run instantly:
The following operations durably block a goroutine:
- A blocking send or receive on a channel created within the bubble.
- A blocking select statement where every case is a channel created within the bubble.
- Calling Cond.Wait.
- Calling WaitGroup.Wait if all WaitGroup.Add calls were made inside the bubble.
- Calling time.Sleep.
Blocking on mutexes, I/O, or system calls is not considered durable, and the synctest bubble can't handle them.
# Scheduling
At the hardware level, CPU cores are responsible for running parallel tasks.
At the operating system level, a thread is the basic unit of execution. There are usually many more threads than CPU cores, so the operating system's scheduler decides which threads to run and which ones to pause.
At the Go runtime level, a goroutine is the basic unit of execution. The runtime scheduler runs a fixed number of OS threads, often one per CPU core. There can be many more goroutines than threads, so the scheduler decides which goroutines to run on the available threads and which ones to pause. The scheduler keeps switching between goroutines to make sure each one gets a turn to run on a thread, instead of waiting in line forever.
This is how Go handles concurrency.
Goroutine scheduler
The goroutine scheduler's job is to run M goroutines on N operating system threads, where M can be much larger than N. Here's a very simplified version of it's algorithm:
- If there's a free thread, assign it a goroutine from the queue.
- If a running goroutine gets blocked (for example, while reading from a channel), put it back in the queue and assign a different goroutine to the thread.
- If a running goroutine gets stuck in a syscall, start a new thread to run other goroutines until the blocked goroutine finishes the syscall.
- Check the running goroutines every 10 ms. Preempt long-running goroutines and return them to the queue to prevent starvation.
The number of threads running Go code is controlled by the GOMAXPROCS environment variable or the runtime.GOMAXPROCS function.
A goroutine is a structure that starts out using about 2 KB of memory, mostly for its stack. The stack can grow if needed. Since goroutines are so lightweight, you can run tens of thousands or even hundreds of thousands of them on a small machine.
# Diagnostics
To troubleshoot concurrent programs in production, we use metrics, profiling, and tracing.
Metrics show how the Go runtime is performing, like how much heap memory it uses or how long garbage collection pauses take. Each metric has a unique name and a value, which can be a number or a histogram.
You can use the runtime/metrics package to get a complete list of metrics or check the values of specific ones:
In practice, people rarely do this manually. Instead, all metrics are automatically exported using Prometheus or OpenTelemetry libraries.
Profiling helps you understand exactly what the program is doing, what resources it uses, and where in the code this happens. Go uses a sampling profiler that's suitable for production.
The most commonly used profiles are CPU, which shows how much processor time each function uses, and heap, which shows how much heap memory each function uses. Goroutine, block, and mutex profiles help identify problems related to concurrency.
The easiest way to add a profiler to your app is by using the net/http/pprof package. To collect a profile with the given name, call the /debug/pprof/{name} endpoint. To view the collected profile, use the go tool pprof utility:
You can also profile manually:
Tracing records certain types of events while the program is running, mainly those related to concurrency and memory. When the profiling server from the net/http/pprof package is running, call the /debug/pprof/trace endpoint to collect a trace. To view the results, use the go tool trace utility.
You can also collect a trace manually:
You can set up automatic tracing with a sliding window that's limited by size or duration. This is called "flight recording". It lets you always keep a recent trace available in case something goes wrong:
# Final thoughts
We've covered a number of Go tools for writing concurrent programs:
- Goroutines for running concurrent tasks.
- Channels and select as flexible communication tools.
- Timers and tickers for working with time.
- Context for canceling operations.
- Wait groups for synchronizing goroutines.
- Mutexes to prevent race conditions.
- Condition variables for signaling events.
- Once for safe one-time initialization.
- Pools to reduce garbage collector load.
- Atomic operations.
If you like the book, please recommend it to your friends or colleagues. If you're interested, check out my other books and projects.
I'm glad you finished the book. Thank you, and I'll see you next time!
★ Subscribe to keep up with new posts.