Concurrency is what makes a Go interview a Go interview. Goroutines and channels are the language’s signature feature, and the standard library packages around them — sync, context — are the supporting cast. This page bundles all of them into one reference: how to start work in the background, how to talk between goroutines, how to wait for them to finish, and how to cancel them when you change your mind.

import "sync"
import "context"

At a glance

Need to…Use
Run something in the backgroundgo func() { ... }()
Send / receive values between goroutineschannels (ch <- v, <-ch)
Wait for N goroutines to finishsync.WaitGroup
Protect shared datasync.Mutex / sync.RWMutex
Run something exactly oncesync.Once
Cancel a long-running operationcontext.WithCancel
Set a timeoutcontext.WithTimeout
Choose between multiple channel operationsselect

Goroutines

A goroutine is a function running concurrently with the rest of your program. Cheap to start (a few KB of stack), cheap to switch between, and managed by the Go runtime — not the OS.

Starting a goroutine

func main() {
    go sayHello()           // returns immediately
    fmt.Println("main")
    time.Sleep(time.Second) // give the goroutine time to run
}

func sayHello() {
    fmt.Println("hello from goroutine")
}

go doesn’t wait. The function runs in the background; the goroutine that called it keeps going.

Sleep is never the real way to wait — it’s just easy in examples. Real code uses channels, WaitGroup, or context.

Loop variable capture — the most-asked gotcha

Before Go 1.22, this was a famous trap:

// PRE-Go 1.22 — all goroutines see i = 3 because they share i
for i := 0; i < 3; i++ {
    go func() {
        fmt.Println(i)   // prints 3, 3, 3 (probably)
    }()
}

The fix was to pass i as an argument:

for i := 0; i < 3; i++ {
    go func(i int) {
        fmt.Println(i)   // prints 0, 1, 2 (in some order)
    }(i)
}

Go 1.22+ changes the loop semantics — each iteration gets its own i, so the original buggy code now works correctly. Interviewers may still test the pre-1.22 behavior, so know both.

Anonymous goroutines

go func() {
    fmt.Println("inline")
}()

The trailing () calls the function immediately (in the new goroutine). Forget it and nothing runs.

Channels

A channel is a typed pipe between goroutines. One side sends, the other receives.

ch := make(chan int)     // unbuffered channel of ints
ch <- 42                 // send
v := <-ch                // receive
v, ok := <-ch            // receive with "is the channel still open?"

Unbuffered vs buffered

Unbuffered (make(chan T)) — send and receive must happen at the same time. The sender blocks until someone receives. The receiver blocks until someone sends. This is synchronous handoff.

Buffered (make(chan T, N)) — holds up to N values without a receiver. Sender only blocks when full; receiver only blocks when empty.

unbuf := make(chan int)      // unbuffered — strict handoff
buf   := make(chan int, 3)   // can hold 3 values

buf <- 1
buf <- 2
buf <- 3   // still doesn't block
buf <- 4   // BLOCKS until someone receives

Default to unbuffered. Reach for buffered when you have a specific reason — usually decoupling producer from consumer or batching.

Closing a channel

close(ch) signals “no more values will be sent.” Reading from a closed channel returns the zero value immediately.

ch := make(chan int, 3)
ch <- 1
ch <- 2
close(ch)

v, ok := <-ch     // v=1, ok=true
v, ok  = <-ch     // v=2, ok=true
v, ok  = <-ch     // v=0, ok=false  (channel is closed and drained)

for ... range over a channel

Range loops over a channel until it’s closed. This is the most idiomatic way to consume values:

ch := make(chan int)

go func() {
    defer close(ch)
    for i := 1; i <= 5; i++ {
        ch <- i
    }
}()

for v := range ch {
    fmt.Println(v)
}
// prints 1, 2, 3, 4, 5

The receiver doesn’t need to know how many values are coming — close ends the loop.

Who closes the channel?

The sender closes. Never the receiver. Closing a channel you don’t own can crash the program. If multiple senders share a channel, use a coordinator goroutine or a sync.WaitGroup to know when they’re all done.

The nil-channel trick

