Go Channels Explained: Concurrent Communication Guide

Go Channels Explained: Concurrent Communication
What are Go Channels?
Go channels are typed conduits that allow goroutines to communicate safely by passing data rather than sharing memory. A channel is created with make(chan T) where T is the data type. The send (ch <- value) and receive (value := <-ch) operators provide built-in synchronisation — unbuffered channels block both sides until a handoff occurs, making data races structurally impossible.
Concurrency 2: Channels & Communication
In the previous module, we learned how to launch Goroutines. However, if Goroutines can't communicate or synchronize, they're not very useful. In traditional languages, concurrent threads share memory and protect that memory with complex locks (mutexes).
Go takes a different approach, embodied in its most famous proverb: "Do not communicate by sharing memory; instead, share memory by communicating."
This communication is handled by Channels.
What is a Channel?
Working with Channels
Channels are typed—you must define what kind of data can pass through them. You use the make function to create a channel and the arrow operator (<-) to send and receive data.
Blocking Behavior
By default, sends and receives block until the other side is ready.
- A sender blocks until a receiver is ready to take the data.
- A receiver blocks until a sender puts data into the channel.
This blocking behavior provides a built-in mechanism for synchronization without needing explicit locks or wait timers.
Unbuffered vs. Buffered Channels
Everything we've discussed so far applies to Unbuffered Channels, which have a capacity of zero. Go also supports Buffered Channels, which can hold a limited amount of data without a corresponding receiver.
Channel Operations
close(ch)Signals that no more data will be sent. Receivers can detect a closed channel to stop looping.
chan<- intYou can define channels as 'send-only' or 'receive-only' in function signatures for better type safety.
for msg := range chConvenient way to loop over a channel until it is closed by the sender.
Directional Strategy
When writing functions that use channels, it's best practice to specify if the function should only send or only receive. This helps the compiler catch bugs where you accidentally try to read from a channel you should only be writing to.
| Task / Feature | Unbuffered Channels | Buffered Channels |
|---|---|---|
| Guaranteed Handoff | Yes | No (Sender continues until buffer is full) |
| Blocking | Always blocks both sides | Only blocks when full or empty |
| Use Case | Strong synchronization | Throttling or decoupled data flow |
Real-World Pattern: Worker Pool
One of the most powerful and practical uses of channels is building a worker pool — a fixed number of goroutines processing tasks from a shared queue. This is the foundation of any high-throughput Go service:
This pattern — a buffered input channel feeding a fixed pool of workers that write to a results channel — is used in everything from web scrapers to image processors to API batch handlers.
Common Pitfalls with Channels
Deadlock
The most common mistake with channels is creating a situation where all goroutines are blocked waiting for each other, causing a deadlock:
The fix: either launch a goroutine to receive, or use a buffered channel for small, one-time sends.
Goroutine Leak
Sending to a channel that nobody ever reads from leaves the sending goroutine suspended in memory forever — a goroutine leak:
Always design your channel communication so that senders know receivers will eventually consume all sent values, or use the select statement with a done channel to signal early termination.
When to Use Channels vs. Mutexes
A common point of confusion is choosing between channels and sync.Mutex for concurrent state management. A simple rule:
- Use channels when you are transferring ownership of data from one goroutine to another.
- Use mutexes when multiple goroutines need to read and write a shared variable (like a counter or a map).
For a simple shared counter, a mutex is simpler and more efficient than building a channel-based solution. For a producer-consumer pipeline where data flows from one stage to the next, channels are the right tool.
Further Reading
This module is part of the Go concurrency series. See the previous post on goroutines and the Go scheduler and the next post on select statements and WaitGroups. For managing cancellation of goroutine trees, see the Context API.
Next Steps
Channels are the foundation of Go's concurrency model, but what if you need to coordinate between multiple channels at once? Or what if you want to stop a Goroutine if it takes too long? In our next module, we will explore Select and WaitGroups, the orchestration tools of the Go world.
Common Channel Mistakes
1. Deadlock from unbuffered channel with no receiver Sending to an unbuffered channel blocks until a receiver is ready. If both sender and receiver are in the same goroutine, or no receiver goroutine is started, the program deadlocks. Always start the receiver goroutine before the sender when using unbuffered channels.
2. Sending on a closed channel
Sending to a closed channel panics. Only close a channel when you are certain no more sends will occur. A common safe pattern: close in a defer inside the goroutine that owns the channel.
3. Ignoring the ok value on channel receive
val := <-ch returns the zero value silently when the channel is closed. Use val, ok := <-ch and check ok to distinguish "received a real value" from "channel was closed". See the Go spec on receive operations.
4. Buffered channels masking race conditions A buffered channel with capacity 1 can make a race condition disappear in testing but reappear under load. Do not use a buffer as a workaround for a design flaw — fix the underlying synchronisation.
5. Ranging over a channel that is never closed
for val := range ch exits only when ch is closed. If the sending goroutine never closes the channel, the range loop blocks forever, creating a goroutine leak.
Frequently Asked Questions
When should I use a buffered vs unbuffered channel? Use an unbuffered channel when you want strict synchronisation — the sender blocks until the receiver is ready, giving you a rendezvous point. Use a buffered channel to decouple the sender from the receiver by allowing the sender to proceed without waiting, up to the buffer capacity. The Go tour on channels demonstrates both forms.
How do I fan out work across multiple goroutines using channels?
Create a shared job channel and start N worker goroutines, each reading from the same channel. The Go runtime distributes sends across available workers. Close the job channel when all work is submitted and workers will exit naturally via a for range loop.
What is a select statement used for with channels?
select lets a goroutine wait on multiple channel operations simultaneously, executing the first one that is ready. It is the primary tool for implementing timeouts (select with a time.After case), cancellation (select with ctx.Done()), and non-blocking sends/receives (using a default case).
