Skip to content

Goroutines and Channels

Intermediate

Go's concurrency model is built on goroutines (lightweight threads managed by the Go runtime) and channels (typed conduits for communication between goroutines). The philosophy: "Don't communicate by sharing memory; share memory by communicating."

Key Facts

  • A goroutine is a function executing concurrently, managed by the Go runtime scheduler, not the OS
  • Goroutine creation is ~2KB initial stack (grows as needed) vs ~1MB for OS threads
  • Go programs routinely run thousands to hundreds of thousands of concurrent goroutines
  • Channels are typed: chan int, chan string, chan MyStruct
  • Unbuffered channels block sender until receiver is ready (synchronization point)
  • Buffered channels (make(chan int, 10)) block sender only when buffer is full
  • select statement multiplexes across multiple channel operations
  • The context package provides cancellation, deadlines, and value propagation across goroutines

Goroutines

Launching

// Launch a goroutine with the go keyword
go func() {
    fmt.Println("running concurrently")
}()

// Named function
go processItem(item)

// Method call
go server.HandleRequest(req)

WaitGroup for Synchronization

var wg sync.WaitGroup

for _, item := range items {
    wg.Add(1)
    go func() {
        defer wg.Done()
        process(item)
    }()
}

wg.Wait() // blocks until all goroutines call Done()

Since Go 1.22, the loop variable item is per-iteration (no capture bug). Before 1.22, you needed item := item inside the loop.

Channels

Basic Operations

// Create channels
ch := make(chan int)       // unbuffered
bch := make(chan int, 10)  // buffered, capacity 10

// Send and receive
ch <- 42       // send (blocks on unbuffered until receiver ready)
val := <-ch    // receive (blocks until value available)

// Close a channel - signals no more values
close(ch)

// Range over channel - receives until closed
for val := range ch {
    fmt.Println(val)
}

Directional Channels

// Read-only channel (receive only)
func consumer(ch <-chan int) {
    for val := range ch {
        process(val)
    }
}

// Write-only channel (send only)
func producer(ch chan<- int) {
    for i := 0; i < 10; i++ {
        ch <- i
    }
    close(ch)
}

// Bidirectional chan converts implicitly to directional
ch := make(chan int)
go producer(ch) // chan int -> chan<- int (implicit)
consumer(ch)    // chan int -> <-chan int (implicit)

Generator Pattern

func countTo(max int) <-chan int {
    ch := make(chan int)
    go func() {
        for i := 0; i < max; i++ {
            ch <- i
        }
        close(ch)
    }()
    return ch
}

for val := range countTo(10) {
    fmt.Println(val)
}

Select Statement

Multiplexes across multiple channel operations. Blocks until one case is ready. If multiple are ready, one is chosen at random.

select {
case val := <-ch1:
    fmt.Println("received from ch1:", val)
case ch2 <- outVal:
    fmt.Println("sent to ch2")
case <-time.After(5 * time.Second):
    fmt.Println("timeout")
default:
    fmt.Println("no channel ready, non-blocking")
}

For-Select Loop (Common Pattern)

for {
    select {
    case val, ok := <-dataCh:
        if !ok {
            return // channel closed
        }
        process(val)
    case <-ctx.Done():
        return // cancellation
    }
}

Context for Cancellation

func countTo(ctx context.Context, max int) <-chan int {
    ch := make(chan int)
    go func() {
        defer close(ch)
        for i := 0; i < max; i++ {
            select {
            case <-ctx.Done():
                return
            case ch <- i:
            }
        }
    }()
    return ch
}

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

    ch := countTo(ctx, 100)
    for val := range ch {
        if val > 5 {
            break // cancel() called by defer, goroutine cleans up
        }
        fmt.Println(val)
    }
}

Context with Timeout

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

select {
case result := <-doWork(ctx):
    fmt.Println("result:", result)
case <-ctx.Done():
    fmt.Println("timed out:", ctx.Err())
}

Concurrency Patterns

Fan-Out / Fan-In

// Fan-out: distribute work to N goroutines
func fanOut(input <-chan int, workers int) []<-chan int {
    channels := make([]<-chan int, workers)
    for i := 0; i < workers; i++ {
        channels[i] = worker(input)
    }
    return channels
}

// Fan-in: merge multiple channels into one
func fanIn(channels ...<-chan int) <-chan int {
    merged := make(chan int)
    var wg sync.WaitGroup
    for _, ch := range channels {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for val := range ch {
                merged <- val
            }
        }()
    }
    go func() {
        wg.Wait()
        close(merged)
    }()
    return merged
}

Pipeline

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

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

// Usage: pipeline composition
ch := square(square(generate(2, 3, 4)))
for val := range ch {
    fmt.Println(val) // 16, 81, 256
}

Semaphore (Limit Concurrency)

sem := make(chan struct{}, 10) // max 10 concurrent

for _, item := range items {
    sem <- struct{}{} // acquire
    go func() {
        defer func() { <-sem }() // release
        process(item)
    }()
}

When to Use Channels vs Mutexes

Use channels when:
  - Passing ownership of data between goroutines
  - Coordinating multiple pieces of logic  
  - Distributing units of work

Use sync.Mutex when:
  - Protecting a shared cache or state
  - Simple counter increments (or use sync/atomic)
  - Guard an internal struct field

Gotchas

  • Goroutine leaks are silent memory leaks - a goroutine blocked forever on a channel send/receive is never garbage collected. Always provide a cancellation path (context) for every goroutine, and close channels when done producing
  • Sending on a closed channel panics - only the producer (sender) should close a channel, never the consumer. If multiple producers write to one channel, use a WaitGroup to close after all producers finish
  • Nil channels block forever - a send or receive on a nil channel blocks permanently. This is actually useful in select to disable a case, but accidentally using an uninitialized channel causes silent deadlocks
  • Buffered channels hide bugs - buffered channels mask timing issues during development that surface under load. Start with unbuffered channels, add buffering only when you have a measured performance reason

See Also