Goroutines and Channels in Go

AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.

Go 1.24

go starts a goroutine and channels pass values between them. The syntax is tiny; the hard parts are who closes and when a send or receive blocks.

Goroutines and WaitGroup

var wg sync.WaitGroup

for i := 1; i <= 3; i++ {
    wg.Add(1)
    go func(n int) {
        defer wg.Done()
        fmt.Printf("task %d started\n", n)
    }(i)
}

wg.Wait()

Three details:

  • wg.Add(1) must run before go; otherwise Wait can return before the counter is incremented.
  • Pass values as parameters. Loop variables are per iteration since Go 1.22, but explicit parameters still document the intent.
  • When main returns, every other goroutine is killed without warning — that is why you need Wait.

Channel basics

ch := make(chan int)        // unbuffered
buf := make(chan int, 3)    // buffered, capacity 3

ch <- 1        // send
v := <-ch      // receive
v, ok := <-ch  // ok == false means closed and drained
close(ch)      // close: sender only
  • Unbuffered: a send completes only when a receiver is ready. This is synchronisation, not just transport.
  • Buffered: sends block when full, receives block when empty.
  • Closed: sending panics; receiving yields the zero value immediately.
  • range ch ends when the channel is closed and drained — without close, it waits forever.
go func() {
    defer close(ch)
    for _, v := range []int{1, 2, 3} {
        ch <- v
    }
}()

for v := range ch {
    fmt.Println(v)
}

Closing is the sender’s job. A receiver must never close. With several senders, coordinate with a sync.WaitGroup and close after the last one finishes.

select

select waits on whichever channel operation becomes ready first:

select {
case v := <-data:
    fmt.Println("got", v)
case err := <-errc:
    fmt.Println("error", err)
case <-time.After(2 * time.Second):
    fmt.Println("timeout")
case <-ctx.Done():
    return ctx.Err()
}

default makes it non-blocking:

select {
case ch <- v:
    // sent
default:
    // buffer full: drop or retry later
}

time.After allocates a timer on every call, so do not use it in a loop; time.NewTimer plus Reset is the long-running pattern.

Patterns worth memorising

Worker pool

func process(ctx context.Context, jobs []Job, workers int) ([]Result, error) {
    jobCh := make(chan Job)
    resCh := make(chan Result)

    var wg sync.WaitGroup
    for i := 0; i < workers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for job := range jobCh {
                resCh <- run(ctx, job)
            }
        }()
    }

    go func() {
        defer close(jobCh)
        for _, job := range jobs {
            select {
            case jobCh <- job:
            case <-ctx.Done():
                return
            }
        }
    }()

    go func() {
        wg.Wait()
        close(resCh)
    }()

    var results []Result
    for res := range resCh {
        results = append(results, res)
    }
    return results, ctx.Err()
}

The producer closes jobCh; wg.Wait() closes resCh once every worker has stopped. That is what lets the consumer’s range finish.

Fan-out / fan-in

Several goroutines read from one channel (fan-out) and results are merged into another (fan-in). The closing rules are the same: the writer closes.

Timeouts

select {
case res := <-doWork():
    return res
case <-time.After(500 * time.Millisecond):
    return ErrTimeout
}

The goroutine behind doWork keeps running. If it writes to a result channel, that channel needs a buffer or a cancellation path, or it leaks.

Mutex or channel

  • Shared state (counters, caches, config): sync.Mutex is more direct.
  • Ownership transfer, pipelines, event notification: channels fit better.
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
}

For read-heavy data, sync.RWMutex allows concurrent RLock holders while Lock stays exclusive. Never hold a lock across I/O or a callback into unknown code.

sync.Once covers one-time initialisation:

var once sync.Once
once.Do(func() { cfg = loadConfig() })

Data races

go test -race ./...
go run -race main.go

The race detector only reports races that actually happen, so the tests have to exercise concurrent paths. Symptoms in production: results that are occasionally wrong, and a hard fatal on concurrent map writes (concurrent map writes).

// unsafe: two goroutines writing the same map
go func() { m["a"] = 1 }()
go func() { m["b"] = 2 }()

// safe: a mutex, sync.Map, or one goroutine owning the map

Common mistakes

1. main exiting early

go doWork()
// main returns, doWork is killed

Wait with a WaitGroup, a channel, or select {} when the program is meant to run forever.

2. Sending on a closed channel

panic: send on closed channel. Have exactly one owner close it, and never send afterwards.

3. Closing twice

Also a panic. Guard the close with sync.Once if several paths can reach it.

4. Deadlocking an unbuffered channel

ch := make(chan int)
ch <- 1        // no receiver: blocks forever

Receive concurrently, or give it a buffer.

5. Goroutine leaks

Nobody reads the channel, a select lacks a ctx.Done() branch, or a loop spawns goroutines that are never reaped. Watch runtime.NumGoroutine() or a goroutine profile for a number that keeps climbing.

6. Capturing the loop variable in a goroutine

for _, v := range items {
    go func() { use(v) }()   // Go 1.22+ is fine; older versions used the last value
}

Explicit parameters remove all doubt: go func(v Item) { use(v) }(v).

7. Sending large values through channels

Channels copy. Send pointers or restructure the data flow when the struct is big.

8. Believing close waits for consumers

close only marks that no more values will be sent; buffered values are still delivered.

Summary

  • go plus WaitGroup is the minimal unit; Add before starting.
  • Unbuffered channels synchronise, buffered ones decouple; the sender closes, the receiver uses range or ok.
  • select handles multiple sources and timeouts; avoid time.After in loops.
  • Worker pools are three pieces: produce and close the input, run the workers, close the output after Wait.
  • Mutexes for shared state, channels for data flow, and -race in CI.

Next: generics, for when one implementation should serve several types.