Table of Contents
- Concurrency Questions
- Table of Contents
- Goroutines
- Channels
- select
- The sync Package
- context
- Patterns: Worker Pools & Pipelines
- Races & Deadlocks
- The GMP Scheduler
- Goroutine Leaks
Concurrency Questions¶
25+ questions on goroutines, channels, select, the sync package, context, the GMP scheduler, and the classic leak/race traps. Difficulty: 🟢 junior · 🟡 mid · 🔴 senior.
Table of Contents¶
- Goroutines
- Channels
- select
- The sync Package
- context
- Patterns: Worker Pools & Pipelines
- Races & Deadlocks
- The GMP Scheduler
- Goroutine Leaks
Goroutines¶
🟢 What is a goroutine and how is it different from an OS thread?
A goroutine is a lightweight, runtime-managed thread of execution started with the `go` keyword. It begins with a tiny (~8KB) growable stack, whereas an OS thread reserves a large fixed stack (often 1–8MB), so you can run hundreds of thousands of goroutines. The Go runtime multiplexes many goroutines onto a small number of OS threads via its scheduler, and switching between goroutines is much cheaper than a kernel thread context switch. You don't manage threads directly; you just create goroutines and let the scheduler place them.🟡 How do you wait for goroutines to finish?
The idiomatic tool is `sync.WaitGroup`: call `wg.Add(n)` before launching, `wg.Done()` (usually deferred) inside each goroutine, and `wg.Wait()` to block until the counter hits zero. For collecting errors, `golang.org/x/sync/errgroup` wraps a WaitGroup and returns the first error while cancelling a shared context. Avoid ad-hoc `time.Sleep` for synchronization — it is a race waiting to happen.🔴 What is the classic loop-variable capture bug with goroutines, and what changed in Go 1.22?
Before Go 1.22, a `for` loop reused a single loop variable across iterations, so closures launched as goroutines all captured the same variable and typically observed its final value: The classic fixes are to pass the variable as an argument (`go func(v Item){...}(v)`) or shadow it (`v := v`). Go 1.22 changed the semantics so each iteration gets a fresh loop variable, eliminating the bug for new code — but you must know your target Go version, and the argument-passing idiom remains the clearest, version-independent fix.🟡 Does `main` wait for goroutines to finish before exiting?
No. When the `main` function returns, the program exits immediately and any still-running goroutines are killed abruptly without running their deferred functions. This is why you need explicit synchronization (`WaitGroup`, channels, `errgroup`) to wait for work to complete. A common beginner bug is launching goroutines and seeing no output because `main` returned first.Channels¶
🟢 What is a channel and what is the difference between buffered and unbuffered?
A channel is a typed conduit for sending and receiving values between goroutines, providing both communication and synchronization. An unbuffered channel (`make(chan T)`) is synchronous: a send blocks until another goroutine receives, so it's a rendezvous. A buffered channel (`make(chan T, n)`) lets up to `n` sends proceed without a matching receiver; sends block only when the buffer is full and receives block only when it's empty. Use unbuffered channels when you want a handoff guarantee, buffered when you want to decouple producer and consumer rates.🟡 What are the "channel axioms" — behavior of nil and closed channels?
These rules are worth memorizing: - Send to or receive from a **nil** channel blocks **forever**. - Send to a **closed** channel **panics**. - Receive from a **closed** channel returns immediately with the zero value; use the comma-ok form (`v, ok := <-ch`) where `ok == false` signals closed and drained. - Closing a nil channel or closing an already-closed channel **panics**. The nil-blocks-forever rule is actually useful: setting a channel variable to `nil` disables its case in a `select`.🟡 Who should close a channel, and why?
The sender closes, never the receiver, because only the sender knows when no more values will be sent, and sending on a closed channel panics. With multiple senders, no single sender should close; instead coordinate with a separate "done" signal or a `sync.WaitGroup` plus a dedicated closer goroutine, or use `context` cancellation. Closing is a broadcast that the stream is finished — it is optional (you don't have to close every channel) and mainly needed to signal `range`/`for` receivers to stop.🟢 How do you range over a channel, and when does the loop end?
`for v := range ch` receives values until the channel is closed and drained, then exits the loop cleanly. If the channel is never closed and no more values arrive, the loop blocks forever — a frequent leak source. So `range` over a channel implies a contract that some sender will eventually `close(ch)`.🔴 What happens when you send on a closed channel, and how do you avoid it?
It panics with `"send on closed channel"`, and this panic cannot be safely prevented with a `select`. The fix is structural: establish clear ownership so the closing goroutine is the only sender, or use a separate cancellation channel/`context` to tell senders to stop and let the closer close only after all senders have exited (coordinate with a `WaitGroup`). Never use `recover` as a routine guard around sends; redesign so the close happens after senders are known to be done.🔴 What is the difference between closing a channel and sending a value to signal completion?
Closing is a one-time broadcast: every current and future receiver observes it (receives return immediately with `ok == false`), which makes `close(done)` the idiomatic way to signal "stop" to many goroutines at once. Sending a value reaches exactly one receiver per send, so it can't broadcast. That is why done/cancellation channels are signalled by `close`, often using `chan struct{}` since the value carries no data. `context.Done()` is built on exactly this closed-channel broadcast.select¶
🟢 What does `select` do?
`select` waits on multiple channel operations and proceeds with one that is ready; if several are ready it chooses one at random (to avoid starvation). It is the core multiplexing primitive for concurrency — letting a goroutine respond to whichever of several events happens first. A `default` case makes the `select` non-blocking (it runs if nothing else is ready), and an empty `select{}` blocks forever.🟡 How do you implement a non-blocking send or receive?
Add a `default` case so the `select` doesn't block when no channel is ready: This is how you implement "try send" semantics, e.g. dropping metrics under load rather than blocking the hot path. A non-blocking receive uses the same pattern with `case v := <-ch`.🔴 How do you add a timeout to a channel operation, and what is the leak with `time.After`?
Use a timeout case in a `select`: The subtle leak: `time.After` creates a `Timer` that isn't garbage-collected until it fires, so in a hot loop or a long-lived `select` that usually takes the other branch, you accumulate pending timers and memory. For repeated use, create a `time.NewTimer`, `Stop()` and `Reset()` it, or prefer a `context.WithTimeout` whose cancel you `defer`. (Go 1.23 made `time.After` timers collectable earlier, but `context` is still the cleaner pattern.)🔴 How does setting a channel to nil interact with select?
A `nil` channel's case in a `select` is never ready (send/receive on nil blocks forever), so assigning `nil` to a channel variable dynamically disables that branch. This is a powerful idiom for state machines: once an input channel is drained/closed, set it to `nil` so its case stops firing while other cases keep working, avoiding a busy-spin on a closed channel that always returns immediately.The sync Package¶
🟢 When should you use a mutex instead of a channel?
Use a mutex (`sync.Mutex`) to protect shared state when the goroutines are simply guarding access to a piece of memory — a counter, a cache map, a config struct. Use channels when you are passing ownership of data or coordinating the flow of work between goroutines ("share memory by communicating"). The Go proverb is "don't communicate by sharing memory; share memory by communicating," but mutexes are perfectly idiomatic and often simpler/faster for plain shared-state protection. Reach for whichever models the problem most clearly.🟡 What is the difference between `sync.Mutex` and `sync.RWMutex`?
A `Mutex` allows one holder at a time for any access. An `RWMutex` distinguishes readers from writers: many readers can hold `RLock` concurrently, but a writer's `Lock` is exclusive and blocks all readers and writers. Use `RWMutex` for read-heavy workloads where reads vastly outnumber writes. It has more overhead than a plain `Mutex`, so for low-contention or write-heavy data a plain `Mutex` is often faster; measure rather than assume.🔴 Why must you not copy a sync.Mutex (or a struct containing one)?
A `Mutex` contains internal state (lock bits, waiter counts); copying it duplicates that state, so the copy and original protect different "locks" and the mutual exclusion guarantee is broken — you can get two goroutines in the "critical section" simultaneously. This is why you pass such structs by pointer and why `go vet` flags lock copies. The same applies to `sync.WaitGroup`, `sync.Once`, `sync.Cond`, and anything embedding them.🟡 What does `sync.Once` guarantee?
`sync.Once` ensures a function runs exactly once, even under concurrent calls, and that all callers observe the completed side effects before `Do` returns (it establishes a happens-before relationship). It's the idiomatic way to do lazy, thread-safe initialization of a singleton, config, or connection. If the function panics, `Once` still considers it "done" and won't retry, so handle errors carefully (Go 1.21 added `OnceFunc`/`OnceValue` helpers).🔴 When should you use `sync/atomic` instead of a mutex?
Use atomics for simple single-word operations — counters, flags, swapping a pointer — where a full mutex would be overkill and you want lock-free performance under contention. `atomic.AddInt64`, `atomic.CompareAndSwap`, and the typed `atomic.Int64`/`atomic.Pointer[T]` wrappers (Go 1.19+) provide this. The catch: atomics only make individual operations atomic, not sequences, so anything requiring an invariant across multiple variables still needs a mutex. Atomics are easy to misuse and create subtle memory-ordering bugs; prefer a mutex unless profiling shows it matters.🟡 What is `sync.WaitGroup` and what is the common misuse?
`WaitGroup` counts outstanding work: `Add` increments, `Done` decrements, `Wait` blocks until zero. The cardinal rule is to call `Add` **before** launching the goroutine, never inside it — calling `Add` inside the goroutine races with `Wait` and may let `Wait` return before the goroutine even started. Also never copy a `WaitGroup`, pass it by pointer, and make sure every path calls `Done` (use `defer wg.Done()`), or `Wait` blocks forever.context¶
🟢 What is `context.Context` used for?
`context` carries deadlines, cancellation signals, and request-scoped values across API boundaries and goroutines. Its primary job is propagating cancellation: when a request is canceled or times out, every goroutine and downstream call working on it can observe `ctx.Done()` and stop, freeing resources. By convention it is the first parameter (`ctx context.Context`) and is never stored in a struct. It is the standard mechanism that ties together timeouts, deadlines, and graceful shutdown.🟡 What are the main context constructors and how do you use them?
`context.Background()` is the empty root (for `main`, init, tests); `context.TODO()` is a placeholder when you haven't wired context through yet. You derive children with `WithCancel`, `WithTimeout`, `WithDeadline`, and `WithValue`. The cancel-returning ones must have their `cancel` called to release resources, even if the work finished, hence the ubiquitous `defer cancel()`.🔴 What happens if you don't call the cancel function returned by WithCancel/WithTimeout?
You leak resources: the child context (and its timer, for `WithTimeout`) stays registered with its parent until the parent is canceled, so the cancellation tree grows and timers aren't released — `go vet` warns about this. Calling `cancel()` is cheap and idempotent; `defer cancel()` immediately after creating the context is the correct habit even when the operation completes normally. Forgetting it is one of the most common context bugs and shows up as slow memory growth in long-lived servers.🔴 How should context values be used, and what's the anti-pattern?
`context.WithValue` should carry only request-scoped data that crosses API boundaries — request IDs, auth/trace info — keyed by an unexported custom type to avoid collisions. The anti-pattern is using it as a general-purpose bag to pass optional function parameters or dependencies; that hides the data flow, defeats static typing, and makes code hard to follow. Required inputs belong in explicit function parameters; context values are for ambient, cross-cutting metadata only.🟡 How does a goroutine actually respond to context cancellation?
It must explicitly check, because cancellation is cooperative — nothing forcibly stops a goroutine. Long-running loops poll `ctx.Err()` or select on `ctx.Done()`, and blocking calls accept the context so they unblock on cancel: Standard-library I/O (`net`, `database/sql`, `http`) accepts contexts and aborts when canceled. Code that ignores its context can't be canceled and will leak.Patterns: Worker Pools & Pipelines¶
🟡 How do you implement a bounded worker pool in Go?
Launch a fixed number of worker goroutines that all read from a shared jobs channel and write to a results channel; close `jobs` when all work is queued, and use a `WaitGroup` to know when to close `results`.jobs := make(chan Job)
results := make(chan Result)
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := range jobs { results <- process(j) }
}()
}
go func() { wg.Wait(); close(results) }() // close results after workers exit
for _, j := range allJobs { jobs <- j }
close(jobs)
for r := range results { collect(r) }