Sending to or receiving from a nil channel blocks forever. This sounds useless, but it’s a classic select trick — set a channel to nil to disable that branch:

var done chan struct{}   // nil — receiving from it never returns

select {
case <-done:
    fmt.Println("never reached")
case <-time.After(time.Second):
    fmt.Println("timeout")
}

select

select chooses between multiple channel operations. Like switch, but every case is a send or receive.

select {
case v := <-ch1:
    fmt.Println("from ch1:", v)
case v := <-ch2:
    fmt.Println("from ch2:", v)
case ch3 <- 42:
    fmt.Println("sent to ch3")
case <-time.After(time.Second):
    fmt.Println("timeout")
}

Rules:

  • If multiple cases are ready, one is chosen randomly.
  • If no case is ready, select blocks until one is — unless there’s a default.

default — non-blocking

select {
case v := <-ch:
    fmt.Println("got", v)
default:
    fmt.Println("nothing ready")
}

Blocking forever — select {}

select {}   // blocks the goroutine forever

Sometimes used in main to keep the program alive while goroutines run.

Timeout pattern

select {
case v := <-ch:
    handle(v)
case <-time.After(time.Second):
    fmt.Println("took too long")
}

This is the cleanest way to put a timeout on a channel operation.

The sync package

For when channels are overkill, or you need to protect mutable state.

sync.WaitGroup — wait for N goroutines

var wg sync.WaitGroup

for i := 1; i <= 5; i++ {
    wg.Add(1)
    go func(i int) {
        defer wg.Done()
        fmt.Println("worker", i)
    }(i)
}

wg.Wait()
fmt.Println("all done")

The pattern:

  1. wg.Add(1) before starting each goroutine
  2. defer wg.Done() as the first line inside the goroutine
  3. wg.Wait() blocks until the counter hits zero

Always call Add from the parent goroutine, not from inside the spawned one. Otherwise there’s a race between Wait and Add.

sync.Mutex — protect shared data

type Counter struct {
    mu sync.Mutex
    n  int
}

func (c *Counter) Inc() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.n++
}

func (c *Counter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.n
}

Always defer mu.Unlock() right after mu.Lock(). It’s almost impossible to leak a lock that way.

sync.RWMutex — many readers, one writer

When reads vastly outnumber writes:

type Cache struct {
    mu sync.RWMutex
    m  map[string]string
}

func (c *Cache) Get(k string) string {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return c.m[k]
}

func (c *Cache) Set(k, v string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.m[k] = v
}

RLock allows multiple goroutines to hold a read lock at once. Lock is exclusive.

sync.Once — run exactly once

var (
    once     sync.Once
    instance *Singleton
)

func GetInstance() *Singleton {
    once.Do(func() {
        instance = &Singleton{...}
    })
    return instance
}

Threadsafe lazy initialization in three lines.

The context package

context.Context is the standard way to carry cancellation and deadlines across API boundaries — especially through goroutines.

Roots — Background and TODO

ctx := context.Background()   // for main(), tests, top-of-stack
ctx := context.TODO()         // when you don't know what context to use yet

These never cancel. Wrap them to add cancellation.

WithCancel — manual cancellation

ctx, cancel := context.WithCancel(context.Background())
defer cancel()   // release resources even on early return

go worker(ctx)

// later, somewhere:
cancel()   // signal worker to stop

WithTimeout and WithDeadline

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

if err := fetch(ctx, url); err != nil {
    // err == context.DeadlineExceeded if it timed out
}

WithDeadline(parent, t) is the same but takes an absolute time.

Checking a context inside a goroutine

func worker(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("worker canceled:", ctx.Err())
            return
        default:
            // do one unit of work
        }
    }
}

ctx.Done() returns a channel that closes when the context is canceled. Selecting on it makes the goroutine cancellable.

Pass ctx as the first parameter to any function that does network, file, or long-running work. It’s the standard Go convention.

WithValue — pass per-request data (use sparingly)

ctx := context.WithValue(parent, userIDKey, "abc123")

func handle(ctx context.Context) {
    id := ctx.Value(userIDKey).(string)
}

