Goroutines & Channels
Concurrency the Go way: cheap goroutines, typed pipes, select.
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 capture —
for _, x := range xs { go func() { use(x) }() }captures the SAMExfor every iteration. Pass it as an arg:go func(x T) { use(x) }(x). - WaitGroup.Add inside the goroutine — can race with
Wait. AlwaysAddbeforego func.