I’ve built services that handle 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. I watched colleagues spend weeks debugging subtle timing bugs that only appeared under production load.
Go changed that calculus entirely. The language emerged from Google’s infrastructure teams grappling with exactly these problems at scale — millions of simultaneous connections, reliably, without your servers catching fire or your team burning out on thread safety issues every sprint.
Starting Goroutines Takes One Keyword
Adding go before any function call spins up a new goroutine. Zero additional lines of setup code.
func fetchData(url string) []byte {
// ...
}
// Sequential: processes URLs one after another
for _, url := range urls {
fetchData(url)
}
// Concurrent: each request runs independently
for _, url := range urls {
go fetchData(url)
}
When you prefix with go, each HTTP request runs concurrently rather than waiting for completion before starting the next one.
The key insight: 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.
Sequential processing writing four files takes cumulative wall-clock time:
// Each call waits before proceeding
for _, path := range []string{"/tmp/a.txt", "/tmp/b.txt", "/tmp/c.txt", "/tmp/d.txt"} {
writeFile(path)
}
Converting this requires collecting results since goroutines complete in nondeterministic order. The fan-out/fan-in technique solves that ordering problem cleanly.
Unbuffered vs Buffered Channels
Unbuffered channels force goroutines to synchronize at exactly the point of handoff. When you run ch := make(chan int), every send blocks until a receiver is ready.
This means your pipeline can’t leak data — the goroutine pauses right after putting a value on the channel. I tested this by spawning five goroutines that each sent one value through an unbuffered channel. Total elapsed time hit exactly 5 × operation_duration because each send waited for its matching receive.
Buffered channels change that math. Switching to ch := make(chan int, 3) allocates a queue holding up to three integers before blocking. Goroutines can fire off three sends immediately without waiting for receivers. Throughput climbs in that burst window. The tradeoff: a fourth send blocks just like unbuffered.
Capacity determines your burst window size:
ch := make(chan WorkUnit, runtime.NumCPU())
This lets CPU-bound tasks queue eight units simultaneously on an eight-core machine.
Rules of thumb:
- Unbuffered for critical path synchronization where dropping a signal causes corruption
- Buffered when decoupling producer rate from consumer rate matters more than absolute delivery guarantees
The built-in len(ch) returns current queue depth at any moment. Log this value periodically 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 blocks until one of its cases becomes ready across any monitored channel:
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, Go randomly picks one. Fairness is built into the runtime scheduler.
In distributed aggregation pipelines, 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 block the entire pipeline when any single dependency runs slowly.
Debugging Concurrency Bugs
Deadlocks bite hard because Go doesn’t always warn you immediately. When your program hangs 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:
go test -race ./...
I caught bugs hiding for months by enabling this during development instead of waiting for production crashes.
Goroutine leaks are subtler than crashes — 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 channels safely: a nil or already-closed channel panics immediately. Always initialize channels before passing them between goroutines and use defer close() to ensure cleanup regardless of how the function exits.