Back to Notes

Go Channels & Goroutines

Overview

Go's concurrency model is built on two primitives: goroutines (lightweight threads managed by the Go runtime) and channels (typed, thread-safe pipes for goroutines to communicate through). The philosophy: "Don't communicate by sharing memory; share memory by communicating."


Goroutines

A goroutine is a function running concurrently with other goroutines in the same address space. Launched with the go keyword — it returns immediately and the function runs in the background. Goroutines are cheap (~2KB stack, grows as needed) — you can have thousands running at once.

func sendEmail(to string) {
    fmt.Printf("Email sent to %s\n", to)
}

func main() {
    go sendEmail("alice@example.com")  // runs concurrently
    go sendEmail("bob@example.com")    // runs concurrently

    fmt.Println("Continuing...")
    time.Sleep(time.Millisecond)       // give goroutines time to finish
}
// Continuing...
// Email sent to alice@example.com   (order not guaranteed)
// Email sent to bob@example.com

Warning: If main() returns, all goroutines are killed immediately — regardless of whether they finished. Use channels or sync.WaitGroup to wait for them.


Channels — Basics

A channel is a typed, thread-safe queue that goroutines use to send and receive values. Must be created with make before use. The <- operator sends to or receives from a channel.

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

Sending: ch <- value — blocks until a receiver is ready Receiving: value := <-ch — blocks until a sender sends

func sum(nums []int, ch chan int) {
    total := 0
    for _, n := range nums {
        total += n
    }
    ch <- total   // send result to channel
}

func main() {
    nums := []int{1, 2, 3, 4, 5, 6}
    ch := make(chan int)

    go sum(nums[:3], ch)   // sum of first half → goroutine
    go sum(nums[3:], ch)   // sum of second half → goroutine

    a, b := <-ch, <-ch     // receive both results (blocks until ready)
    fmt.Println(a + b)     // 21
}

Channel Axioms (Dave Cheney)

Source: Channel Axioms — Dave Cheney

A few fundamental rules about channel behaviour worth internalising:

A declared but uninitialised channel is nil — just like a slice

var s []int      // nil
var c chan string // nil

var s = make([]int, 5) // not nil
var c = make(chan int)  // not nil

A send to a nil channel blocks forever

var c chan string
c <- "let's get started" // blocks forever

A receive from a nil channel blocks forever

var c chan string
fmt.Println(<-c) // blocks forever

A send to a closed channel panics

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

A receive from a closed channel returns the zero value immediately

c := make(chan int, 100)
close(c)
fmt.Println(<-c) // 0

These four rules give you a mental model for reasoning about any channel interaction at a glance.


Unbuffered Channels — Synchronous

An unbuffered channel (make(chan T)) requires both sender and receiver to be ready at the same time — it's a synchronous handshake. Sending blocks until a receiver reads, and receiving blocks until a sender sends.

func main() {
    ch := make(chan string)

    go func() {
        ch <- "hello"               // blocks until main receives
        fmt.Println("sent!")
    }()

    msg := <-ch                     // blocks until goroutine sends
    fmt.Println("received:", msg)
}
// received: hello
// sent!

Buffered Channels — Async up to Capacity

A buffered channel holds a fixed number of values internally. Sending only blocks when the buffer is full. Receiving only blocks when the buffer is empty. This decouples the sender and receiver — they don't need to be ready at the same time.

ch := make(chan int, 3)  // buffer capacity = 3

// Sending — doesn't block (buffer has space)
ch <- 1
ch <- 2
ch <- 3
// ch <- 4  // would BLOCK — buffer full

// Receiving — doesn't block (buffer has values)
fmt.Println(<-ch)  // 1
fmt.Println(<-ch)  // 2
fmt.Println(<-ch)  // 3
// <-ch  // would BLOCK — buffer empty

Full example showing blocking behaviour:

