Getting Started with Go, Part 3: Concurrency — Goroutines & Channels
Parts 1 and 2 built a processor that handles jobs one at a time, in order. That's the point where most languages reach for a thread pool, an async runtime, or a task queue library. Go's answer is built into the language itself: goroutines and channels. This part turns the single-threaded processor into a concurrent worker pool.
Goroutines: cheap, not free
go in front of a function call runs it concurrently, on a goroutine — a unit of concurrency the Go runtime schedules onto a small number of OS threads, not a 1:1 mapping to threads the way most languages' native threads work:
go processJob(job)
That line returns immediately; processJob runs concurrently, and the calling function has no idea when it finishes unless you tell it to wait. Goroutines start around 2KB of stack (growing as needed) instead of the megabyte-plus a real OS thread costs, which is why spinning up thousands of them is a normal, boring thing to do in Go — it would be a bad idea with OS threads in most other languages.
"Cheap" doesn't mean "free," though. A goroutine that's never told to stop, is never signals it's done, or blocks forever on a channel nobody else touches — that's a goroutine leak, and it's the single most common concurrency bug in Go code. We'll deal with that properly by the end of this part.
Channels: how goroutines talk
A channel is a typed pipe — one goroutine sends, another receives, and the runtime handles the synchronization for you:
jobs := make(chan Job) // unbuffered — send blocks until a receiver is ready
results := make(chan Result, 10) // buffered — send only blocks once it's full
An unbuffered channel forces a handoff: the sender can't proceed until a receiver is actively waiting, which is a useful guarantee when you need to know work was actually picked up, not just queued. A buffered channel decouples the two up to its capacity — useful when producers and consumers run at different speeds and you don't want either one stalling on every single item.
The range-over-channel form reads until the channel is closed:
for job := range jobs {
// handle job
}
That loop exits cleanly when (and only when) the channel is closed — reading from an open-but-empty channel just blocks, it doesn't return a zero value and move on. This is the part that catches people: only the sender should close a channel, and only when it's genuinely done sending. Closing a channel a receiver is still using, or closing it twice, is a runtime panic — not a warning, a hard crash.
select and cancellation
select waits on multiple channel operations at once and proceeds with whichever one is ready first — it's the mechanism behind timeouts and cancellation:
select {
case job := <-jobs:
process(job)
case <-ctx.Done():
return // context was cancelled or timed out
}
context.Context is the standard library's answer to "tell a tree of goroutines to stop" — you create one with a timeout or a cancel function, pass it down through every function that might need to bail early, and every one of them checks ctx.Done() the same way. It's a convention baked hard enough into the standard library that most functions taking a context.Context take it as their first parameter, by convention, every time.
The worker pool
Here's Parts 1–2's processor turned concurrent: a fixed pool of workers pulling from a shared jobs channel, each one writing its result to a results channel, with a sync.WaitGroup marking when every worker has actually finished (not just when the jobs channel is empty — a worker might still be mid-job after the last item was sent). This is a complete, runnable program:
package main
import (
"fmt"
"sort"
"sync"
"time"
)
type Job struct {
Name string
Priority string
}
type Result struct {
Job string
Worker int
Message string
}
func worker(id int, jobs <-chan Job, results chan<- Result, wg *sync.WaitGroup) {
defer wg.Done()
for j := range jobs {
time.Sleep(time.Millisecond) // stand-in for real work
results <- Result{
Job: j.Name,
Worker: id,
Message: fmt.Sprintf("worker %d handled %s (priority=%s)", id, j.Name, j.Priority),
}
}
}
func main() {
jobList := []Job{
{Name: "urgent-deploy", Priority: "HIGH"},
{Name: "cleanup", Priority: "LOW"},
{Name: "urgent-migrate", Priority: "HIGH"},
{Name: "batch-report", Priority: "LOW"},
{Name: "audit-log", Priority: "NORMAL"},
{Name: "urgent-rollback", Priority: "HIGH"},
}
jobs := make(chan Job, len(jobList))
results := make(chan Result, len(jobList))
const workerCount = 3
var wg sync.WaitGroup
for w := 1; w <= workerCount; w++ {
wg.Add(1)
go worker(w, jobs, results, &wg)
}
for _, j := range jobList {
jobs <- j
}
close(jobs) // safe: only the sender closes, and we're done sending
go func() {
wg.Wait()
close(results)
}()
var collected []Result
for r := range results {
collected = append(collected, r)
}
// Concurrent work finishes in a nondeterministic order — sort before
// printing so the output is stable and comparable across runs.
sort.Slice(collected, func(i, j int) bool { return collected[i].Job < collected[j].Job })
for _, r := range collected {
fmt.Println(r.Message)
}
}
Notice the shape: jobs is closed once, by the sender, after every item is sent. results is closed by a separate goroutine that waits for all workers to finish first — closing it from main directly, without the wg.Wait(), would close it while workers were still trying to send to it, which panics. Hit Run a few times and watch which worker number handles which job change between runs — that nondeterminism is real, it's why the output is sorted before printing, and it's the exact reason a job's result should never depend on which worker happened to process it.
The footgun list
Three mistakes account for almost every concurrency bug beginners hit:
- Closing a channel from the receiver side, or more than once. Only the sender closes, only once. If you need multiple senders to agree when to close, coordinate with a
sync.WaitGroupthe way the results channel does above. - A goroutine blocked on a channel nobody will ever write to or read from again. That goroutine leaks forever — it's not garbage collected, because from the runtime's perspective it might still resume.
contextcancellation, or making sure every channel has a defined "done" path, is how you prevent this. - Capturing a loop variable by reference instead of by value. In loops that launch goroutines, always pass the loop variable in as a parameter (as
worker(w, ...)does above) rather than closing over it directly, unless you're on Go 1.22+ where the loop variable is scoped per-iteration by default — before that change, every goroutine could see the same, final value of the loop variable instead of the one it was launched with.
Further Reading
- Effective Go: Concurrency
- Go blog: Share Memory By Communicating
- Go blog: Go Concurrency Patterns: Context
- The Go Memory Model — the formal rules behind what's guaranteed to happen-before what