Lesson 5: Goroutines & Channels
Why Concurrency Defines Go
Go was built at Google to solve Google-scale problems: thousands of network connections, parallel I/O, distributed systems. The language has concurrency built into its runtime, not bolted on as a library.
// This is how Kubernetes runs a controller:
func (c *Controller) Run(ctx context.Context, workers int) {
for i := 0; i < workers; i++ {
go c.worker(ctx) // launch worker goroutines
}
<-ctx.Done()
}
That go keyword launches a goroutine — a lightweight thread managed by Go's runtime, not the OS. Kubernetes controllers, Docker container runtimes, Prometheus scrapers — all use this pattern.
Do not communicate by sharing memory; instead, share memory by communicating. — Go proverb
This means: prefer channels (sending data between goroutines) over mutexes (locking shared memory). Not always — sometimes a mutex is the right tool — but the default Go instinct should be "use a channel."
Launch goroutines, communicate between them with channels, multiplex with select, and build a pipeline pattern.
Goroutines — Lightweight Concurrency
A goroutine is a function running concurrently with other goroutines. They're not OS threads — Go multiplexes thousands of goroutines onto a small number of OS threads.
Launching a Goroutine
go doWork() // named function
go func() { // anonymous function
fmt.Println("hello from goroutine")
}()
That's it. go before any function call starts it in a new goroutine. The goroutine runs concurrently — the calling code continues immediately.
Goroutines Are Cheap
A goroutine starts with ~2KB of stack space (and grows as needed). You can have hundreds of thousands of goroutines in a single process. An OS thread needs ~1MB. This is why Go servers handle thousands of concurrent connections with ease.
The Closure Trap (Go < 1.22)
// BUG in Go < 1.22: loop variable captured by reference
for i := 0; i < 5; i++ {
go func() {
fmt.Println(i) // all goroutines print 5!
}()
}
// Fix 1: pass as parameter
for i := 0; i < 5; i++ {
go func(n int) { fmt.Println(n) }(i)
}
// Fix 2: local copy
for i := 0; i < 5; i++ {
i := i // shadow creates a new variable per iteration
go func() { fmt.Println(i) }()
}
// Go 1.22+: loop variables are per-iteration — the bug is fixed.
You're on Go 1.26.4 — the loop variable trap is fixed. But you'll see the old workarounds in existing codebases (Kubernetes, Docker), so it's important to recognize them.
Channels — Typed Communication Pipes
A channel is a typed conduit through which you send and receive values between goroutines.
Creating Channels
// Unbuffered channel: send blocks until receive, receive blocks until send
ch := make(chan int)
// Buffered channel: send blocks only when buffer is full
ch := make(chan string, 10)
Send and Receive
ch <- 42 // send: put 42 into channel
val := <-ch // receive: take value out
val, ok := <-ch // receive with check: ok=false if closed & empty
Unbuffered = Synchronization
An unbuffered channel synchronizes the sender and receiver. The sender blocks until a receiver takes the value. This is not just data transfer — it's a synchronization primitive.
func main() {
ch := make(chan string) // unbuffered
go func() {
time.Sleep(time.Second)
ch <- "done" // sender blocks until main goroutine receives
}()
fmt.Println("waiting...")
msg := <-ch // blocks here until goroutine sends
fmt.Println(msg) // prints "done" after 1 second
}
Channel Direction
You can constrain channels at function boundaries to prevent misuse:
func publish(ch chan<- string, msg string) { // send-only
ch <- msg
}
func subscribe(ch <-chan string) { // receive-only
msg := <-ch
fmt.Println(msg)
}
ch := make(chan string)
go publish(ch, "hello") // bidirectional → send-only (automatic)
go subscribe(ch) // bidirectional → receive-only (automatic)
Directional channels in function signatures are self-documenting and prevent bugs. If you see chan<-, you know the function only sends. <-chan, it only receives.
Buffered Channels
A buffered channel has a capacity. Sends block only when the buffer is full; receives block only when the buffer is empty.
ch := make(chan int, 3) // buffer of 3
ch <- 1 // doesn't block (buffer: [1])
ch <- 2 // doesn't block (buffer: [1, 2])
ch <- 3 // doesn't block (buffer: [1, 2, 3] — full)
ch <- 4 // BLOCKS — buffer full, no receiver yet
fmt.Println(<-ch) // 1 (buffer: [2, 3] — now there's space)
Buffered channels are for throughput, not synchronization. If you're using a buffered channel to "not block," ask yourself: are you sure you don't need that synchronization? Most Go code uses unbuffered channels by default.
select — Multiplex Channels
select is like switch but for channel operations. It blocks until one case is ready, then executes it. If multiple are ready, it picks one at random.
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(1 * time.Second)
ch1 <- "from ch1"
}()
go func() {
time.Sleep(2 * time.Second)
ch2 <- "from ch2"
}()
for i := 0; i < 2; i++ {
select {
case msg1 := <-ch1:
fmt.Println(msg1)
case msg2 := <-ch2:
fmt.Println(msg2)
}
}
// Output: "from ch1", then "from ch2" (1s apart)
}
Timeout with time.After
select {
case result := <-ch:
fmt.Println(result)
case <-time.After(5 * time.Second):
fmt.Println("timed out")
}
Non-blocking with default
select {
case msg := <-ch:
fmt.Println("received:", msg)
default:
fmt.Println("no message available")
}
Close and Range Over Channels
Closing a channel signals "no more values will be sent." It's a broadcast to all receivers.
func producer(ch chan<- int) {
for i := 0; i < 5; i++ {
ch <- i
}
close(ch) // "I'm done — no more values"
}
func main() {
ch := make(chan int)
go producer(ch)
// range automatically exits when channel is closed
for val := range ch {
fmt.Println(val)
}
// Output: 0 1 2 3 4
}
Channel Close Semantics
Operationnil channelOpen channelClosed channel SendBlocks foreverBlocks until spacePANICS ReceiveBlocks foreverBlocks until valueZero value (ok=false) ClosePANICSSucceedsPANICS
Only the sender closes the channel. Receiving from a closed channel is safe (returns zero value). Sending on a closed channel panics. Closing a closed channel panics. The sender is the only one who knows "no more data."
Real-World Patterns
Pattern 1: Pipeline
Channels connect stages — each stage reads from one channel, processes, writes to the next:
func gen(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums { out <- n }
close(out)
}()
return out
}
func sq(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in { out <- n * n }
close(out)
}()
return out
}
// Pipeline: gen → sq → stdout
for n := range sq(gen(2, 3, 4)) {
fmt.Println(n) // 4, 9, 16
}
Pattern 2: Done Channel (Cancellation)
func worker(done <-chan struct{}, input <-chan int) {
for {
select {
case <-done:
fmt.Println("worker: stopping")
return
case val := <-input:
fmt.Println("worker:", val)
}
}
}
// To cancel: close(done) — all workers receive zero value, exit
chan struct{} is the idiomatic signal-only channel — zero bytes, pure synchronization.
Pattern 3: Fan-Out (multiple workers, one input)
func fanOut(input <-chan int, workers int) []<-chan int {
outputs := make([]<-chan int, workers)
for i := 0; i < workers; i++ {
ch := make(chan int)
outputs[i] = ch
go func(out chan<- int) {
for val := range input { out <- val * val }
close(out)
}(ch)
}
return outputs
}
Practice
Write a program that launches a goroutine to compute sum(1..N) and sends the result back through a channel. In main(), print the result received from the channel.
Use an unbuffered channel. Let N = 100. You should see 5050.
Write a function FetchWithTimeout(url string, timeout time.Duration) (string, error) that:
- Launches a goroutine that "fetches" the URL (simulate with
time.Sleepand return"content of " + url) - Uses
selectto wait for either the result or a timeout - Returns the result or an error if timeout occurs
Build a 3-stage pipeline for processing strings:
- Stage 1: Read strings from a slice and send them to a channel
- Stage 2: Read from stage 1, convert to uppercase, send to stage 3
- Stage 3: Read from stage 2, print each string
Each stage should run in its own goroutine. Close channels to signal completion. Print
["hello", "world", "golang"]→HELLO WORLD GOLANG.
Quick Check
What's Next?
You can now write concurrent Go programs with goroutines and channels. Next: Concurrency II — sync, atomic & the Race Detector. Mutexes, WaitGroups, atomic operations, and the tool Go uses to catch data races.
Ask Questions
Confused about buffered vs unbuffered? Want to see more pipeline examples? Ask.
1. Q1: What happens when you send on a closed channel?
2. Q2: An unbuffered channel synchronizes because:
3. Q3: Who should close a channel?