Go Channels & Goroutines
Overview
Go's concurrency model is built on two primitives: goroutines (lightweight threads managed by the Go runtime) and channels (typed, thread-safe pipes for goroutines to communicate through). The philosophy: "Don't communicate by sharing memory; share memory by communicating."
Goroutines
A goroutine is a function running concurrently with other goroutines in the same address space. Launched with the go keyword — it returns immediately and the function runs in the background. Goroutines are cheap (~2KB stack, grows as needed) — you can have thousands running at once.
func sendEmail(to string) {
fmt.Printf("Email sent to %s\n", to)
}
func main() {
go sendEmail("alice@example.com") // runs concurrently
go sendEmail("bob@example.com") // runs concurrently
fmt.Println("Continuing...")
time.Sleep(time.Millisecond) // give goroutines time to finish
}
// Continuing...
// Email sent to alice@example.com (order not guaranteed)
// Email sent to bob@example.com
Warning: If
main()returns, all goroutines are killed immediately — regardless of whether they finished. Use channels orsync.WaitGroupto wait for them.
Channels — Basics
A channel is a typed, thread-safe queue that goroutines use to send and receive values. Must be created with make before use. The <- operator sends to or receives from a channel.
ch := make(chan int) // unbuffered channel of int
Sending: ch <- value — blocks until a receiver is ready
Receiving: value := <-ch — blocks until a sender sends
func sum(nums []int, ch chan int) {
total := 0
for _, n := range nums {
total += n
}
ch <- total // send result to channel
}
func main() {
nums := []int{1, 2, 3, 4, 5, 6}
ch := make(chan int)
go sum(nums[:3], ch) // sum of first half → goroutine
go sum(nums[3:], ch) // sum of second half → goroutine
a, b := <-ch, <-ch // receive both results (blocks until ready)
fmt.Println(a + b) // 21
}
Channel Axioms (Dave Cheney)
Source: Channel Axioms — Dave Cheney
A few fundamental rules about channel behaviour worth internalising:
A declared but uninitialised channel is nil — just like a slice
var s []int // nil
var c chan string // nil
var s = make([]int, 5) // not nil
var c = make(chan int) // not nil
A send to a nil channel blocks forever
var c chan string
c <- "let's get started" // blocks forever
A receive from a nil channel blocks forever
var c chan string
fmt.Println(<-c) // blocks forever
A send to a closed channel panics
c := make(chan int, 100)
close(c)
c <- 1 // panic: send on closed channel
A receive from a closed channel returns the zero value immediately
c := make(chan int, 100)
close(c)
fmt.Println(<-c) // 0
These four rules give you a mental model for reasoning about any channel interaction at a glance.
Unbuffered Channels — Synchronous
An unbuffered channel (make(chan T)) requires both sender and receiver to be ready at the same time — it's a synchronous handshake. Sending blocks until a receiver reads, and receiving blocks until a sender sends.
func main() {
ch := make(chan string)
go func() {
ch <- "hello" // blocks until main receives
fmt.Println("sent!")
}()
msg := <-ch // blocks until goroutine sends
fmt.Println("received:", msg)
}
// received: hello
// sent!
Buffered Channels — Async up to Capacity
A buffered channel holds a fixed number of values internally. Sending only blocks when the buffer is full. Receiving only blocks when the buffer is empty. This decouples the sender and receiver — they don't need to be ready at the same time.
ch := make(chan int, 3) // buffer capacity = 3
// Sending — doesn't block (buffer has space)
ch <- 1
ch <- 2
ch <- 3
// ch <- 4 // would BLOCK — buffer full
// Receiving — doesn't block (buffer has values)
fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2
fmt.Println(<-ch) // 3
// <-ch // would BLOCK — buffer empty
Full example showing blocking behaviour:
func main() {
ch := make(chan int, 2)
ch <- 1
fmt.Println("sent 1")
ch <- 2
fmt.Println("sent 2")
// Buffer is full — must send in a goroutine to avoid deadlock
go func() {
ch <- 3
fmt.Println("sent 3")
}()
fmt.Println("received", <-ch) // unblocks the goroutine above
fmt.Println("received", <-ch)
fmt.Println("received", <-ch)
}
// sent 1
// sent 2
// received 1
// sent 3
// received 2
// received 3
Closing Channels
The sender closes a channel with close(ch) to signal that no more values will be sent. Receivers can detect a closed channel using the comma-ok idiom. Closing is a signal — not required unless receivers need to know when to stop.
func producer(ch chan int) {
for i := 0; i < 5; i++ {
ch <- i
}
close(ch) // signal: no more values
}
func main() {
ch := make(chan int, 5)
go producer(ch)
// Comma-ok: ok=false when channel is closed and empty
for {
v, ok := <-ch
if !ok {
fmt.Println("channel closed")
break
}
fmt.Println("received:", v)
}
}
// received: 0
// received: 1
// received: 2
// received: 3
// received: 4
// channel closed
Rules for closing:
- Only the sender should close — receiving from a closed channel is fine; sending to a closed channel panics
- Closing is optional — only close when receivers need the signal to stop
Ranging Over Channels
range over a channel reads values until the channel is closed. It's the idiomatic way to consume all values from a channel without the comma-ok boilerplate.
func generate(ch chan int, n int) {
for i := 0; i < n; i++ {
ch <- i * i // send squares
}
close(ch) // must close — otherwise range blocks forever
}
func main() {
ch := make(chan int, 5)
go generate(ch, 5)
for v := range ch { // reads until closed
fmt.Println(v)
}
}
// 0 1 4 9 16
Real-world pattern — file upload pipeline:
func main() {
files := []string{"image1.jpg", "video.mp4", "audio.mp3"}
ch := make(chan string)
go func() {
for _, f := range files {
fmt.Printf("Uploading %s...\n", f)
ch <- f
}
close(ch)
}()
for f := range ch {
fmt.Printf("Processing %s\n", f)
}
fmt.Println("All files processed.")
}
// Uploading image1.jpg...
// Processing image1.jpg...
// Uploading video.mp4...
// Processing video.mp4...
// Uploading audio.mp3...
// Processing audio.mp3...
// All files processed.
Channel Direction — Send-Only / Receive-Only
Function parameters can restrict a channel to send-only (chan<- T) or receive-only (<-chan T). This makes intent explicit and is enforced by the compiler — a function that should only send cannot accidentally receive.
// send-only: can only send into ch
func producer(ch chan<- int) {
for i := 0; i < 3; i++ {
ch <- i
}
close(ch)
// v := <-ch // compile error: receive from send-only channel
}
// receive-only: can only receive from ch
func consumer(ch <-chan int) {
for v := range ch {
fmt.Println("consumed:", v)
}
// ch <- 1 // compile error: send to receive-only channel
}
func main() {
ch := make(chan int, 3)
go producer(ch)
consumer(ch)
}
// consumed: 0
// consumed: 1
// consumed: 2
select — Multiplexing Channels
select lets a goroutine wait on multiple channel operations at once, picking whichever is ready first. If multiple are ready simultaneously, one is chosen at random. It's like a switch but for channels.
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() { time.Sleep(1 * time.Second); ch1 <- "one" }()
go func() { time.Sleep(2 * time.Second); ch2 <- "two" }()
// Wait for whichever arrives first — twice
for i := 0; i < 2; i++ {
select {
case msg := <-ch1:
fmt.Println("received from ch1:", msg)
case msg := <-ch2:
fmt.Println("received from ch2:", msg)
}
}
}
// received from ch1: one (after ~1s)
// received from ch2: two (after ~2s)
select with default — Non-blocking
Adding a default case makes select non-blocking — it runs immediately if no channel is ready.
ch := make(chan int, 1)
select {
case v := <-ch:
fmt.Println("received:", v)
default:
fmt.Println("no value ready") // runs immediately — channel is empty
}
Ignoring a Channel Value in select
When you only care that an event arrived, not the value itself, omit the variable. Both forms work — prefer the first (shorter):
// Preferred — no binding
select {
case <-ch:
// event received; value ignored
default:
// nothing ready
}
// Also valid — blank identifier
select {
case _ = <-ch:
// event received; value ignored
default:
// nothing ready
}
select with Timeout
Three time utilities to know:
| Function | Behaviour |
|---|---|
time.After(d) | Returns a channel that sends once after duration d |
time.Tick(d) | Returns a channel that sends repeatedly every interval d |
time.Sleep(d) | Blocks the current goroutine for duration d |
ch := make(chan string)
select {
case msg := <-ch:
fmt.Println("received:", msg)
case <-time.After(2 * time.Second):
fmt.Println("timed out") // runs if nothing arrives within 2s
}
Done Channel — Cancellation Signal
func worker(done <-chan struct{}) {
for {
select {
case <-done:
fmt.Println("worker stopped")
return
default:
fmt.Println("working...")
time.Sleep(500 * time.Millisecond)
}
}
}
func main() {
done := make(chan struct{})
go worker(done)
time.Sleep(2 * time.Second)
close(done) // broadcast stop signal to all listeners
time.Sleep(time.Millisecond)
}
sync.WaitGroup — Waiting for Goroutines
WaitGroup waits for a collection of goroutines to finish. Add(n) sets the count, Done() decrements it (call via defer), Wait() blocks until count reaches zero.
import "sync"
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // decrement counter when this goroutine finishes
fmt.Printf("worker %d starting\n", id)
time.Sleep(time.Second)
fmt.Printf("worker %d done\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1) // increment before launching goroutine
go worker(i, &wg)
}
wg.Wait() // blocks until all workers call Done()
fmt.Println("all workers finished")
}
// worker 1 starting
// worker 2 starting
// worker 3 starting
// worker 1 done (order of "done" not guaranteed)
// worker 2 done
// worker 3 done
// all workers finished
Worker Pool Pattern
A common pattern: a fixed number of goroutines (workers) consume from a jobs channel. Controls concurrency and limits resource usage.
func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for j := range jobs {
fmt.Printf("worker %d processing job %d\n", id, j)
results <- j * j // send result
}
}
func main() {
jobs := make(chan int, 10)
results := make(chan int, 10)
var wg sync.WaitGroup
// Start 3 workers
for w := 1; w <= 3; w++ {
wg.Add(1)
go worker(w, jobs, results, &wg)
}
// Send 9 jobs
for j := 1; j <= 9; j++ {
jobs <- j
}
close(jobs) // signal workers: no more jobs
// Close results when all workers are done
go func() {
wg.Wait()
close(results)
}()
// Collect results
for r := range results {
fmt.Println("result:", r)
}
}
Channel vs Mutex — When to Use Which
| Use channels when | Use mutex (sync.Mutex) when |
|---|---|
| Passing data between goroutines | Protecting shared state (counter, cache) |
| Signalling / events | Simple read/write guards |
| Pipelines and fan-out/fan-in | Performance-critical hot paths |
| One goroutine owns the data | Multiple goroutines share the same data |
Gotchas
| Gotcha | Detail |
|---|---|
| Deadlock | All goroutines blocked waiting — Go runtime detects and panics with all goroutines are asleep |
| Send to closed channel | Panics — only the sender should close, and only once |
| Receive from nil channel | Blocks forever — always initialise with make |
Forgetting to close | range over an unclosed channel blocks forever |
wg.Add inside goroutine | Race condition — always call Add before launching the goroutine |
| main exits early | If main returns, all goroutines die — use WaitGroup or channel to wait |
| Goroutine leak | Goroutines blocked on channels with no sender/receiver — use done channel or context to cancel |