Go concurrency distilled
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 The foundation of concurrency in Go is goroutines – functions started with the 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 ends, other goroutines also shut down. We use a wait group ( ) to wait for goroutines to finish in the example above. A wait group has a counter inside. Calling increments it by , while decrements it by one. 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. automatically increments the wait group counter, runs a function in a goroutine, and decrements the counter when it's done: 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 ( ), it blocks and waits for someone to receive that value ( ). Only then does it continue. 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: To signal readers that all data has been sent, the writer goroutine closes the channel with : 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 status. If the channel is closed, the reader gets a zero value and a 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. 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. You can protect yourself from accidental write/close errors by setting the channel direction. Channels can be: 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 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 and functions: Reading from a closed buffered channel returns values from the buffer and a status. Once all values are taken, it returns a zero value and a status, like a regular channel: Like any type in Go, channels have a zero value, which is . Writing to or reading from a nil channel blocks the goroutine indefinitely: Closing a nil channel causes a panic: The select statement is somewhat like , but specifically designed for channels. Here's what it does: Select is used to manage data flow in pipelines: To cancel goroutines: For non-blocking operations: And for much more. 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: A goroutine can signal other goroutines that it has finished its work using an output channel : If a goroutine doesn't need to return results, it can signal completion using a done channel : To terminate a goroutine early, a calling goroutine can use a cancel channel : There are three approaches to error handling in concurrent pipelines. ➊ Return on the first error: ➋ Use a result type: ➌ Collect errors separately: Besides handling date and time, the package offers tools for managing time-sensitive operations in concurrent programs. returns a channel that is initially empty, but receives a value after the timeout period. It's useful for timing out operations: waits for to complete, but thanks to , it won't wait longer than the duration: A timer ( ) is a structure with a channel to which it sends the current time when it triggers (expires). Timers are useful for planning future executions: stops the timer and returns if it hasn't expired yet, and otherwise: It's often more convenient to use the wrapper function. It waits for duration and then executes function : 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: A ticker is like a timer, but it keeps firing until you stop it. Tickers are useful for executing periodic tasks: creates a ticker that sends the current time to the channel at interval . You must stop the ticker eventually with to free up resources. If the channel reader can't keep up with the ticker, the ticker will skip ticks. The main purpose of context is to cancel operations, either manually or by timeout/deadline. The function accepts a context and uses its channel to listen for cancellation: Cancel manually ( error): Cancel by timeout ( error): Cancel by deadline ( 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 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 , and . This cause is accessible through : You can register a function to execute when the context is canceled with : Context can pass additional information about a call using , 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. The type lets you wait for one or more goroutines to finish: A doesn't know anything about the goroutines it manages. It works with an internal counter. Calling increments the counter by one, while decrements it. blocks the calling goroutine until the counter reaches zero. The method combines , starting a goroutine, and : All methods are safe to use from multiple goroutines. Normally, all calls happen before . But technically, there's nothing stopping you from doing some of the calls before and some after (from another goroutine). You can call from multiple goroutines. They will all block until the group's counter reaches zero. 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 flag, which works with the , , , and commands. Channels are safe for concurrent reading and writing, and they don't cause data races. Ways to prevent data races: 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: 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: The 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 and at a time. A mutex is used in these situations: If all goroutines are only reading the data, you don't need a mutex. The method tries to lock the mutex, just like a regular . But if it can't, it returns right away instead of blocking the goroutine: The type distinguishes between readers and writers. It provides two sets of methods: Here's how it works: This creates a "single writer, multiple readers" setup. Both and implement the same interface: By using 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. You can use a channel instead of a mutex to protect shared data: 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: 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 package. A rendezvous lets two goroutines wait for each other: You can implement a simple rendezvous with a wait group: A barrier is a general case of a rendezvous. It lets N goroutines wait for each other: You can implement a simple barrier with a wait group: The (conditional variable) type lets one goroutine signal to another that it's ready, and lets the other goroutine wait for that signal. A includes a mutex and has two methods — and . If there are multiple waiting goroutines when is called, only one of them will be resumed. If there are no waiting goroutines, does nothing. You can also use the method. While wakes up only one goroutine waiting on , the 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: The type makes sure that the given function runs only once. If multiple goroutines call at the same time, only one will run the function, while the others will wait until it returns: is perfect for one-time initialization or cleanup in a concurrent environment. Besides the type, the package also includes three convenience once-functions: The type helps reuse memory instead of allocating it every time, which reduces the load on the garbage collector: takes an item from the pool. If there are no available items, it creates a new one using (which we have to define ourselves, since the pool doesn't know anything about the items it creates). returns an item back to the pool. Things to keep in mind: 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 package: Each atomic type provides the following methods: Numeric types also provide an 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: If your concurrent program uses channels or custom types with synchronization methods like , 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 package. It exports two functions: runs an isolated bubble. The bubble uses a fake clock, and you can manually control goroutine synchronization with . blocks until all goroutines in the bubble — except the one that called — 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 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 ➌ isn't running. Thanks to this, time-dependent tests run instantly: The following operations durably block a goroutine: Blocking on mutexes, I/O, or system calls is not considered durable, and the bubble can't handle them. 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: The number of threads running Go code is controlled by the environment variable or the 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. 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 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 package. To collect a profile with the given name, call the endpoint. To view the collected profile, use the 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 package is running, call the endpoint to collect a trace. To view the results, use the 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: We've covered a number of Go tools for writing concurrent programs: 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! (bidirectional): for reading and writing (default); (send-only): for writing only; (receive-only): for reading only. 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. 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. Avoid concurrent data modification (typically by using channels). Synchronize access with mutexes. Use only atomic operations. Check if the assumed (old) state matches reality. If it does, change the state to new. If not, do nothing. When multiple goroutines are modifying the same data. When one goroutine is modifying the data and others are reading it. / lock and unlock the mutex for both reading and writing. / lock and unlock the mutex for reading only. If a goroutine locks the mutex with , other goroutines will be blocked if they try to use or . If a goroutine locks the mutex with , other goroutines can also lock it with without being blocked. If at least one goroutine has locked the mutex with , other goroutines will be blocked if they try to use . 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. 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. 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. unlocks the mutex and suspends the goroutine until it receives a signal. wakes the goroutine that is waiting on . When wakes up, it locks the mutex again. 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 at the same time, 1000 more buffers will be allocated. After an item is returned to the pool with , you shouldn't use it anymore (since another goroutine might already have taken and started using it). reads the value of a variable. sets a new value. sets a new value (like ) and returns the old one. sets a new value only if the current value is still what you expect it to be. 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 if all calls were made inside the bubble. 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. 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.