Every concurrency primitive Go offers — from goroutines and channels to context, sync.Once, and the race detector — with diagrams, code, real-world use cases, and honest trade-offs.
Go's goroutines and channels give you a concurrent structure.
The Go runtime's M:N scheduler then maps those goroutines to OS threads, which run on physical CPU cores in
true parallel. Set GOMAXPROCS=runtime.NumCPU() (the
default since Go 1.5) and your concurrent Go program automatically becomes parallel. You write for
concurrency; Go delivers parallelism for free.
GOMAXPROCS=1: goroutines interleave but never truly parallelgo keyword. Initial stack is ~2KB
(growing dynamically up to 1GB). You can run millions of goroutines on a single machine
where OS threads would crash at ~10,000.A goroutine is a function executing concurrently with other goroutines in the same address space. Unlike OS threads, goroutines are multiplexed onto a smaller number of OS threads by the Go runtime. When a goroutine blocks on I/O, the runtime moves it off the thread and runs another goroutine instead — maximizing CPU utilization with zero developer effort.
Goroutines are not OS threads. They're an abstraction ON TOP of OS threads. The Go scheduler (part of the runtime) decides which goroutines run on which threads.
go myFunc() — goroutine placed in P's local run queue
Waiting in queue to be scheduled onto an M (OS thread)
Executing on an M. Preempted after 10ms time slice or on yield point
Waiting on channel, mutex, or syscall. M free to run other goroutines
Function returned. Stack reclaimed. goroutine exits cleanly.
go func()ch <- val — blocks if unbuffered (until receiver ready) or
buffer fullval := <-ch — blocks until value is availableclose(ch) — only sender should close; signals no more values
for v := range ch — reads until channel closedv, ok := <-ch — ok=false means closedchan T — bidirectional (can send and receive)<-chan T — receive-only (function can only read from it)chan<- T — send-only (function can only write to it)chan<-, consumer gets <-chan — prevents mistakesselect: that case is never selecteddone := make(chan struct{}) — zero-size struct signalclose(done) to broadcast to all receivers simultaneouslyAdd(n) to set the count, each goroutine calls Done() when
finished, and Wait() blocks until the counter reaches zero. It's the standard way to fan-out
work and collect completion.defer wg.Done() ensures completion even on panicMutex when all access is read+write. Use RWMutex
(read-write mutex) when reads are frequent and writes are rare — allows multiple concurrent readers OR one
exclusive writer.RLock(): multiple goroutines can hold simultaneouslyLock(): blocks until ALL RLock holders releasesync.Map is a built-in concurrent-safe map (use over map+Mutex for read-heavy)
defer mu.Unlock() guarantees unlock even on panicdefault
case makes select non-blocking.defer cancel() leaks resources (common mistake)Do() after
the first are no-ops. Zero overhead after the first execution. The backbone of goroutine-safe lazy
initialization and the Singleton pattern in Go.go test -race or go run -race. Detects concurrent
reads and writes to shared variables that aren't synchronized. Also understand deadlock detection — Go's
runtime prints a deadlock stack trace and panics when all goroutines are blocked.A data race occurs when two or more goroutines access the same variable concurrently, and at least one of the accesses is a write, and they are not synchronized by any synchronization primitive (channel, mutex, atomic).
Data races are undefined behavior in Go — they can cause corrupted data, crashes, or seemingly correct but wrong results. They are the #1 bug in concurrent code.
go func() { use(i) }() — all goroutines see same
i. Fix: pass as argument go func(id int) { use(id) }(i)
Every primitive at a glance — when to reach for each tool.
go test -race. Finds data races using ThreadSanitizer. 5-15x slower —
dev/test only. Runtime auto-detects total deadlocks.| Situation | Use This | Why | Avoid This |
|---|---|---|---|
| Run code concurrently | go func() | Goroutine is the atomic unit of concurrency | OS threads (too heavy) |
| Pass data between goroutines | channel | Type-safe, goroutine-safe ownership transfer | Shared variable without sync |
| Wait for N goroutines | WaitGroup | Simple barrier — Add/Done/Wait | time.Sleep (wrong) |
| Protect shared counter/map | Mutex | Simple exclusive access to shared state | Channel (overkill) |
| Frequent reads, rare writes | RWMutex | Multiple concurrent readers allowed | Mutex (blocks all readers) |
| Wait on multiple channels | select | Built-in multi-channel multiplexer | Polling loop (wasteful) |
| Timeout / cancel operation | context | Standard propagation across goroutines and libs | Timer channel alone |
| Non-blocking channel check | select + default | Default case makes select non-blocking | Spinning goroutine |
| Initialize singleton | sync.Once | Exactly once, goroutine safe, zero overhead after | init() (always runs) |
| High-freq counter / flag | atomic.Int64 | 6x faster than mutex, hardware guarantee | Mutex (overkill for single int) |
| Broadcast stop signal | close(done) or ctx.Cancel() | close() fans out to all goroutines at once | Sending N times for N goroutines |
| Find concurrency bugs | go test -race | ThreadSanitizer catches races at runtime | Code review alone (insufficient) |
| Concurrent-safe map | sync.Map or map+RWMutex | sync.Map for read-heavy, RWMutex for write-heavy | Plain map (concurrent panic) |
defer wg.Done() and defer cancel() — never trust your own
code not to panic.
go test -race in CI on every commit. No exceptions.