Go Interview Prep #2: Goroutines

Goroutines are Go’s unit of concurrency. They are the first thing interviewers ask about when the topic turns to concurrency.

What is a goroutine?

A goroutine is a lightweight function that runs concurrently with other goroutines. It is not an OS thread — the Go runtime multiplexes many goroutines onto a smaller pool of OS threads (M:N scheduling).

go func() {
    fmt.Println("running concurrently")
}()

The go keyword is all you need to launch one. The function returns immediately; there is no join built into the language.

Why goroutines are cheap

  • Initial stack size is about 2 KB (versus 1–8 MB for an OS thread). Since Go 1.19 the runtime may start larger when it predicts a deep stack.
  • The stack grows and shrinks dynamically as needed.
  • Context switching between goroutines happens in user space — no kernel call for a normal yield.

A typical Go program can run tens or hundreds of thousands of goroutines without issue.

Scheduling and preemption

Historically, goroutines only yielded at specific points (function calls, channel ops, syscalls) — “cooperative” scheduling. Since Go 1.14, the runtime uses asynchronous preemption: a long-running goroutine can be interrupted even in a tight loop without function calls. That means pure CPU loops no longer starve the scheduler the way they could in older Go versions.

You still should not assume fairness for latency-sensitive work; structure concurrency with channels, contexts, and explicit yields when needed.

The scheduler: GMP model

The Go scheduler uses three entities:

  • G — a goroutine
  • M — an OS thread (machine)
  • P — a logical processor (an execution context with its own local run queue)

GOMAXPROCS controls the number of Ps (defaults to runtime.NumCPU()). Each P runs one goroutine at a time on its M. When a goroutine blocks on I/O or a syscall, the runtime can detach the P from that M and attach it to another M so runnable goroutines keep making progress. Extra Ms may exist for blocked syscalls — so the number of OS threads can exceed GOMAXPROCS.

Work stealing: when a P’s local queue is empty, it steals half the work from another P’s queue (or from the global queue).

Goroutine leaks

A goroutine that is never scheduled to finish is a leak. Leaked goroutines accumulate over time, hold stack memory and any heap objects they reference, and waste scheduler bookkeeping. The most common cause is a goroutine blocked forever on a channel send/receive with no partner on the other end. See the deeper dive in Goroutine Leaks.

// Leak: nobody ever sends on done
func leak() {
    done := make(chan struct{})
    go func() {
        <-done // blocks forever
    }()
}

Fix: always give every goroutine a path to exit — typically via context.Context cancellation or a closed done channel:

func worker(ctx context.Context, jobs <-chan Job) {
    for {
        select {
        case <-ctx.Done():
            return
        case j, ok := <-jobs:
            if !ok {
                return
            }
            process(j)
        }
    }
}

When the parent cancels ctx or closes jobs, the worker returns and can be reclaimed.

Common interview questions

Q: What is the difference between a goroutine and a thread?
Goroutines are lighter (small growable stacks, user-space scheduling), managed by the Go runtime rather than the OS. You can have hundreds of thousands of goroutines; that many OS threads would exhaust memory and kernel resources.

Q: How do you wait for a goroutine to finish?
Use sync.WaitGroup, receive from a channel, or use errgroup.Group. Sleeping with time.Sleep is never correct for synchronization.

Q: What does GOMAXPROCS do?
It sets the number of logical processors (Ps) — how many goroutines can execute Go code in parallel. By default it equals the number of CPU cores. It does not cap the total number of OS threads (blocked syscalls can create more Ms).

Q: Are goroutines preemptively scheduled?
Yes, since Go 1.14 (async preemption). Before that, scheduling was effectively cooperative at safe points. Interviewers still sometimes expect both the historical and modern answers.

Q: How do you detect goroutine leaks?
Use runtime.NumGoroutine() in tests, or tools like goleak which fails a test if unexpected goroutines remain at the end. In production, export goroutine counts and use pprof (/debug/pprof/goroutine).

Quick reference

ConceptKey point
Launchgo f()
Initial stack~2 KB (adaptive), grows dynamically
SchedulerGMP model, M:N, work stealing
PreemptionAsync since Go 1.14
GOMAXPROCSNumber of Ps (parallel Go code), not total OS threads
LeaksAlways give goroutines a way to exit