Dev Skills

Interview: concurrency

The half of the interview that usually decides it, and the code they will put in front of you.

Topic 95 of 98 · 20 min to read · 50 min to build
After this topic you can
  • 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 GOMAXPROCS processor 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

Operationnil channelopen, unbufferedopen, bufferedclosed
sendblocks foreverblocks until a receiver takes itproceeds if the buffer has room, else blockspanics
receiveblocks foreverblocks until a sender arrivesproceeds if the buffer has a value, else blocksbuffered values first, then zero value, ok=false
closepanicssucceeds; waiting receivers wake up, waiting senders panicsucceeds; buffered values can still be receivedpanics
  • 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 nil inside a select disables that case. That is the standard way to drop a branch out of a loop.

"Spot the bug" — the goroutine leak

they will show you this
func leak() {
ch := make(chan int)
go func() { ch <- expensive() }()
return
}
The goroutine blocks on the send forever, so it is never collected, and neither is anything it references. Two fixes: make the channel buffered with size 1, or select on ctx.Done() alongside the send.

"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 vet catches 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.Wait after Done, Once.Do, the go statement, 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 with pprof’s goroutine profile.
  • The special case: the runtime usually detects concurrent map access and crashes with fatal error: concurrent map writes (or concurrent map read and map write). The check is best-effort, so it does not replace -race.

"Write a worker pool"

the shape they want to see
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
}
Four things they are marking: Add before go, the sender closes, ctx.Done in every select, and bounded concurrency rather than one goroutine per job.
Note: Often the better answer is "I would use 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

pool.go
package main
import (
"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)
}
Results come back in completion order, so they carry the job ID. If order matters, write into `out[j.id]` of a preallocated slice instead of a channel.

"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.

limiter.go
package main
import (
"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))
}
Marked on: the buffered channel *is* the bucket (capacity = burst), the refill never blocks (`select` with `default`), `Stop` stops the ticker, and `Wait` honours `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.

cache.go
package main
import (
"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())
}
Marked on: `RLock` for reads, `Lock` for writes, no lock held while calling out, and the expired-on-read check. The 100 goroutines prove it under `go run -race`.

"How do you cancel work?"

  • context.Context: first parameter, named ctx, never stored in a struct, and defer 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 from context.Background() inside a handler is a real bug.
  • context.Value is 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 was v := 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.
The short version
  • 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; -race in CI is the answer to "how do you find one".
  • The worker pool is marked on: Add before go, sender closes, ctx.Done() in every select, bounded concurrency.
Remember: Anyone can start a goroutine. The interview is about stopping one: how it exits, who waits for it, and what happens when it panics.
Build this before moving on
Write the worker pool from a blank file, with no reference, in under ten minutes. Then run it with -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.
Then try these in your own editor
  1. 1Write three different goroutine leaks and fix each one a different way.
  2. 2Implement a rate limiter with a time.Ticker and a buffered channel. Bonus (outside the stdlib): compare it with `golang.org/x/time/rate`, the Go team's ready-made token-bucket limiter.
  3. 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.
  4. 4Explain the G-M-P model out loud in 60 seconds, including work stealing.
Check yourselfTake the Interview preparation test