Channels in Go: How Goroutines Talk to Each Other

The Silent Orchestra Problem

Imagine an orchestra where musicians can't hear each other. The violinist finishes their part but the drummer doesn't know. The pianist waits forever for a cue that never comes. Chaos.
This is exactly what happens when goroutines can't communicate. They run independently but have no way to share results, coordinate timing, or signal completion. You end up with race conditions, missed data, and programs that work "sometimes."
Go's channels solve this elegantly. They're the conductor that keeps your goroutine orchestra in sync.

Before Channels: The Shared Memory Nightmare

Traditional concurrent programming uses shared memory. Multiple threads access the same variables. Sounds simple until you realize:
Go blog diagram 1

Go blog diagram 1

Both threads read 5, both increment to 6, but we lost an increment. This is a race condition. It happens randomly, making bugs nearly impossible to reproduce.
The traditional fix involves locks, mutexes, and careful programming. One mistake and you get deadlocks or corrupted data. It's error prone and mentally exhausting.

Pipes Between Workers

Think of it like this: Channels are pipes connecting workers in a factory. One worker puts items into the pipe, another takes them out. The pipe handles the transfer safely. Workers don't need to know where items come from or go to. They just work with what's in front of them.
Go's philosophy is "Don't communicate by sharing memory; share memory by communicating." Instead of goroutines accessing the same data, they send data through channels.

Understanding Channels Step by Step

A channel is a typed conduit. You can send values of one type through it and receive values of that same type.
go
// Create a channel that carries integers ch := make(chan int) // Send a value into the channel ch <- 42 // Receive a value from the channel value := <-ch
Go blog diagram 2

Go blog diagram 2

Here's your first working example:
go
// Filename: basic_channel.go package main import "fmt" func main() { // Create an unbuffered channel for strings // Why: Channels are typed, this one only carries strings messages := make(chan string) // Start a goroutine that sends a message // Why: Demonstrates sending data through a channel go func() { messages <- "Hello from goroutine!" }() // Receive the message in main goroutine // Why: This blocks until a message arrives msg := <-messages fmt.Println(msg) }
Expected Output:
Hello from goroutine!

The Blocking Nature of Channels

Unbuffered channels block. This is a feature, not a bug.
When you send: The sender blocks until someone receives When you receive: The receiver blocks until someone sends
Go blog diagram 3

Go blog diagram 3

This synchronization happens automatically. No locks needed. No race conditions possible. The channel ensures safe handoff.
go
// Filename: blocking_demo.go package main import ( "fmt" "time" ) func main() { ch := make(chan string) go func() { fmt.Println("Goroutine: About to send...") time.Sleep(2 * time.Second) ch <- "Message after 2 seconds" fmt.Println("Goroutine: Sent!") }() fmt.Println("Main: Waiting to receive...") msg := <-ch // This blocks until message arrives fmt.Println("Main: Received:", msg) }
Expected Output:
Main: Waiting to receive... Goroutine: About to send... Goroutine: Sent! Main: Received: Message after 2 seconds

Buffered Channels: When Blocking Hurts

Sometimes you don't want sending to block immediately. Buffered channels hold a fixed number of values without a receiver.
go
// Create a buffered channel with capacity 3 ch := make(chan int, 3) ch <- 1 // Doesn't block ch <- 2 // Doesn't block ch <- 3 // Doesn't block ch <- 4 // BLOCKS - buffer is full
Go blog diagram 4

Go blog diagram 4

When to use buffered channels:
  • Known number of items to process
  • Producer is faster than consumer temporarily
  • You want to limit work in progress
go
// Filename: buffered_channel.go package main import "fmt" func main() { // Buffered channel holds 2 messages // Why: Sender can send twice without blocking messages := make(chan string, 2) messages <- "First" // No blocking messages <- "Second" // No blocking fmt.Println(<-messages) // Prints: First fmt.Println(<-messages) // Prints: Second }
Expected Output:
First
Second

Closing Channels: Signaling Completion

Channels can be closed to signal that no more values will be sent.
go
close(ch) // Close the channel // Check if channel is closed value, ok := <-ch // ok is false if channel is closed and empty
Go blog diagram 5

Go blog diagram 5

A powerful pattern is ranging over a channel:
go
// Filename: channel_range.go package main import "fmt" func producer(ch chan int) { for i := 1; i <= 5; i++ { ch <- i } close(ch) // Signal no more values } func main() { ch := make(chan int) go producer(ch) // Range automatically stops when channel closes // Why: Clean way to receive all values for num := range ch { fmt.Println("Received:", num) } fmt.Println("Channel closed, loop ended") }
Expected Output:
Received: 1 Received: 2 Received: 3 Received: 4 Received: 5 Channel closed, loop ended

Select: Handling Multiple Channels

Real programs often work with multiple channels. The select statement lets you wait on multiple channel operations.
Go blog diagram 6

Go blog diagram 6

