Goroutines & Channels

Concurrency with goroutines, channels, select, and the sync package.

Goroutines

A goroutine is a lightweight, independently-scheduled function managed by the Go runtime rather than the OS — you can spin up thousands of them cheaply (a few KB of stack each, growing as needed), unlike OS threads.

Go
package main

import (
    "fmt"
    "time"
)

func sayHello() {
    fmt.Println("Hello from a goroutine!")
}

func main() {
    go sayHello()          // starts sayHello concurrently, doesn't block
    time.Sleep(100 * time.Millisecond) // give it time to run before main exits
}

main() exiting kills all goroutines immediately, running or not — this is why real code coordinates goroutines properly instead of using time.Sleep (a bad practice used above purely for a minimal first example).

Channels

A channel is a typed pipe goroutines use to send and receive values safely — Go's idiom is "don't communicate by sharing memory; share memory by communicating."

Go
func worker(results chan<- int) {
    results <- 42 // send a value into the channel
}

func main() {
    results := make(chan int)
    go worker(results)

    value := <-results // receive — blocks until a value is available
    fmt.Println(value) // 42
}

Coordinating multiple goroutines with WaitGroup

Go
import "sync"

func main() {
    var wg sync.WaitGroup
    results := make(chan int, 3) // buffered channel — holds up to 3 values without blocking

    for i := 1; i <= 3; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            results <- n * n
        }(i) // pass i explicitly to avoid the classic loop-variable-capture bug
    }

    wg.Wait()
    close(results)

    for r := range results {
        fmt.Println(r) // 1, 4, 9 (order not guaranteed)
    }
}

select — waiting on multiple channels

select blocks until one of several channel operations is ready, similar in spirit to a switch for channels:

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

    go func() { ch1 <- "from ch1" }()
    go func() { ch2 <- "from ch2" }()

    for i := 0; i < 2; i++ {
        select {
        case msg1 := <-ch1:
            fmt.Println(msg1)
        case msg2 := <-ch2:
            fmt.Println(msg2)
        }
    }
}

Buffered vs unbuffered channels

  • Unbuffered (make(chan int)) — a send blocks until a receiver is ready. Great for strict handoff/synchronization.
  • Buffered (make(chan int, 5)) — a send only blocks once the buffer is full, decoupling producer and consumer timing somewhat.

The Go memory model, briefly

Go's race detector (go run -race main.go) can catch data races — concurrent, unsynchronized access to the same memory — during testing. If two goroutines access shared state without a channel or a sync.Mutex, run the race detector before trusting the code in production.

Go
var mu sync.Mutex
var counter int

func increment() {
    mu.Lock()
    defer mu.Unlock()
    counter++
}

Common mistakes

  • Capturing a loop variable by reference inside a goroutine closure without passing it as a parameter — a very common source of "all goroutines printed the same last value" bugs (mostly fixed automatically in Go 1.22+, but still worth understanding).
  • Forgetting to close() a channel when no more values will be sent, causing a range over that channel to block forever.
  • Sharing a map or slice across goroutines without a mutex — maps are not safe for concurrent read/write in Go.

Interview questions

Q: What's the difference between a goroutine and an OS thread? A goroutine is managed by the Go runtime's scheduler, starts with a tiny (~2KB) growable stack, and thousands can run cheaply. OS threads are managed by the kernel, have a much larger fixed stack, and are far more expensive to create and context-switch.

Q: What does Go's proverb "don't communicate by sharing memory; share memory by communicating" mean? Prefer passing data between goroutines through channels (communication) over having multiple goroutines directly read/write the same shared variables (shared memory), which requires careful locking and is more error-prone.

Q: What happens if you send on a closed channel? It panics immediately. It's safe to receive from a closed channel (you get the zero value and ok == false), but never safe to send on one.