Go Goroutines and Channels Tutorial
A practical introduction to concurrency in Go: goroutines, channels, select, common patterns like fan-out/fan-in, and the pitfalls that cause leaks and races.
What you'll learn
- ✓What a goroutine actually costs
- ✓How channels coordinate goroutines
- ✓The select statement and timeouts
- ✓Fan-out/fan-in and worker pool patterns
- ✓Common leaks and race conditions
Prerequisites
- •Basic familiarity with the language
Go’s concurrency model is one of the reasons the language took off. Spawning a goroutine is cheap. Communicating between them through channels feels natural. The result is concurrent code that does not look concurrent. But “easy to write” is not the same as “easy to get right.” Goroutine leaks and channel deadlocks are real, and they are the most common Go bugs.
Goroutines
A goroutine is a function running concurrently with other goroutines, scheduled by the Go runtime onto a small pool of OS threads. Starting one is a single keyword.
go doSomething()
A goroutine has a stack of a few kilobytes, which grows as needed. You can have hundreds of thousands of them on a single process. The runtime multiplexes them onto threads using cooperative scheduling at function call boundaries.
package main
import (
"fmt"
"time"
)
func say(s string) {
for i := 0; i < 3; i++ {
fmt.Println(s)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
go say("hello")
say("world")
}
That program prints lines from both goroutines interleaved. When main returns, the program exits regardless of whether other goroutines are done.
Channels
A channel is a typed conduit for sending values between goroutines.
ch := make(chan int) // unbuffered
buf := make(chan int, 10) // buffered, capacity 10
ch <- 42 // send
v := <-ch // receive
close(ch)
Sends and receives synchronize. On an unbuffered channel, a send blocks until a receive is ready, and vice versa. That synchronization is how channels move values and coordinate timing in one step.
A buffered channel can hold up to its capacity without a receiver. Sends only block when the buffer is full.
The mental model
goroutine A: produce x ---> ch <--- goroutine B: consume x
(rendezvous on unbuffered)
goroutine A: produce x ---> [buf: x] <--- goroutine B
(decoupled by buffer) Unbuffered channels enforce a handshake. Buffered channels are queues with bounded capacity. The choice between them is the choice between synchronous and asynchronous handoff.
Select
select lets a goroutine wait on multiple channel operations at once.
select {
case msg := <-ch1:
fmt.Println("got", msg)
case ch2 <- 42:
fmt.Println("sent")
case <-time.After(time.Second):
fmt.Println("timeout")
}
The first case ready to proceed is taken. If multiple are ready, one is chosen at random. default makes the select non-blocking.
Timeouts via time.After are the standard idiom. Cancellation via a done channel (or context.Context) is how long-running goroutines learn to stop.
Patterns
Fan-out / fan-in: multiple workers consume from one channel and produce into another.
func worker(jobs <-chan int, results chan<- int) {
for j := range jobs {
results <- j * 2
}
}
func main() {
jobs := make(chan int, 100)
results := make(chan int, 100)
for w := 0; w < 4; w++ {
go worker(jobs, results)
}
for j := 1; j <= 9; j++ {
jobs <- j
}
close(jobs)
for r := 1; r <= 9; r++ {
fmt.Println(<-results)
}
}
close(jobs) signals the workers to stop. Ranging over a channel ends when the channel is closed.
Done channel for cancellation:
func produce(done <-chan struct{}, out chan<- int) {
defer close(out)
for i := 0; ; i++ {
select {
case out <- i:
case <-done:
return
}
}
}
The producer exits cleanly when the caller closes done.
sync.WaitGroup
When you need to wait for a known set of goroutines to finish, a WaitGroup is simpler than a result channel.
var wg sync.WaitGroup
for _, u := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
fetch(u)
}(u)
}
wg.Wait()
Note the variable capture: u is passed as an argument so each goroutine sees its own value. Without that, all goroutines might see the loop’s final u.
Common pitfalls
Goroutine leaks. A goroutine blocked forever on a channel is a leak. It costs memory and never frees. Always design exit paths: close channels, use done channels, propagate context.Context.
Sending on a closed channel. Panics. Only the sender should close. If many senders share a channel, use an external coordinator or sync.Once.
Range over a channel that is never closed. Loops forever. Symptom of a forgotten close.
Data races on shared state. Channels move data; they do not protect arbitrary fields. If you mutate a struct from multiple goroutines, use a mutex or copy the data through a channel.
Variable capture in goroutines. The closure captures by reference; loop variables change.
Practical tips
Prefer channels for communication and mutexes for protecting state. The Go proverb “share memory by communicating” is genuinely good advice, but it does not forbid mutexes; some problems are mutex problems.
Use context.Context to propagate cancellation through API boundaries. Every long-running function in modern Go takes a ctx.
Run with -race during development. The race detector finds bugs that show up once in a hundred runs in production.
Wrap-up
Goroutines and channels make concurrency in Go approachable. The patterns to internalize are: unbuffered for handshakes, buffered for queues, select for waiting, close for completion, and context for cancellation. Once you have those reflexes, concurrent Go reads as straight-line code with a sprinkle of go, and the bugs that remain are usually about leaks rather than races.
Related articles
- Go Go Context Cancellation Patterns
Master Go's context package: propagate deadlines, cancel goroutines safely, and avoid leaks with practical patterns for HTTP, database, and pipeline code.
- Go Go context Package Explained
How to use Go's context package effectively: cancellation, deadlines, propagation, request-scoped values, and the patterns that keep services responsive.
- Go Goroutines and Channels in Go: Concurrency Basics
Learn Go concurrency from the ground up: launching goroutines, communicating via channels, using select, range, and building a worker pool pattern.
- Rust Rust Channels and mpsc Tutorial
A practical tour of Rust's std::sync::mpsc channels: senders, receivers, backpressure, and patterns for safe message passing between threads.