Skip to content

Goroutines and channels

Go Language › 09 Goroutines and channels

The goroutine model

A goroutine is a lightweight thread managed by the Go runtime — the fundamental unit of concurrency in Go.

go
go func() {
    fmt.Println("hello from goroutine")
}()
// The program continues here without waiting for the goroutine

The go keyword spawns a function call to run concurrently. It works with anonymous closures and named functions:

go
func printHello() { fmt.Println("hello") }
go printHello()

M:N scheduling

Go's runtime uses an M:N scheduler — it multiplexes M goroutines across N OS threads. The scheduler is cooperatively preemptive, yielding at function calls, channel operations, memory allocation, and I/O syscalls. Goroutines are not scheduled by the OS — the Go runtime decides scheduling.

go
runtime.GOMAXPROCS(4) // Limit to 4 OS threads

The default GOMAXPROCS matches the CPU core count and is optimal for most workloads.

Stack management

Goroutines start with a tiny stack (~4 KB, vs ~1-8 MB per OS thread) that grows and shrinks as needed. This is why you can spawn millions of goroutines without exhausting memory.

ResourceGoroutineOS thread
Stack start~4 KB~1-8 MB
Spawn cost~1-2 µs~10-100 µs
Max per processMillionsThousands

The practical limit is blocking resources — each goroutine blocked on a syscall occupies an OS thread.

Fire-and-forget

The simplest pattern: start a goroutine and let it complete.

go
go func() {
    result := expensiveComputation()
    log.Printf("result: %v", result)
}()

A goroutine exits when its function returns. There is no kill — a goroutine must stop itself.

No return values

A goroutine cannot return a value to its caller — go discards the return. Use channels instead:

go
ch := make(chan int)
go func() {
    ch <- compute()
}()
result := <-ch

The main goroutine

When main() returns, all goroutines are killed immediately:

go
func main() {
    go func() { fmt.Println("may never run") }()
    // program exits immediately
}

Always ensure goroutines complete before main() returns, using channels or sync.WaitGroup.

Channel basics

A channel is a typed conduit for sending and receiving values between goroutines. Go's motto: "Do not communicate by sharing memory; instead, share memory by communicating."

go
ch := make(chan int) // unbuffered channel

go func() { ch <- 42 }()
value := <-ch // 42

Unbuffered channels

Zero capacity. Every send blocks until a receive is ready, and every receive blocks until a send is ready. This provides synchronisation — both goroutines rendezvous at the channel operation.

go
ch := make(chan int)
go func() { ch <- 1 }() // blocks until main receives
v := <-ch                // blocks until goroutine sends

Buffered channels

Fixed capacity. Sends block only when the buffer is full; receives block only when empty.

go
ch := make(chan int, 3)
ch <- 1 // does not block
ch <- 2
ch <- 3
// ch <- 4 // would block — buffer full

fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2

Use buffered channels when sender and receiver have different throughput rates, for bounded work queues, or to decouple producer and consumer up to the buffer size.

Channel direction

Function parameters can constrain channel direction at compile time:

go
func producer(ch chan<- int) { ch <- 42 }   // send-only
func consumer(ch <-chan int) { v := <-ch }  // receive-only

Nil channels

A nil channel blocks forever on both send and receive. Useful in select to disable a case dynamically.

go
var ch chan int // nil — sending or receiving blocks forever

Closing and ranging

close(ch)

Closing signals that no more values will be sent:

go
ch := make(chan int)
go func() {
    for i := 0; i < 5; i++ { ch <- i }
    close(ch)
}()
for v := range ch { fmt.Println(v) } // 0, 1, 2, 3, 4

Rules:

  • Only the sender closes a channel. Closing on the receiver side panics.
  • Sending on a closed channel panics.
  • Receiving from a closed, empty channel returns zero value (plus false from comma-ok).
  • Closing an already-closed channel panics.

Comma-ok receive