go
// Filename: select_example.go package main import ( "fmt" "time" ) func main() { ch1 := make(chan string) ch2 := make(chan string) // Goroutine that sends after 1 second go func() { time.Sleep(1 * time.Second) ch1 <- "Message from channel 1" }() // Goroutine that sends after 2 seconds go func() { time.Sleep(2 * time.Second) ch2 <- "Message from channel 2" }() // Wait for whichever channel is ready first // Why: select handles whichever is ready, doesn't block on one for i := 0; i < 2; i++ { select { case msg1 := <-ch1: fmt.Println("Received:", msg1) case msg2 := <-ch2: fmt.Println("Received:", msg2) } } }
Expected Output:
Received: Message from channel 1 Received: Message from channel 2

Real World Example: Worker Pool

Let's build a practical worker pool that processes jobs concurrently:
go
// Filename: worker_pool.go package main import ( "fmt" "time" ) // Job represents work to be done type Job struct { ID int Data string } // Result represents completed work type Result struct { JobID int Output string } // worker processes jobs from jobs channel and sends results // Why: Each worker runs as a goroutine, processing jobs independently func worker(id int, jobs <-chan Job, results chan<- Result) { for job := range jobs { // Simulate work time.Sleep(500 * time.Millisecond) // Send result results <- Result{ JobID: job.ID, Output: fmt.Sprintf("Worker %d processed: %s", id, job.Data), } } } func main() { const numJobs = 5 const numWorkers = 3 jobs := make(chan Job, numJobs) results := make(chan Result, numJobs) // Start workers // Why: Workers wait for jobs and process them concurrently for w := 1; w <= numWorkers; w++ { go worker(w, jobs, results) } // Send jobs // Why: Jobs are distributed to available workers automatically for j := 1; j <= numJobs; j++ { jobs <- Job{ID: j, Data: fmt.Sprintf("Task-%d", j)} } close(jobs) // No more jobs coming // Collect results // Why: We expect one result per job for r := 1; r <= numJobs; r++ { result := <-results fmt.Println(result.Output) } }
Expected Output:
Worker 3 processed: Task-3 Worker 1 processed: Task-1 Worker 2 processed: Task-2 Worker 1 processed: Task-4 Worker 3 processed: Task-5
Go blog diagram 7

Go blog diagram 7

Channel Direction: Making Intent Clear

You can specify if a channel is for sending only or receiving only:
go
// Send only: can only send to this channel func sender(ch chan<- int) { ch <- 42 } // Receive only: can only receive from this channel func receiver(ch <-chan int) { value := <-ch }
This catches bugs at compile time. If you try to receive from a send only channel, the compiler complains.

Common Patterns

Pattern 1: Done Signal

go
done := make(chan bool) go func() { // Do work done <- true // Signal completion }() <-done // Wait for completion

Pattern 2: Timeout

go
select { case result := <-ch: fmt.Println("Got result:", result) case <-time.After(3 * time.Second): fmt.Println("Timeout!") }

Pattern 3: Non blocking Operations

go
select { case msg := <-ch: fmt.Println("Received:", msg) default: fmt.Println("No message available") }

Mistakes That Will Bite You

Mistake 1: Sending on a closed channel (panic)
go
// WRONG: Causes panic ch := make(chan int) close(ch) ch <- 1 // PANIC: send on closed channel // RIGHT: Only close when sure no more sends
Mistake 2: Forgetting to close channels in range loops
go
// WRONG: Range never ends, goroutine leaks go func() { for i := 0; i < 5; i++ { ch <- i } // Missing close(ch) }() for v := range ch { // Blocks forever fmt.Println(v) }
Mistake 3: Deadlock from unbuffered channel in single goroutine
go
// WRONG: Deadlock ch := make(chan int) ch <- 1 // Blocks forever, no one to receive fmt.Println(<-ch) // RIGHT: Use goroutine or buffer ch := make(chan int, 1) ch <- 1 fmt.Println(<-ch)

Comparing Communication Approaches

ApproachSafetyComplexityUse Case
Shared MemoryLowHighRarely preferred in Go
ChannelsHighLowMost goroutine communication
Atomic OperationsHighMediumSimple counters only
MutexHighMediumProtecting complex state
Channels should be your default choice. They're safer and more Go idiomatic.

What You Learned

You now understand that:
  • Channels are typed pipes: They safely transfer data between goroutines
  • Unbuffered channels synchronize: Sender waits for receiver
  • Buffered channels decouple: Limited waiting before blocking
  • Select multiplexes: Handle multiple channels elegantly
  • Closing signals completion: Range loops detect closure

Your Next Steps

  • Build: Create a pipeline that downloads, processes, and saves files
  • Read Next: Learn about sync.WaitGroup for coordination without channels
  • Experiment: Try building a rate limiter with channels
Channels transform concurrent programming from a minefield into a structured conversation. Your goroutines now have a safe way to share data. Next, we'll explore the sync package for cases where channels aren't the best fit.
All Blogs
Tags:golangchannelsgoroutinesconcurrency