func main() {
    ch := make(chan int, 2)

    ch <- 1
    fmt.Println("sent 1")
    ch <- 2
    fmt.Println("sent 2")

    // Buffer is full — must send in a goroutine to avoid deadlock
    go func() {
        ch <- 3
        fmt.Println("sent 3")
    }()

    fmt.Println("received", <-ch)  // unblocks the goroutine above
    fmt.Println("received", <-ch)
    fmt.Println("received", <-ch)
}
// sent 1
// sent 2
// received 1
// sent 3
// received 2
// received 3

Closing Channels

The sender closes a channel with close(ch) to signal that no more values will be sent. Receivers can detect a closed channel using the comma-ok idiom. Closing is a signal — not required unless receivers need to know when to stop.

func producer(ch chan int) {
    for i := 0; i < 5; i++ {
        ch <- i
    }
    close(ch)   // signal: no more values
}

func main() {
    ch := make(chan int, 5)
    go producer(ch)

    // Comma-ok: ok=false when channel is closed and empty
    for {
        v, ok := <-ch
        if !ok {
            fmt.Println("channel closed")
            break
        }
        fmt.Println("received:", v)
    }
}
// received: 0
// received: 1
// received: 2
// received: 3
// received: 4
// channel closed

Rules for closing:

  • Only the sender should close — receiving from a closed channel is fine; sending to a closed channel panics
  • Closing is optional — only close when receivers need the signal to stop

Ranging Over Channels

range over a channel reads values until the channel is closed. It's the idiomatic way to consume all values from a channel without the comma-ok boilerplate.

func generate(ch chan int, n int) {
    for i := 0; i < n; i++ {
        ch <- i * i   // send squares
    }
    close(ch)         // must close — otherwise range blocks forever
}

func main() {
    ch := make(chan int, 5)
    go generate(ch, 5)

    for v := range ch {   // reads until closed
        fmt.Println(v)
    }
}
// 0  1  4  9  16

Real-world pattern — file upload pipeline:

func main() {
    files := []string{"image1.jpg", "video.mp4", "audio.mp3"}
    ch := make(chan string)

    go func() {
        for _, f := range files {
            fmt.Printf("Uploading %s...\n", f)
            ch <- f
        }
        close(ch)
    }()

    for f := range ch {
        fmt.Printf("Processing %s\n", f)
    }
    fmt.Println("All files processed.")
}
// Uploading image1.jpg...
// Processing image1.jpg...
// Uploading video.mp4...
// Processing video.mp4...
// Uploading audio.mp3...
// Processing audio.mp3...
// All files processed.

Channel Direction — Send-Only / Receive-Only

Function parameters can restrict a channel to send-only (chan<- T) or receive-only (<-chan T). This makes intent explicit and is enforced by the compiler — a function that should only send cannot accidentally receive.

// send-only: can only send into ch
func producer(ch chan<- int) {
    for i := 0; i < 3; i++ {
        ch <- i
    }
    close(ch)
    // v := <-ch  // compile error: receive from send-only channel
}

// receive-only: can only receive from ch
func consumer(ch <-chan int) {
    for v := range ch {
        fmt.Println("consumed:", v)
    }
    // ch <- 1  // compile error: send to receive-only channel
}

func main() {
    ch := make(chan int, 3)
    go producer(ch)
    consumer(ch)
}
// consumed: 0
// consumed: 1
// consumed: 2

select — Multiplexing Channels

select lets a goroutine wait on multiple channel operations at once, picking whichever is ready first. If multiple are ready simultaneously, one is chosen at random. It's like a switch but for channels.

func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)

    go func() { time.Sleep(1 * time.Second); ch1 <- "one" }()
    go func() { time.Sleep(2 * time.Second); ch2 <- "two" }()

    // Wait for whichever arrives first — twice
    for i := 0; i < 2; i++ {
        select {
        case msg := <-ch1:
            fmt.Println("received from ch1:", msg)
        case msg := <-ch2:
            fmt.Println("received from ch2:", msg)
        }
    }
}
// received from ch1: one   (after ~1s)
// received from ch2: two   (after ~2s)

select with default — Non-blocking

Adding a default case makes select non-blocking — it runs immediately if no channel is ready.

ch := make(chan int, 1)

