golang · level 9

Goroutines & Channels

Concurrency the Go way: cheap goroutines, typed pipes, select.

150 XP

Goroutines & Channels

Concurrency is what Go is famous for. The two primitives are tiny: goroutines (cheap concurrent functions) and channels (typed pipes between them).

A goroutine

go f() runs f in a new goroutine — concurrently with the caller. That's it:

func main() {
    go fetch("https://a.com")
    go fetch("https://b.com")
    fetch("https://c.com")
    // main returns immediately if there's nothing waiting...
    // ...so the other two goroutines may not finish in time.
}

A goroutine is much lighter than an OS thread — a few KB of stack, multiplexed onto threads by the runtime. Spawning 10,000 goroutines is normal. Spawning 10,000 threads is not.

The catch: when does main wait?

main returning kills every goroutine. You need a way to say "wait until those finish":

import "sync"

func main() {
    var wg sync.WaitGroup
    for _, url := range urls {
        wg.Add(1)
        go func(u string) {
            defer wg.Done()
            fetch(u)
        }(url)
    }
    wg.Wait()
}

sync.WaitGroup is a counter. Add(n) increments, Done() decrements, Wait() blocks until it hits zero.

Channels

A channel is a typed pipe. One goroutine sends; another receives. Send and receive block until the other side is ready:

ch := make(chan int)

go func() {
    ch <- 42        // send
}()

val := <-ch         // receive — blocks until something arrives
fmt.Println(val)    // 42

make(chan T) creates an unbuffered channel — every send blocks until a corresponding receive. make(chan T, N) creates a buffered channel with capacity N — sends don't block until the buffer is full.

The classic worker pattern

jobs := make(chan int, 100)
results := make(chan int, 100)

// Spawn three workers.
for w := 0; w < 3; w++ {
    go func() {
        for job := range jobs {
            results <- process(job)
        }
    }()
}

// Send jobs.
for _, j := range []int{1, 2, 3, 4, 5} {
    jobs <- j
}
close(jobs)        // signal: no more jobs

// Collect results.
for i := 0; i < 5; i++ {
    fmt.Println(<-results)
}

for x := range ch reads until the channel is closed. Closing tells the receivers "I'm done sending" so they can exit cleanly.

select

select picks among multiple channel operations — whichever is ready first:

select {
case msg := <-incoming:
    handle(msg)
case <-time.After(time.Second):
    fmt.Println("timeout")
case results <- value:
    // sent the value
}

select is the heart of Go's concurrency. It's also how you implement timeouts (time.After), cancellation (a "done" channel), and fan-in (combine multiple inputs).

context for cancellation

A context.Context carries a deadline + cancellation signal across goroutines. Long-running operations should accept one as their first argument:

func fetch(ctx context.Context, url string) (Body, error) {
    req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
    return http.DefaultClient.Do(req)
}

// Caller decides the deadline.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

body, err := fetch(ctx, "https://example.com")

If the timeout fires, the in-flight request is cancelled and the goroutine returns. Pass ctx down through every layer; goroutines listen for <-ctx.Done() to exit early.

Mutex vs channel

Two ways to share state across goroutines:

// Mutex — protect direct shared state.
var mu sync.Mutex
counter := 0

go func() {
    mu.Lock()
    counter++
    mu.Unlock()
}()

// Channel — pass the state instead of sharing it.
ch := make(chan int)
go func() {
    val := <-ch
    process(val)
}()
ch <- 42

"Don't communicate by sharing memory; share memory by communicating."

The Go proverb. When in doubt, prefer channels — they're how the language was designed to be used. Use a mutex when the state is genuinely shared (a cache, a counter) and there's nothing meaningful to send.

Common bugs

  • Goroutine leak — a goroutine waiting on a channel that nobody will ever send to lives forever. Always make sure every spawned goroutine has a way to exit (close a channel, cancel a context).
  • Send on closed channel — panics. The sender should always be the one to close.
  • Range loop variable capturefor _, x := range xs { go func() { use(x) }() } captures the SAME x for every iteration. Pass it as an arg: go func(x T) { use(x) }(x).
  • WaitGroup.Add inside the goroutine — can race with Wait. Always Add before go func.