Go Channels Tutorial
Channels are the communication pipes that connect concurrent goroutines in Go. Unlike shared memory models that require locks, channels enable goroutines to synchronize and exchange data safely through communication.
1. Channel Fundamentals
What Are Channels?
Channels are typed conduits that allow you to send and receive values between goroutines. They enforce synchronization by design - a send operation blocks until there's a receiver ready, and vice versa.
// Basic channel example
messageChan := make(chan string) // Create unbuffered string channel
go func() {
messageChan <- "Hello" // Send blocks until receiver is ready
}()
msg := <-messageChan // Receive blocks until sender is ready
fmt.Println(msg) // Prints: Hello
Channel Characteristics
- Thread-safe: No additional locking needed
- Synchronized: Send/receive operations coordinate goroutines
- Typed: Only declared data types can pass through
- First-in-first-out: Maintains order of sent values
2. Channel Types
Unbuffered Channels
Unbuffered channels (capacity=0) require both sender and receiver to be ready at the same instant. They provide strong synchronization guarantees.
func main() {
done := make(chan bool) // Unbuffered
go func() {
fmt.Println("Working...")
time.Sleep(time.Second)
done <- true // Blocks until main() receives
}()
<-done // Blocks until goroutine sends
fmt.Println("Finished")
}
Buffered Channels
Buffered channels (capacity>0) allow sends to complete without blocking until the buffer is full. They're useful when producers and consumers operate at different speeds.
func main() {
ch := make(chan int, 3) // Buffered (holds 3 values)
ch <- 1 // Doesn't block
ch <- 2
ch <- 3
fmt.Println(<-ch) // 1 (FIFO order)
ch <- 4 // Now allowed since space freed
}
Directional Channels
Channel directions constrain usage to prevent misuse in function signatures:
// Producer can only send to channel
func producer(ch chan<- int) {
for i := 0; i < 5; i++ {
ch <- i
}
close(ch)
}
// Consumer can only receive from channel
func consumer(ch <-chan int) {
for num := range ch {
fmt.Println(num)
}
}
3. Channel Operations
Sending and Receiving
The <- operator handles both sending and receiving. Operations block until the complementary operation is ready.
ch := make(chan int)
// Send operation (blocks if unbuffered and no receiver)
go func() { ch <- 42 }()
// Receive operation (blocks until value available)
value := <-ch
fmt.Println(value)
Closing Channels
Closing indicates no more values will be sent. Receivers can detect closure using the comma-ok idiom.
ch := make(chan int, 2)
ch <- 1
ch <- 2
close(ch) // Closes channel
// Safe receive check
if val, ok := <-ch; ok {
fmt.Println(val)
} else {
fmt.Println("Channel closed")
}
Range Over Channels
The range loop automatically exits when a channel is closed.
func producer(ch chan int) {
for i := 0; i < 3; i++ {
ch <- i
}
close(ch)
}
func main() {
ch := make(chan int)
go producer(ch)
// Automatically stops at close
for v := range ch {
fmt.Println(v) // 0, 1, 2
}
}
4. Select Statement
Basic Select
The select statement allows a goroutine to wait on multiple channel operations simultaneously.
ch1 := make(chan string)
ch2 := make(chan string)
go func() { ch1 <- "one" }()
go func() { ch2 <- "two" }()
for i := 0; i < 2; i++ {
select {
case msg1 := <-ch1:
fmt.Println(msg1)
case msg2 := <-ch2:
fmt.Println(msg2)
}
}
Default Case
A default case makes select non-blocking.
select {
case msg := <-messages:
fmt.Println(msg)
default:
fmt.Println("No message ready")
}
Timeout Pattern
Select can implement timeouts using time.After.
select {
case res := <-longOperation():
fmt.Println(res)
case <-time.After(1 * time.Second):
fmt.Println("Operation timed out")
}
5. Channel Patterns
Worker Pool
Distribute work across multiple goroutines using channels.
func worker(id int, jobs <-chan int, results chan<- int) {
for j := range jobs {
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 <= 5; j++ {
jobs <- j
}
close(jobs)
// Collect results
for a := 1; a <= 5; a++ {
<-results
}
}
Fan-out/Fan-in
Parallelize work across multiple goroutines and consolidate results.
func producer(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 merge(cs ...<-chan int) <-chan int {
var wg sync.WaitGroup
out := make(chan int)
// Start output goroutine for each input channel
for _, c := range cs {
wg.Add(1)
go func(c <-chan int) {
for n := range c {
out <- n
}
wg.Done()
}(c)
}
// Close out after all inputs are done
go func() {
wg.Wait()
close(out)
}()
return out
}
6. Common Pitfalls
Deadlocks
Occur when goroutines wait indefinitely for each other.
// Deadlock: No receiver for the send
func main() {
ch := make(chan int)
ch <- 42 // Blocks forever
fmt.Println(<-ch)
}
Premature Closing
Sending to a closed channel causes a panic.
ch := make(chan int)
close(ch)
ch <- 1 // panic: send on closed channel
Leaking Goroutines
Blocked goroutines that never terminate can leak resources.
func leak() {
ch := make(chan int)
go func() {
<-ch // Blocks forever
}()
// Returns without sending to ch
}
7. Best Practices
Ownership Principles
- Channel owners should close channels
- Never close channels from receivers
- Document channel ownership clearly
Error Handling
Use dedicated error channels in concurrent pipelines.
func worker(jobs <-chan int, results chan<- int, errs chan<- error) {
for j := range jobs {
if j < 0 {
errs <- fmt.Errorf("invalid job %d", j)
continue
}
results <- j * 2
}
}
When to Use Channels vs Mutexes
| Use Channels When | Use Mutexes When |
|---|---|
| Transferring ownership of data | Protecting internal state |
| Coordinating goroutines | Simple shared memory access |
| Implementing pipelines | Performance-critical sections |