{}The Interview
Handbook

Tracks / Go

Goroutines, channels & context

mid 10 questions · 9 min read goconcurrencychannelscontext

Questions in this set 10
  1. 01What is a goroutine, and how is it different from a thread?
  2. 02Channels: buffered or unbuffered, and what causes a deadlock?
  3. 03What is a goroutine leak? Show me one.
  4. 04Explain context. What are the rules?
  5. 05Why does this comparison say the error is non-nil?
  6. 06How do you handle errors idiomatically?
  7. 07Explain slices. Why does this function mutate the caller's data?
  8. 08When do you use a mutex instead of a channel?
  9. 09What are interfaces good for in Go, and where do you define them?
  10. 10What should you know about Go's garbage collector and performance?

Go is the default for microservices at a lot of companies, and its interviews are consistent: concurrency, context, error handling, and a handful of semantics (nil interfaces, slice aliasing, loop variables) that catch people who have written Go without reading the spec.

01

What is a goroutine, and how is it different from a thread?

A goroutine is a function scheduled by the Go runtime rather than the OS. It starts with a small growable stack (2 KB, versus ~1-8 MB for an OS thread), so hundreds of thousands are practical.

The runtime multiplexes goroutines onto OS threads with the G-M-P scheduler: G is a goroutine, M an OS thread, P a logical processor (GOMAXPROCS, defaulting to the number of cores). Each P has a local run queue and can steal work from others. The scheduler is cooperative but preemptive since 1.14 — a tight loop with no function calls can now be interrupted, where previously it could hang the scheduler.

The practical consequence to state: goroutines are cheap enough that you spawn one per request or per connection without thinking, but they are not free — each holds its stack and anything it references, so a leaked goroutine is a memory leak with a heartbeat.

Follow-up: "What happens when a goroutine blocks on I/O?" The runtime detaches the M from the P and lets another goroutine run — this is why Go gets async-style throughput with synchronous-looking code, and why you never need async/await colouring.

02

Channels: buffered or unbuffered, and what causes a deadlock?

An unbuffered channel is a synchronisation point: the send blocks until a receiver is ready, so both sides rendezvous. A buffered channel accepts up to its capacity without a receiver.

go
ch := make(chan int)        // unbuffered: send blocks until received
ch := make(chan int, 100)   // buffered: blocks only when full

The rules that produce deadlocks and panics, which is what actually gets asked:

  • Send on a full channel blocks; receive on an empty one blocks. If nothing else can run, the runtime detects it: fatal error: all goroutines are asleep - deadlock!
  • Send on a closed channel panics. Receive from a closed channel returns the zero value immediately — use v, ok := <-ch to distinguish.
  • Close from the sender, never the receiver, and only once. Closing is a broadcast that says "no more values", not a cleanup operation.
  • A nil channel blocks forever on both send and receive. That is occasionally useful — setting a channel to nil in a select disables that case.
go
// the standard fan-in with a wait group
var wg sync.WaitGroup
out := make(chan Result)
for _, job := range jobs {
    wg.Add(1)                                  // Add BEFORE the goroutine starts
    go func(j Job) { defer wg.Done(); out <- process(j) }(j)
}
go func() { wg.Wait(); close(out) }()          // closer runs once all senders are done
for r := range out { … }                       // ranges until closed
03

What is a goroutine leak? Show me one.

A goroutine blocked forever on a channel operation nobody will complete. It never gets collected, and neither does anything it references.

go
// LEAK: if the caller stops reading (an error, an early return, a timeout),
// this goroutine blocks on the send forever.
func Search(q string) <-chan Result {
    ch := make(chan Result)
    go func() {
        ch <- doSearch(q)      // blocks here if nobody receives
    }()
    return ch
}

// FIX 1: buffer of 1 — the send always completes, the value is dropped if unread.
ch := make(chan Result, 1)

// FIX 2: honour cancellation.
go func() {
    select {
    case ch <- doSearch(q):
    case <-ctx.Done():
    }
}()

Detection: runtime.NumGoroutine() as a metric — a count that only rises is conclusive — and /debug/pprof/goroutine?debug=2, which dumps every goroutine's stack so you can see thousands parked on the same line. go test -race and goleak in tests catch many of them before they ship.

04

Explain context. What are the rules?

context.Context carries cancellation, deadlines and request-scoped values across API boundaries. It is how a Go service stops doing work nobody is waiting for.

go
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
    defer cancel()                              // ALWAYS — otherwise the timer leaks

    rows, err := s.db.QueryContext(ctx, q)      // cancellation propagates to the driver
    if err != nil { … }
}

The conventions, all of which get asked:

  • First parameter, always named ctx, never stored in a struct.
  • Always call cancel, usually with defer, even when the operation completed — it releases the timer and the parent's reference.
  • Cancellation flows down the tree: cancelling a parent cancels every child.
  • ctx.Err() returns context.Canceled or context.DeadlineExceeded, which is how you distinguish "the client went away" from "we were too slow" in your metrics.
  • context.Value is for request-scoped data only — a request id, a trace span, an authenticated user. Not for optional parameters or dependencies. Use an unexported key type to avoid collisions.
  • Pass context.TODO() when you have not plumbed it yet, context.Background() at the top of main or a worker.

The deeper point: a context is a cooperative cancellation signal. It does not kill anything — the code being cancelled must check ctx.Done() or pass the context to something that does. A tight CPU loop ignoring the context runs to completion regardless.

05

Why does this comparison say the error is non-nil?

go
type MyErr struct{}
func (e *MyErr) Error() string { return "boom" }

func mayFail() error {
    var p *MyErr = nil
    return p                 // returns a NON-NIL error
}

