Goroutines & Channels
Concurrency is Go's flagship feature. The language was designed from the ground up to make concurrent programming straightforward. Goroutines are lightweight threads managed by the Go runtime. Channels are typed pipes that let goroutines communicate safely. Together, they implement Go's concurrency philosophy: do not communicate by sharing memory; share memory by communicating.
Goroutines
A goroutine is a function executing concurrently with other goroutines. You start one with the go keyword.
func main() {
go sayHello("Alice")
go sayHello("Bob")
time.Sleep(time.Second) // Wait for goroutines (crude, we'll fix this later)
}
func sayHello(name string) {
fmt.Printf("Hello, %s\n", name)
}
Hello, Bob
Hello, Alice
The output order is not guaranteed. Goroutines run concurrently, and the scheduler decides the execution order.
Goroutines Are Cheap
A goroutine starts with about 8 KB of stack space (which grows as needed). You can run hundreds of thousands of goroutines in a single process. OS threads typically start at 1-8 MB.
func main() {
var wg sync.WaitGroup
for i := 0; i < 100_000; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
time.Sleep(time.Second)
}(i)
}
wg.Wait()
fmt.Println("100,000 goroutines completed")
}
This works. Try launching 100,000 OS threads and your system will run out of memory or file descriptors.
The go Statement
The go keyword works with any function call: named functions, methods, closures, and function literals.
// Named function
go processItem(item)
// Method
go server.Start()
// Function literal (anonymous function)
go func() {
fmt.Println("running in a goroutine")
}()
// Closure over a variable
name := "Alice"
go func() {
fmt.Println(name) // Captures name from outer scope
}()
Channels
Channels are typed conduits for passing values between goroutines. They are the primary synchronization mechanism in Go.
Creating Channels
ch := make(chan int) // Unbuffered channel of int
ch := make(chan string, 10) // Buffered channel with capacity 10
ch := make(chan struct{}) // Signal-only channel (zero-size type)
Sending & Receiving
ch <- 42 // Send 42 into the channel
value := <-ch // Receive a value from the channel
A simple example: one goroutine produces, another consumes.
func main() {
ch := make(chan string)
go func() {
ch <- "hello from goroutine"
}()
msg := <-ch
fmt.Println(msg) // hello from goroutine
}
Unbuffered Channels
An unbuffered channel (make(chan int)) has no internal storage. A send blocks until another goroutine receives, and a receive blocks until another goroutine sends. This creates a synchronization point: the sender and receiver must meet at the channel.
func main() {
ch := make(chan int)
go func() {
fmt.Println("sending...")
ch <- 42 // Blocks until main receives
fmt.Println("sent!")
}()
time.Sleep(time.Second)
fmt.Println("receiving...")
val := <-ch // Unblocks the sender
fmt.Println("received:", val)
}
sending...
receiving...
sent!
received: 42
The goroutine blocks on ch <- 42 for about a second, until main is ready to receive.
Buffered Channels
A buffered channel has internal capacity. Sends succeed without blocking until the buffer is full. Receives succeed without blocking as long as there is data in the buffer.
ch := make(chan int, 3)
ch <- 1 // Does not block (buffer has space)
ch <- 2 // Does not block
ch <- 3 // Does not block
// ch <- 4 // Would block (buffer is full)
fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2
fmt.Println(<-ch) // 3
Use buffered channels when the producer and consumer run at different speeds and you want to decouple them.
Closing Channels
The sender closes a channel to signal that no more values will be sent. Receivers can detect this.
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)
go producer(ch)
// Range over a channel: loop until closed
for val := range ch {
fmt.Println(val)
}
fmt.Println("channel closed, done")
}
0
1
2
3
4
channel closed, done
Rules for closing:
- Only the sender should close a channel. Closing a channel you receive from is a bug.
- Sending on a closed channel panics.
- Receiving from a closed channel returns the zero value immediately.
- You can detect a closed channel with the comma-ok idiom:
val, ok := <-ch
val, ok := <-ch
if !ok {
fmt.Println("channel is closed")
}
Channel Direction
Function signatures can restrict a channel to send-only or receive-only. This makes the intent clear and prevents misuse.
// Send-only: can only write to ch
func producer(ch chan<- int) {
ch <- 42
// <-ch // Compile error: cannot receive from send-only channel
}
// Receive-only: can only read from ch
func consumer(ch <-chan int) {
val := <-ch
fmt.Println(val)
// ch <- 1 // Compile error: cannot send to receive-only channel
}
func main() {
ch := make(chan int, 1)
// A bidirectional channel is implicitly convertible to directional
producer(ch)
consumer(ch)
}
A Real-World Example: Pipeline
Channels excel at building processing pipelines where each stage runs concurrently.
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
out <- n * n
}
close(out)
}()
return out
}
func main() {
// Pipeline: generate -> square
nums := generate(1, 2, 3, 4, 5)
squared := square(nums)
for val := range squared {
fmt.Println(val) // 1, 4, 9, 16, 25
}
}
Each stage reads from its input channel, processes, and writes to its output channel. They all run concurrently. The pipeline is self-regulating through channel backpressure -- if the consumer is slow, the producer blocks on send.
Common Pitfalls
- Goroutine leaks. A goroutine blocked on a channel send or receive that never completes will live forever, consuming memory. Always ensure goroutines can exit -- use context cancellation, close channels, or use buffered channels.
- Forgetting that main exits immediately. When
mainreturns, all goroutines are killed. Usesync.WaitGroup, channels, orselectto wait for goroutines to finish. - Sending on a closed channel. This causes a panic. Only the producer should close a channel, and only when it is done sending.
- Using unbuffered channels as queues. Unbuffered channels are synchronization points, not queues. If you need a queue, use a buffered channel.
- Sharing a loop variable across goroutines (pre-Go 1.22). The classic closure bug. In Go versions before 1.22, the loop variable is shared across iterations.
// Bug in Go < 1.22
for _, url := range urls {
go func() {
fetch(url) // All goroutines see the last value of url
}()
}
// Fix (all Go versions): pass as argument
for _, url := range urls {
go func(u string) {
fetch(u)
}(url)
}
- Not using channel direction in function signatures. Directional channels document intent and catch mistakes at compile time. Use them.
Key Takeaways
- Goroutines are launched with
go func(). They are cheap (8 KB initial stack) and can number in the hundreds of thousands. - Channels are typed pipes:
ch <- valueto send,value := <-chto receive. - Unbuffered channels synchronize sender and receiver. Buffered channels decouple them up to the buffer size.
- Close channels from the sender side only. Use
rangeto read until closed. - Channel direction (
chan<-,<-chan) in function signatures prevents misuse. - The core principle: do not communicate by sharing memory; share memory by communicating.
- Always ensure goroutines can exit. Leaked goroutines are memory leaks.