Go Concurrency Explained Simply — Goroutines & Channels in 10 Minutes

I built a GPU inference cluster that handles thousands of requests per second. Concurrency used to terrify me. Thread pools, mutex locks, race conditions — these terms sent most developers running toward simpler languages where everything runs sequentially by default. I watched colleagues spend weeks debugging subtle timing bugs that only appeared under production load two hours before deployment deadlines across different time zones. Go changed that calculus entirely.

The language emerged from Google’s infrastructure teams grappling with exactly these problems at scale. The kind of challenges you encounter when you need to handle millions of simultaneous connections reliably without your servers catching fire or your engineering team burning out debugging thread safety issues every other sprint cycle.

This is a genuinely uncomfortable realization for experienced engineers who have spent years mastering traditional threading models, even if those models require constant vigilance against subtle bugs hiding everywhere waiting patiently for production traffic patterns to reveal their existence. The worst possible moment.

Starting Goroutines Takes One Keyword

Adding go before any function call spins up a new goroutine in zero additional lines of setup code.

func fetchData(url string) []byte { /* ... */ }

The sequential approach processes URLs one after another: results := make([][]byte, len(urls)) for i := range urls {. When I prefix that loop body with go fetchData(urls[i]), each HTTP request runs concurrently rather than waiting for completion before starting the next one.

The key insight here is that calling go does not guarantee simultaneous CPU use. It guarantees non-blocking behavior so other work can proceed while each call executes independently. Go’s runtime scheduler handles distribution across physical cores automatically using GOMAXPROCS, which defaults to your machine’s CPU count reported by the operating system at program startup. A few milliseconds per operation on spinning media versus near-zero latency on flash storage.

Sequential processing writing four files takes cumulative wall-clock time adding each operation together.

// Sequential writes blocking main thread until each completes
for _, path := range []string{"/tmp/a.txt", "/tmp/b.txt", "/tmp/c.txt", "/tmp/d.txt"} {
writeFile(path) // Each call waits before proceeding
}

Converting this pattern requires collecting results since goroutines complete in nondeterministic order. The fan-out/fan-in technique solves that ordering preservation problem cleanly without sacrificing concurrency benefits entirely. The performance difference becomes measurable when I/O bound work dominates versus CPU intensive tasks where goroutines timeslice onto available cores.

Channels Control Data Flow Between Goroutines

Buffered channels changed that math entirely. Switching to ch := make(chan int, 3) allocated a queue holding up to three integers before blocking. My goroutines could fire off three sends immediately without waiting for receivers. Throughput climbed significantly in that burst window. The tradeoff hit me when I filled the buffer completely — a fourth send blocked just like an unbuffered channel would have.

Capacity determines your burst window size: make(chan WorkUnit, runtime.NumCPU()) let my CPU-bound tasks queue eight units simultaneously on an eight-core machine. I prefer unbuffered channels for critical path synchronization where dropping a signal causes corruption rather than delay. I reach for buffered channels when decoupling producer rate from consumer rate matters more than absolute delivery guarantees.

The built-in len(ch) function returns current queue depth at any moment. I log this value every 200ms using Prometheus Gauge vectors to catch backpressure before it cascades through the pipeline.

Handle Multiple Channels with select {}

When your application listens to several goroutines at once, a plain <-ch receive locks you into whichever source fires first. The select statement solves this elegantly. It blocks until one of its cases becomes unblocked across any monitored channel. Here’s how two concurrent timers compete through a single selector.

select {
case msg := <-fastSource:
handleData(msg)
case msg := <-slowSource:
handleData(msg)
case <-time.After(300 * time.Millisecond):
fmt.Println("deadline exceeded")
}

If both fastSource and slowSource send simultaneously in this setup, Go randomly picks one. Fairness is built into the runtime scheduler by design.

In distributed aggregation pipelines I monitor through Prometheus dashboards at work, fan-out patterns like this prevent any single slow dependency from starving the entire request cycle. A central goroutine spawns workers per region. Each writes results into its own typed channel. Then joins them all inside a single select block instead of chaining futures sequentially. Without multiplexing via select, you’d need to chain futures sequentially, which blocks the entire pipeline when any single dependency runs slowly.

Debugging Concurrency Bugs Is a Different Skill

Deadlocks bite hard because Go doesn’t always warn you immediately. When your program hangs indefinitely with no output, it’s usually stuck in circular wait — one goroutine holding Lock A while waiting for Lock B, while another holds B waiting for A. Running with GOTRACEBACK=crash gives you stack traces showing exactly where each thread froze.

Data races destroy correctness in ways tests rarely catch on the first pass. The -race flag activates a runtime sanitizer that instruments every memory access. Add it like this: go test -race ./.... I caught bugs hiding for months by enabling it during development instead of waiting for production crashes.

Goroutine leaks are subtler than crashes since the process stays alive but bleeds memory over time. The /debug/pprof/ endpoint exposes goroutine counts via its heap profile. Compare snapshots taken minutes apart to spot gradual growth before it exhausts your container limits. Closing a nil or already closed channel panics immediately. Always initialize channels before passing them between goroutines and use the defer close() pattern to ensure cleanup happens regardless of how the function exits.


Keep Reading

Go’s concurrency model doesn’t eliminate complexity — it makes complexity manageable. The tools you’ve seen here — goroutines, channels, select, and the race detector — give you a structured way to reason about concurrent behavior. Start with unbuffered channels for synchronization, add buffering only when you measure backpressure, and always run -race in CI. That discipline turns concurrency from a source of dread into a competitive advantage.