select {
case v := <-ch:
    fmt.Println("received:", v)
default:
    fmt.Println("no value ready")  // runs immediately — channel is empty
}

Ignoring a Channel Value in select

When you only care that an event arrived, not the value itself, omit the variable. Both forms work — prefer the first (shorter):

// Preferred — no binding
select {
case <-ch:
    // event received; value ignored
default:
    // nothing ready
}

// Also valid — blank identifier
select {
case _ = <-ch:
    // event received; value ignored
default:
    // nothing ready
}

select with Timeout

Three time utilities to know:

FunctionBehaviour
time.After(d)Returns a channel that sends once after duration d
time.Tick(d)Returns a channel that sends repeatedly every interval d
time.Sleep(d)Blocks the current goroutine for duration d
ch := make(chan string)

select {
case msg := <-ch:
    fmt.Println("received:", msg)
case <-time.After(2 * time.Second):
    fmt.Println("timed out")   // runs if nothing arrives within 2s
}

Done Channel — Cancellation Signal

func worker(done <-chan struct{}) {
    for {
        select {
        case <-done:
            fmt.Println("worker stopped")
            return
        default:
            fmt.Println("working...")
            time.Sleep(500 * time.Millisecond)
        }
    }
}

func main() {
    done := make(chan struct{})
    go worker(done)

    time.Sleep(2 * time.Second)
    close(done)   // broadcast stop signal to all listeners
    time.Sleep(time.Millisecond)
}

sync.WaitGroup — Waiting for Goroutines

WaitGroup waits for a collection of goroutines to finish. Add(n) sets the count, Done() decrements it (call via defer), Wait() blocks until count reaches zero.

import "sync"

func worker(id int, wg *sync.WaitGroup) {
    defer wg.Done()   // decrement counter when this goroutine finishes
    fmt.Printf("worker %d starting\n", id)
    time.Sleep(time.Second)
    fmt.Printf("worker %d done\n", id)
}

func main() {
    var wg sync.WaitGroup

    for i := 1; i <= 3; i++ {
        wg.Add(1)           // increment before launching goroutine
        go worker(i, &wg)
    }

    wg.Wait()   // blocks until all workers call Done()
    fmt.Println("all workers finished")
}
// worker 1 starting
// worker 2 starting
// worker 3 starting
// worker 1 done      (order of "done" not guaranteed)
// worker 2 done
// worker 3 done
// all workers finished

Worker Pool Pattern

A common pattern: a fixed number of goroutines (workers) consume from a jobs channel. Controls concurrency and limits resource usage.

func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
    defer wg.Done()
    for j := range jobs {
        fmt.Printf("worker %d processing job %d\n", id, j)
        results <- j * j   // send result
    }
}

func main() {
    jobs    := make(chan int, 10)
    results := make(chan int, 10)
    var wg sync.WaitGroup

    // Start 3 workers
    for w := 1; w <= 3; w++ {
        wg.Add(1)
        go worker(w, jobs, results, &wg)
    }

    // Send 9 jobs
    for j := 1; j <= 9; j++ {
        jobs <- j
    }
    close(jobs)   // signal workers: no more jobs

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

    // Collect results
    for r := range results {
        fmt.Println("result:", r)
    }
}

Channel vs Mutex — When to Use Which

Use channels whenUse mutex (sync.Mutex) when
Passing data between goroutinesProtecting shared state (counter, cache)
Signalling / eventsSimple read/write guards
Pipelines and fan-out/fan-inPerformance-critical hot paths
One goroutine owns the dataMultiple goroutines share the same data

Gotchas

GotchaDetail
DeadlockAll goroutines blocked waiting — Go runtime detects and panics with all goroutines are asleep
Send to closed channelPanics — only the sender should close, and only once
Receive from nil channelBlocks forever — always initialise with make
Forgetting to closerange over an unclosed channel blocks forever
wg.Add inside goroutineRace condition — always call Add before launching the goroutine
main exits earlyIf main returns, all goroutines die — use WaitGroup or channel to wait
Goroutine leakGoroutines blocked on channels with no sender/receiver — use done channel or context to cancel