go
v, ok := <-ch
// ok == true  → value was delivered by a send
// ok == false → channel was closed and empty

for range on channels

go
func sumValues(ch <-chan int) int {
    total := 0
    for v := range ch { total += v }
    return total
}

The loop ends automatically when the channel is closed and drained.

select

select waits on multiple channel operations simultaneously:

go
select {
case v := <-ch1:
    fmt.Println("received from ch1:", v)
case ch2 <- value:
    fmt.Println("sent to ch2")
case <-time.After(1 * time.Second):
    fmt.Println("timeout — neither case ready")
default:
    fmt.Println("non-blocking — neither ready")
}

Select rules

  • Blocks until one case can proceed.
  • If multiple are ready, one is chosen uniformly at random.
  • default runs immediately if no other case is ready (non-blocking).
  • A nil channel case is never chosen.

Timeout pattern

go
select {
case v := <-ch:
    fmt.Println(v)
case <-time.After(1 * time.Second):
    fmt.Println("timeout")
}

Non-blocking send/receive

go
select {
case ch <- v:
    fmt.Println("sent")
default:
    fmt.Println("dropped — channel not ready")
}

Useful for rate limiting and lossy channels.

Fan-in

go
func fanIn(ch1, ch2 <-chan string) <-chan string {
    out := make(chan string)
    go func() {
        for {
            select {
            case v := <-ch1: out <- v
            case v := <-ch2: out <- v
            }
        }
    }()
    return out
}

Worker pool pattern

The canonical Go concurrency pattern: fixed workers consume jobs from a shared channel and send results to another channel.

go
func worker(id int, jobs <-chan int, results chan<- int) {
    for j := range jobs {
        fmt.Printf("worker %d processing job %d\n", id, j)
        time.Sleep(time.Second)
        results <- j * 2
    }
}

func main() {
    const numJobs = 10
    const numWorkers = 3

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

    for w := 1; w <= numWorkers; w++ {
        go worker(w, jobs, results)
    }

    for j := 1; j <= numJobs; j++ {
        jobs <- j
    }
    close(jobs)

    for r := 1; r <= numJobs; r++ {
        <-results
    }
}

Properties

  • Fixed parallelism: bounded by numWorkers, not numJobs.
  • Backpressure: the buffered jobs channel provides limited backpressure.
  • Fan-out/fan-in: jobs fanned out across workers, results fanned back.

Tuning

go
// CPU-bound: match GOMAXPROCS
numWorkers := runtime.GOMAXPROCS(0)

// I/O-bound: higher than CPU count
numWorkers := 100 // empirical tuning needed

Error handling

go
type Result struct {
    Value int
    Err   error
}

func worker(id int, jobs <-chan int, results chan<- Result) {
    for j := range jobs {
        v, err := process(j)
        results <- Result{Value: v, Err: err}
    }
}

Common mistakes

Goroutine leaks

go
ch := make(chan int)
go func() {
    ch <- expensiveOperation() // blocks forever — nobody receives
}()
// goroutine never exits

Fix: ensure every send has a corresponding receive, or use context cancellation.

Sending on a closed channel

go
ch := make(chan int)
close(ch)
ch <- 1 // panic

Ensure sends complete before closing.

Closing from the receiver

Only the sender should close. Coordinate multi-sender shutdown with sync.WaitGroup.

Using time.Sleep for synchronisation

go
// Bad
go func() { globalResult = compute() }()
time.Sleep(100 * time.Millisecond) // fragile, race-prone

// Good
ch := make(chan int)
go func() { ch <- compute() }()
result := <-ch

Unbuffered channel deadlock

go
ch := make(chan int)
ch <- 1 // blocks forever in same goroutine — no receiver
v := <-ch // never reached

Sender and receiver must be in different goroutines (or use a buffered channel).

The Intermediate tier's HTTP server uses go func() in test helpers for concurrent requests. The Advanced tier's graceful shutdown uses signal channels with signal.Notify.


Next: 10 Sync primitives and Context