Interview: concurrency
The half of the interview that usually decides it, and the code they will put in front of you.
- Explain how goroutines differ from OS threads and what each channel operation does.
- Spot and fix a goroutine leak and a data race in interview code.
- Write a worker pool, a rate limiter and a concurrency-safe cache from memory.
Concurrency is where Go interviews get hard. Most candidates can define a goroutine. Far fewer can spot a leak, explain why a race is undefined behaviour, or say what happens when nobody receives.
"What is a goroutine? How is it different from a thread?"
- A user-space thread scheduled by the Go runtime, starting at ~2KB of stack that grows on demand. An OS thread costs about a megabyte and a system call to create.
- Go multiplexes many goroutines onto few OS threads (the G-M-P model: goroutines, machine threads, and
GOMAXPROCSprocessor contexts, with work stealing between them). - The consequence worth stating: when a goroutine blocks on I/O the runtime parks it and runs another on that thread. You write plain blocking code and get event-loop throughput without ever touching a callback or
async/await.
The channel table — know it cold
| Operation | nil channel | open, unbuffered | open, buffered | closed |
|---|---|---|---|---|
| send | blocks forever | blocks until a receiver takes it | proceeds if the buffer has room, else blocks | panics |
| receive | blocks forever | blocks until a sender arrives | proceeds if the buffer has a value, else blocks | buffered values first, then zero value, ok=false |
| close | panics | succeeds; waiting receivers wake up, waiting senders panic | succeeds; buffered values can still be received | panics |
- The sender closes, never the receiver, and only one goroutine may close.
- You do not have to close a channel. The GC collects unreferenced ones. Close only when receivers need to *know* it ended.
- Why a nil channel blocking is useful: setting a channel variable to
nilinside aselectdisables that case. That is the standard way to drop a branch out of a loop.
"Spot the bug" — the goroutine leak
func leak() { ch := make(chan int) go func() { ch <- expensive() }() return}"When would you use a mutex instead of a channel?"
- Channels transfer ownership of a value. A mutex protects state. A mutex around a cache is simpler and faster than a goroutine owning a map behind a channel.
- Say this plainly: "share memory by communicating" is a good default, not a law. Forcing channels onto "protect this field" is a common way to make Go code worse.
- The mutex rules they may probe: never copy a struct containing one (
go vetcatches it), never hold a lock across a network call, and it is not reentrant.
"What is a data race, and how do you find one?"
- Two goroutines touch the same memory, at least one writes, and there is no synchronisation between them. The behaviour is undefined, which is worse than "occasionally wrong": the compiler and CPU both reorder freely.
- Happens-before edges come from: a channel send/receive pair, close→receive,
Unlock→Lock,WaitGroup.WaitafterDone,Once.Do, thegostatement, and atomics. go test -race ./...in CI. It only reports races that actually occur, so the concurrent paths need test coverage. Add that they can find leaks withpprof’s goroutine profile.- The special case: the runtime usually detects concurrent map access and crashes with
fatal error: concurrent map writes(orconcurrent map read and map write). The check is best-effort, so it does not replace-race.
"Write a worker pool"
func pool(ctx context.Context, jobs []Job, workers int) []Result { jobCh := make(chan Job) resCh := make(chan Result, len(jobs)) var wg sync.WaitGroup for i := 0; i < workers; i++ { wg.Add(1) // BEFORE the go statement go func() { defer wg.Done() for j := range jobCh { // exits when jobCh closes select { case resCh <- process(j): case <-ctx.Done(): return // every loop needs this } } }() } go func() { defer close(jobCh) // the sender closes for _, j := range jobs { select { case jobCh <- j: case <-ctx.Done(): return } } }() wg.Wait() close(resCh) out := make([]Result, 0, len(jobs)) for r := range resCh { out = append(out, r) } return out}errgroup with SetLimit" (`golang.org/x/sync/errgroup`, outside the stdlib: a WaitGroup that also returns the first error): bounded concurrency and first-error cancellation in a few lines. Knowing when *not* to hand-roll the pool is a senior signal. But be able to write the pool if they ask.Worked solution: the worker pool, runnable
package mainimport ( "context" "fmt" "sync" "time")type Job struct{ id, n int }type Result struct{ id, square int }func process(j Job) Result { time.Sleep(10 * time.Millisecond) // pretend work return Result{j.id, j.n * j.n}}func pool(ctx context.Context, jobs []Job, workers int) []Result { jobCh := make(chan Job) resCh := make(chan Result, len(jobs)) // buffered: workers never block on send var wg sync.WaitGroup for i := 0; i < workers; i++ { wg.Add(1) // BEFORE the go statement go func() { defer wg.Done() for j := range jobCh { // exits when jobCh closes select { case resCh <- process(j): case <-ctx.Done(): return } } }() } go func() { defer close(jobCh) // the sender closes for _, j := range jobs { select { case jobCh <- j: case <-ctx.Done(): return } } }() wg.Wait() close(resCh) out := make([]Result, 0, len(jobs)) for r := range resCh { out = append(out, r) } return out}func main() { jobs := make([]Job, 20) for i := range jobs { jobs[i] = Job{i, i} } start := time.Now() res := pool(context.Background(), jobs, 4) sum := 0 for _, r := range res { sum += r.square } // 20 jobs x 10ms over 4 workers: about 50ms, not 200ms. fmt.Println(len(res), "results, sum of squares =", sum) fmt.Println("took under 150ms:", time.Since(start) < 150*time.Millisecond) // Cancel early: the pool returns promptly with fewer results, no leak. ctx, cancel := context.WithTimeout(context.Background(), 15*time.Millisecond) defer cancel() fmt.Println("after cancel, got fewer than 20:", len(pool(ctx, jobs, 2)) < 20)}"Write a rate limiter"
Say the algorithm first: a token bucket. Tokens refill at a fixed rate up to a burst size; each request takes one or waits. Then say that in production you would use `golang.org/x/time/rate` (outside the stdlib, maintained by the Go team), which is exactly this. Then write it with the stdlib, because that is what they asked.
package mainimport ( "context" "fmt" "time")// Limiter is a token bucket: burst tokens at most, one refilled every interval.type Limiter struct { tokens chan struct{} ticker *time.Ticker done chan struct{}}func NewLimiter(every time.Duration, burst int) *Limiter { l := &Limiter{ tokens: make(chan struct{}, burst), ticker: time.NewTicker(every), done: make(chan struct{}), } for i := 0; i < burst; i++ { l.tokens <- struct{}{} // start full } go func() { for { select { case <-l.ticker.C: select { case l.tokens <- struct{}{}: // refill one default: // bucket full: drop the token, never block } case <-l.done: return } } }() return l}// Wait blocks until a token is free or ctx is done.func (l *Limiter) Wait(ctx context.Context) error { select { case <-l.tokens: return nil case <-ctx.Done(): return ctx.Err() }}// Allow takes a token if one is free, without waiting. For "429 Too Many Requests".func (l *Limiter) Allow() bool { select { case <-l.tokens: return true default: return false }}func (l *Limiter) Stop() { l.ticker.Stop() close(l.done)}func main() { l := NewLimiter(20*time.Millisecond, 3) defer l.Stop() // The burst goes through at once, then the bucket is empty. for i := 1; i <= 5; i++ { fmt.Printf("request %d allowed: %v\n", i, l.Allow()) } // Wait paces the rest at one per interval. start := time.Now() for i := 0; i < 3; i++ { if err := l.Wait(context.Background()); err != nil { fmt.Println(err) } } fmt.Println("3 more took at least 40ms:", time.Since(start) >= 40*time.Millisecond) // And a deadline is respected instead of waiting forever. ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond) defer cancel() fmt.Println("with 1ms deadline:", l.Wait(ctx))}"Write a concurrency-safe cache"
Clarify first: read-heavy or write-heavy? Do entries expire? Then the shape: a map behind a sync.RWMutex, an expiry stored with each value, and lazy deletion on read plus an optional sweeper. Mention sync.Map only to say why you did not use it: it is tuned for keys written once and read many times, or for disjoint key sets per goroutine.
package mainimport ( "fmt" "sync" "time")type item[V any] struct { val V expires time.Time}// Cache is safe for concurrent use. The zero value is not: use NewCache.type Cache[K comparable, V any] struct { mu sync.RWMutex items map[K]item[V] ttl time.Duration}func NewCache[K comparable, V any](ttl time.Duration) *Cache[K, V] { return &Cache[K, V]{items: make(map[K]item[V]), ttl: ttl}}func (c *Cache[K, V]) Get(key K) (V, bool) { c.mu.RLock() it, ok := c.items[key] c.mu.RUnlock() if !ok || time.Now().After(it.expires) { var zero V return zero, false // expired entries are dropped by Sweep } return it.val, true}func (c *Cache[K, V]) Set(key K, val V) { c.mu.Lock() defer c.mu.Unlock() c.items[key] = item[V]{val, time.Now().Add(c.ttl)}}// Sweep deletes expired entries. Call it from a ticker goroutine in a service.func (c *Cache[K, V]) Sweep() int { c.mu.Lock() defer c.mu.Unlock() n := 0 now := time.Now() for k, it := range c.items { if now.After(it.expires) { delete(c.items, k) // deleting during range is allowed in Go n++ } } return n}func main() { c := NewCache[string, int](30 * time.Millisecond) var wg sync.WaitGroup for i := 0; i < 100; i++ { wg.Add(1) go func() { defer wg.Done() key := fmt.Sprint("k", i%10) c.Set(key, i) c.Get(key) }() } wg.Wait() _, ok := c.Get("k3") fmt.Println("k3 present:", ok) time.Sleep(40 * time.Millisecond) _, ok = c.Get("k3") fmt.Println("k3 after ttl:", ok) fmt.Println("swept:", c.Sweep())}"How do you cancel work?"
context.Context: first parameter, namedctx, never stored in a struct, anddefer cancel()every time.- Cancelling does not stop anything by itself. It closes
ctx.Done(), and your code must select on it. Anything that blocks should. - In a handler, derive from
r.Context()so a disconnecting client stops your database query too. Deriving fromcontext.Background()inside a handler is a real bug. context.Valueis for request-scoped identity crossing API boundaries, not for dependencies or config.
The loop variable question
- Before Go 1.22,
for _, v := range xs { go func(){ use(v) }() }captured one shared variable, so every goroutine usually saw the last element. The fix wasv := v. - Go 1.22 made loop variables per-iteration, so it is now correct as written — but only if the module’s `go.mod` declares 1.22 or later. Mentioning that caveat shows you know the change, not just the headline.
- Know the channel behaviour table cold, including why a nil channel blocking is useful.
- Be able to spot a goroutine leak and give two fixes in one sentence.
- A data race is undefined behaviour;
-racein CI is the answer to "how do you find one". - The worker pool is marked on:
Addbeforego, sender closes,ctx.Done()in every select, bounded concurrency.
-race. Bonus (outside the stdlib): add `goleak`, a test helper from Uber that fails a test if any goroutine is still running at the end, to prove nothing leaked.- 1Write three different goroutine leaks and fix each one a different way.
- 2Implement a rate limiter with a
time.Tickerand a buffered channel. Bonus (outside the stdlib): compare it with `golang.org/x/time/rate`, the Go team's ready-made token-bucket limiter. - 3Bonus (outside the stdlib): take the worker pool and rewrite it with `errgroup`
SetLimit. errgroup is a WaitGroup that also collects the first error and cancels the rest;SetLimit(n)caps how many run at once. Time how much shorter it is. - 4Explain the G-M-P model out loud in 60 seconds, including work stealing.