Concurrency is the feature that makes Go famous, and it rests on two ideas: goroutines (cheap, lightweight threads managed by the Go runtime) and channels (typed pipes that goroutines use to pass values to each other). Neither is complicated on its own. This guide explains both with small runnable examples, then combines them into the pattern you will use most: a worker pool.
Goroutines: functions that run alongside
Prefix any function call with go and it runs concurrently — the main program continues immediately instead of waiting:
func main() {
go fmt.Println("from a goroutine")
fmt.Println("from main")
time.Sleep(time.Millisecond * 100)
}
Goroutines are not OS threads. The Go runtime multiplexes thousands of them onto a few threads, each starting with a stack of only a few kilobytes — which is why Go programs casually launch goroutines where other languages reach for thread pools. The time.Sleep above is a teaching shortcut: without it, main would exit and kill the goroutine mid-flight. Real programs coordinate properly, which is what channels and sync.WaitGroup are for.
Channels: pass values, don’t share memory
Go’s concurrency motto is “share memory by communicating”. A channel is a typed conduit:
ch := make(chan string) // create a channel of strings
go func() {
ch <- "hello" // send
}()
msg := <-ch // receive
fmt.Println(msg) // hello
By default, sends and receives block. A send waits until another goroutine receives; a receive waits until another sends. That synchronisation is not a limitation — it is the mechanism that lets two goroutines agree “this value has been handed over” with no locks.
Unbuffered vs buffered channels
make(chan T) creates an unbuffered channel: sender and receiver must meet. make(chan T, 3) creates a buffer of three slots: sends complete immediately until the buffer fills, which decouples a fast producer from a slower consumer. Use unbuffered by default — it gives a stronger guarantee (a completed send means a completed hand-off) — and add buffering when you have measured a reason.
Closing channels and ranging over them
The sender closes a channel with close(ch) to announce “no more values”. Receivers can then drain it with a range loop that ends automatically at the close:
for value := range ch {
fmt.Println(value)
}
Only the sender may close, and a channel is closed at most once — closing is a broadcast to every receiver, which makes it the standard way to shut down a pipeline stage.
select: wait on several channels at once
select blocks until one of its channel cases can proceed, and picks randomly among ready cases. Its most valuable trick is the timeout:
select {
case res := <-resultCh:
fmt.Println("result:", res)
case <-time.After(2 * time.Second):
fmt.Println("timed out")
}
The same mechanism implements cancellation: pass a done channel (or a context.Context) into workers and let them select on it, so stopping is instant instead of waiting for work to finish.
The worker pool: everything combined
Three workers share one jobs channel; results flow back on another. This is the shape behind web crawlers, image processors, queue consumers, and background tasks in web services (such as in our guide to building a REST API with Go and Fiber):
func worker(id int, jobs <-chan int,
results chan<- int) {
for j := range jobs {
results <- j * 2
}
}
func main() {
jobs := make(chan int, 5)
results := make(chan int, 5)
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}
for j := 1; j <= 5; j++ {
jobs <- j
}
close(jobs)
for r := 1; r <= 5; r++ {
fmt.Println(<-results)
}
}
Notice the direction arrows in the worker signature (<-chan, chan<-): they make the data flow explicit and let the compiler catch a worker trying to send on the jobs channel. Note also the coordination: main knows exactly five results are coming, so no WaitGroup is needed here — when the count is unknown, pair goroutines with sync.WaitGroup and close the results channel once all workers finish.
The three mistakes to avoid
-
Deadlock by symmetry. An unbuffered send with no receiver blocks forever and Go’s runtime will tell you:
all goroutines are asleep - deadlock!Every send needs a guaranteed receiver. -
Leaked goroutines. A goroutine blocked on a channel nobody closes lives forever. Whoever creates a goroutine owns its exit path.
-
Sending on a closed channel. It panics. Receives never panic — sends and double-closes do. Close from the sender side only. (When propagating failures across goroutines, return structured error values as explained in our practical guide to Go error handling).
Start goroutines freely, but design the channels first: draw who sends, who receives, and who closes. If that diagram is clear, the code almost writes itself.



