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.
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."
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
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:
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.
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 arangeover 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.