if mayFail() != nil { fmt.Println("this prints") }

An interface value is a (type, value) pair, and it is nil only when both halves are nil. Returning a nil *MyErr as an error gives you the pair (*MyErr, nil) — the type is set, so the interface is not nil.

The fix is to never declare a typed nil error variable and return it; return the literal nil:

go
func mayFail() error {
    if ok { return nil }      // untyped nil — the interface is genuinely nil
    return &MyErr{}
}

This is the single most-asked Go trick question, it appears in real codebases, and go vet catches only some cases.

06

How do you handle errors idiomatically?

Explicitly, as values, at every call site. Then the parts that distinguish a mid from a senior answer:

go
// wrap with %w to preserve the chain
if err := db.Save(ctx, o); err != nil {
    return fmt.Errorf("saving order %s: %w", o.ID, err)
}

// inspect by identity or by type, never by string matching
if errors.Is(err, sql.ErrNoRows) { … }

var ve *ValidationError
if errors.As(err, &ve) { return badRequest(ve.Field) }
  • %w wraps (preserving the chain for Is/As); %v formats and loses it.
  • Error messages are lowercase and without punctuation, because they get wrapped into sentences.
  • Add context at each layer, but do not log and return — that duplicates every error N times up the stack. Handle it once, at the boundary that can actually decide.
  • panic is for programmer errors and truly unrecoverable states, not control flow. recover belongs in a middleware at the top of a request handler so one bad request does not take down the process.
  • Sentinel errors (var ErrNotFound = errors.New("not found")) are part of your public API — changing them breaks callers.
07

Explain slices. Why does this function mutate the caller's data?

A slice is a header — pointer to a backing array, length, capacity — passed by value. Copying the header copies the pointer, so both slices share the array.

go
func modify(s []int) { s[0] = 99 }        // visible to the caller: shared backing array
func extend(s []int) { s = append(s, 1) } // NOT visible: append may reallocate, and the
                                          // caller's header is unchanged either way

a := []int{1, 2, 3, 4, 5}
b := a[1:3]                 // len 2, cap 4 — b shares a's array
b = append(b, 99)           // within capacity, so it OVERWRITES a[3]
fmt.Println(a)              // [1 2 3 99 5]  ← the classic surprise

c := a[1:3:3]               // three-index slice caps it: append now copies

Related traps worth naming: a small slice of a huge array keeps the whole array alive (copy it explicitly if you are retaining a fragment of a large read); append in a loop without make([]T, 0, n) reallocates repeatedly; and passing a slice to a goroutine shares the array, so it needs the same synchronisation as any shared memory.

08

When do you use a mutex instead of a channel?

Go's proverb is "share memory by communicating", but the honest answer is that a mutex is often simpler and faster.

Use a channel when you are transferring ownership of data, distributing work, or coordinating goroutine lifecycles — a pipeline, a worker pool, a signal to shut down.

Use a mutex when you are protecting shared state that goroutines read and write in place — a cache, a counter, a connection pool, a map. Trying to express a concurrent map as a channel-guarded goroutine produces a slower, more complicated version of sync.RWMutex.

go
type Cache struct {
    mu sync.RWMutex                      // RWMutex: many readers, one writer
    m  map[string]Value
}
func (c *Cache) Get(k string) (Value, bool) {
    c.mu.RLock(); defer c.mu.RUnlock()
    v, ok := c.m[k]; return v, ok
}

Also know: sync.Once for one-time initialisation, sync/atomic (and atomic.Value/atomic.Pointer) for lock-free counters and flags, and sync.Map — which is not a general-purpose faster map; it is optimised for the specific cases of write-once-read-many or disjoint key sets per goroutine, and is slower than a mutex-guarded map otherwise.

Always run go test -race in CI. The race detector finds real bugs that reviewing cannot, and Go's memory model is explicit that a data race is undefined behaviour, not merely a stale read.

09

What are interfaces good for in Go, and where do you define them?

Interfaces are satisfied implicitly — a type implements one by having the methods, with no declaration. That makes decoupling cheap and is why Go avoids the dependency-injection ceremony of other languages.

The convention that matters: define the interface where it is consumed, not where it is implemented. The package that needs "something I can read from" declares its own one-method interface; the package providing the concrete type knows nothing about it. That keeps interfaces small (the standard library's io.Reader and io.Writer are one method each) and avoids the anti-pattern of a services package exporting a giant interface mirroring one struct.

go
// consumer-side, minimal
type OrderStore interface {
    Get(ctx context.Context, id string) (*Order, error)
}
func NewHandler(s OrderStore) *Handler { … }     // any type with that method works, incl. a fake

Accept interfaces, return concrete types. And keep them small — "the bigger the interface, the weaker the abstraction".

10

What should you know about Go's garbage collector and performance?

A concurrent, tri-colour mark-and-sweep collector tuned for low pause times (sub-millisecond, running alongside your program) rather than maximum throughput. GOGC controls the heap growth target (default 100 = collect when the heap doubles); GOMEMLIMIT (1.19+) sets a soft memory ceiling, and is the correct way to keep a containerised Go service from being OOM-killed — set it slightly below the container limit.

Optimisation approach, in order: profile first with pprof (/debug/pprof/, go tool pprof, and go test -bench . -benchmem for micro-benchmarks); then reduce allocations, which is usually the dominant cost — preallocate slices and maps with a capacity, reuse buffers with sync.Pool for high-churn objects, avoid unnecessary string↔[]byte conversions, and use strings.Builder instead of += in a loop. Understand escape analysis (go build -gcflags=-m) so you know when a value moves to the heap: taking the address of a local and returning it, or passing it to an interface, typically does it.