Go Intermediate #3 Goroutines and Channels Intro
After #2 Error-Handling Patterns, it’s time for Go’s biggest strength — concurrency. We’ll start with how lightweight goroutines and channels work.
Goroutines — lightweight concurrent units #
package main
import (
"fmt"
"time"
)
func say(s string) {
for i := 0; i < 3; i++ {
fmt.Println(s)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
go say("hello") // run as a goroutine
say("world") // run on the main goroutine
time.Sleep(time.Second)
}A single go keyword runs the function concurrently in a new goroutine, separate from the main flow.
Why are goroutines lightweight? #
A single OS thread is usually 1MB ~ 2MB of memory. A goroutine starts at about 2KB. You can spawn hundreds of thousands without trouble.
The Go runtime automatically schedules many goroutines across a small number of OS threads. You express what should run concurrently; how it actually runs is the runtime’s job.
When main ends, all goroutines die #
A common pitfall.
func main() {
go say("hello")
// main exits immediately — say may not print a single line
}When main ends — every running goroutine dies instantly. The earlier time.Sleep was to make main wait long enough. In real code we use synchronization primitives like channels or WaitGroup to wait.
Channels — communication between goroutines #
A channel is a tool for passing values between goroutines. It also handles synchronization.
func main() {
ch := make(chan int)
go func() {
ch <- 42 // send on the channel
}()
v := <-ch // receive from the channel
fmt.Println(v) // 42
}<- points right when sending and left when receiving — an intuitive arrow.
Channels are synchronization tools #
A default channel is unbuffered. Send and receive must meet to proceed.
- Sender: blocks until a receiver is ready
- Receiver: blocks until a sender is ready
ch := make(chan struct{})
go func() {
doWork()
ch <- struct{}{} // "done" signal
}()
<-ch // main proceeds only after receiving the signal
fmt.Println("work done")struct{} (empty struct) is used often — when the value itself is meaningless and only the signal matters.
Buffered channels — like a queue #
ch := make(chan int, 3) // capacity 3
ch <- 1 // doesn't block
ch <- 2 // doesn't block
ch <- 3 // doesn't block
// ch <- 4 ← blocks here (buffer full)
<-ch // 1
<-ch // 2
<-ch // 3
// <-ch ← blocks here (empty)Sends block when capacity is full, receives block when empty. Behaves like a queue.
Unbuffered vs buffered — the unbuffered default is safer. Use a buffered channel only when there’s a clear reason (throughput, batching a known number of jobs, etc.).
Closing a channel — close
#
When the sender has nothing more to send.
ch := make(chan int)
go func() {
for i := 0; i < 5; i++ {
ch <- i
}
close(ch) // nothing more to send
}()
for v := range ch { // automatically ends when closed
fmt.Println(v)
}
// 0, 1, 2, 3, 4The for range ch pattern is the most common. It automatically ends when the channel is closed.
close rules #
- Receivers don’t close — only the sender
- Sending on a closed channel → panic
- Receiving from a closed channel → returns the zero value immediately (no block)
Check whether closed — comma-ok #
v, ok := <-ch
if !ok {
fmt.Println("channel closed")
}Same pattern as map’s comma-ok. If ok is false, it’s closed and there’s nothing more to receive.
WaitGroup — wait for many goroutines
#
To wait until many goroutines finish — sync.WaitGroup is standard.
import (
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Printf("goroutine %d\n", id)
}(i)
}
wg.Wait() // wait until all are done
fmt.Println("done")
}Three methods:
Add(n)— register the number of goroutines to wait forDone()— one goroutine finished (usually with defer)Wait()— wait until all are Done
Pitfall — closure capture #
for i := 0; i < 3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(i) // ✗ all may capture the same i (3, 3, 3)
}()
}A classic Go pitfall — every goroutine captured the same i variable, producing unexpected results. From Go 1.22+, each iteration gets a fresh i and the issue is resolved (similar to JS’s let).
For compatibility with older code, you’ll often see explicit copying to a local variable.
for i := 0; i < 3; i++ {
i := i // fresh variable per iteration
go func() {
fmt.Println(i)
}()
}Pattern — Worker Pool #
A common pattern — N goroutines pull jobs from a queue and process them.
func worker(id int, jobs <-chan int, results chan<- int) {
for j := range jobs {
fmt.Printf("worker %d: processing job %d\n", id, j)
time.Sleep(time.Second)
results <- j * 2
}
}
func main() {
jobs := make(chan int, 100)
results := make(chan int, 100)
// start 3 workers
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}
// send jobs
for j := 1; j <= 9; j++ {
jobs <- j
}
close(jobs) // no more
// collect results
for a := 1; a <= 9; a++ {
fmt.Println("result:", <-results)
}
}<-chan int (receive-only) and chan<- int (send-only) notation makes intent explicit in the function signature. Here it’s clear that worker only receives jobs and only sends results.
Pattern — Fan-out / Fan-in #
Distribute work to many goroutines (fan-out), gather results into one channel (fan-in). Covered in detail in #1 Concurrency Patterns.
Channels are like mailboxes — analogy #
Similar to OOP message passing. Two goroutines don’t share memory; they send data through channels.
“Don’t communicate by sharing memory; share memory by communicating.”
— Go aphorism
Instead of locking shared variables, the recommended model is to flow data through channels. Concurrent code is easier to read and has fewer bugs.
Pitfall — deadlock #
Channels are powerful, but used wrong they cause deadlock (everyone waiting on each other).
func main() {
ch := make(chan int)
ch <- 1 // ✗ no receiver — blocks forever
fmt.Println("never")
}
// fatal error: all goroutines are asleep - deadlock!When the Go runtime detects all goroutines stuck, it panics. It can’t catch partial deadlocks, but full deadlocks are detected automatically.
The most common causes:
- Sender/receiver mismatch
for range chwaiting forever because the channel was never closed- Two goroutines waiting on each other’s channels (cycle)
Pitfall — goroutine leak #
When a goroutine never ends and lives forever — a memory-leak-like situation arises.
func leak() {
ch := make(chan int)
go func() {
v := <-ch // waits forever
fmt.Println(v)
}()
// no one sends on ch and the function ends
// the goroutine survives, occupying memory
}The solution is to make sure every goroutine has an exit path. A context or a done channel are the standard tools for this (see #5 context).
Channels as function parameters — declare direction #
func produce(out chan<- int) { // send only
for i := 0; i < 5; i++ {
out <- i
}
close(out)
}
func consume(in <-chan int) { // receive only
for v := range in {
fmt.Println(v)
}
}
func main() {
ch := make(chan int)
go produce(ch)
consume(ch)
}How the function uses the channel — enforced by the signature. Trying to receive inside produce is a compile error. Intent is expressed in code.
Wrap-up #
What we covered:
go func()to start a goroutine — lightweight concurrent execution (~2KB)- When main ends, all goroutines end — synchronization required
- Channels — value transfer + synchronization between goroutines
- Unbuffered channels — send/receive must meet to proceed
- Buffered channels — queue-like, blocks when full
close(ch)+for rangeis the standard termination patternsync.WaitGroupto wait for many goroutines- Worker pool pattern (
<-chan/chan<-direction) - “Share memory by communicating” — the aphorism
- Deadlock and goroutine-leak pitfalls
In the next post (#4 select and Timeouts) we cover the select statement that handles many channels at once, plus timeout and cancellation patterns.