WithValue is for request-scoped data like user ID, trace ID, auth token — things that pass through many layers. Don’t use it as a backdoor for passing arguments. If a function needs a parameter, give it a parameter.

Common patterns

Worker pool

func workerPool(numWorkers int, jobs []Job) []Result {
    in := make(chan Job)
    out := make(chan Result)
    var wg sync.WaitGroup

    // Start workers
    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for job := range in {
                out <- process(job)
            }
        }()
    }

    // Close out when all workers are done
    go func() {
        wg.Wait()
        close(out)
    }()

    // Feed jobs
    go func() {
        defer close(in)
        for _, j := range jobs {
            in <- j
        }
    }()

    // Collect results
    var results []Result
    for r := range out {
        results = append(results, r)
    }
    return results
}

Three goroutine roles: workers (read jobs, write results), feeder (writes jobs, closes input when done), collector (drains results in main). The wg.Wait() + close(out) trick is what makes the collector’s range end.

Fan-out, fan-in

Same shape as the worker pool: one input channel, many workers, one output channel.

Pipeline

A series of stages, each running in its own goroutine, connected by channels:

func gen(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for _, n := range nums {
            out <- n
        }
    }()
    return out
}

func square(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for n := range in {
            out <- n * n
        }
    }()
    return out
}

// Use it:
for v := range square(gen(1, 2, 3, 4, 5)) {
    fmt.Println(v)   // 1, 4, 9, 16, 25
}

Each stage takes a read-only channel and returns a read-only channel. Easy to compose.

Cancellation propagation

When a parent context is canceled, all goroutines watching its Done channel see it and shut down:

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

for i := 0; i < 10; i++ {
    go worker(ctx, i)
}

// Cancel everyone:
cancel()

All workers selecting on <-ctx.Done() exit.

Concurrency gotchas

Goroutine leaks

Every goroutine that’s started must eventually finish. If a worker blocks forever (waiting on a channel that’s never closed, never receives), it stays in memory.

// LEAK — if nobody receives, this goroutine blocks forever
go func() {
    ch <- compute()
}()

Fix by:

  • Making sure someone receives, OR
  • Using a buffered channel that can hold the result, OR
  • Adding a select with <-ctx.Done() so the goroutine can give up

Send on closed channel — panic

ch := make(chan int)
close(ch)
ch <- 1   // panic: send on closed channel

This is why the “sender closes” rule matters. If multiple senders share a channel, none of them should close — let a coordinator do it.

Closing a channel twice — also panic

close(ch)
close(ch)   // panic: close of closed channel

Same rule: only one closer, and only once.

Data race

Two goroutines accessing the same variable, at least one of which is a write — undefined behavior. Always use a mutex or channel to coordinate.

go run -race main.go   # detects races at runtime

The race detector is your friend. Use it in tests.

Reading and writing a map concurrently — panic

Maps aren’t safe for concurrent use. Use sync.Mutex to protect them, or sync.Map if reads vastly outnumber writes.

Quick rules of thumb

  • Don’t communicate by sharing memory; share memory by communicating. (The Go proverb.) Prefer channels for coordination, mutexes for guarding state.
  • Sender closes the channel. Always.
  • Default to unbuffered channels. Add buffering only when you have a reason.
  • defer wg.Done() as the first line in the goroutine.
  • defer cancel() right after WithCancel/WithTimeout.
  • Pass ctx context.Context as the first parameter in any cancellable function.
  • Run tests with -race.

Common mistakes

  • Forgetting () on go func() {...}() — defines the function, never runs it.
  • Reading the loop variable in a goroutine pre-Go 1.22 — captures the shared variable; fix by passing as an arg.
  • Closing a channel from the receiver side — panics on the next send.
  • Closing the same channel twice — panics.
  • Calling wg.Add(1) inside the goroutine — race with wg.Wait().
  • Forgetting cancel() after WithCancel — leaks the context.
  • Putting business logic in context.WithValue — abused as a backdoor for function args. Pass explicit parameters instead.
  • Map writes without a mutex — panics with “concurrent map writes.”
  • Goroutine leaks from unbuffered sends with no receiver — silent until memory grows.
Toggle theme (T)