In the previous tutorial, you learned about goroutines. But goroutines alone are not enough. They need a way to communicate. That is what channels are for.
Channels are typed pipes that connect goroutines. One goroutine sends a value into the channel, another goroutine receives it. Channels make concurrent programming safe and simple.
Go has a famous saying: “Do not communicate by sharing memory. Share memory by communicating.” Channels are how you do that.
Creating and Using Channels
A channel is created with make. It has a type — the type of values it carries:
package main
import "fmt"
func main() {
// Create a channel that carries strings
ch := make(chan string)
// Send a value in a goroutine
go func() {
ch <- "Hello from goroutine!" // Send
}()
// Receive the value in main
message := <-ch // Receive
fmt.Println(message)
}
Output:
Hello from goroutine!
Two operations:
ch <- value— send a value into the channelvalue := <-ch— receive a value from the channel
Important: An unbuffered channel blocks the sender until the receiver is ready, and blocks the receiver until the sender is ready. This is how goroutines synchronize.
Unbuffered Channels
The channel we just created is unbuffered. This means:
- The sender blocks until someone receives
- The receiver blocks until someone sends
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int)
go func() {
fmt.Println("Sending 42...")
ch <- 42 // Blocks until main receives
fmt.Println("Sent!")
}()
time.Sleep(500 * time.Millisecond) // Simulate delay
fmt.Println("Receiving...")
value := <-ch // Unblocks the sender
fmt.Println("Received:", value)
}
Output:
Sending 42...
Receiving...
Sent!
Received: 42
The goroutine sends 42 and blocks. After 500ms, main receives it, and both continue.
Buffered Channels
A buffered channel has a capacity. Sends only block when the buffer is full. Receives only block when the buffer is empty:
package main
import "fmt"
func main() {
// Channel with buffer size 3
ch := make(chan string, 3)
// These sends do NOT block because the buffer has space
ch <- "first"
ch <- "second"
ch <- "third"
// ch <- "fourth" // This WOULD block — buffer is full
fmt.Println(<-ch) // first
fmt.Println(<-ch) // second
fmt.Println(<-ch) // third
}
Output:
first
second
third
Use buffered channels when the sender and receiver work at different speeds. The buffer absorbs temporary differences.
When to Use Buffered vs Unbuffered
| Type | Use When |
|---|---|
| Unbuffered | You need synchronization — sender waits for receiver |
| Buffered | You want to decouple sender and receiver speed |
Most of the time, start with unbuffered channels. Only add a buffer if you have a specific reason.
Channel Direction
You can restrict a channel to send-only or receive-only in function signatures. This makes your code safer:
package main
import "fmt"
// send-only channel parameter
func producer(ch chan<- int) {
for i := 0; i < 5; i++ {
ch <- i * 10
}
close(ch)
}
// receive-only channel parameter
func consumer(ch <-chan int) {
for val := range ch {
fmt.Printf("Received: %d\n", val)
}
}
func main() {
ch := make(chan int)
go producer(ch) // Can only send
consumer(ch) // Can only receive
}
Output:
Received: 0
Received: 10
Received: 20
Received: 30
Received: 40
Three channel types:
chan int— bidirectional (send and receive)chan<- int— send-only<-chan int— receive-only
Go converts bidirectional channels to directional ones automatically. This is a compile-time safety feature. A function with chan<- int cannot accidentally receive from the channel.
Closing Channels
Close a channel to signal that no more values will be sent:
package main
import "fmt"
func main() {
ch := make(chan int, 5)
// Send some values and close
for i := 1; i <= 5; i++ {
ch <- i
}
close(ch)
// Method 1: Check if channel is closed
val, ok := <-ch
fmt.Printf("Value: %d, Open: %v\n", val, ok) // 1, true
// Method 2: Range over channel (stops when closed)
for val := range ch {
fmt.Printf("Got: %d\n", val)
}
fmt.Println("Channel closed, range finished")
}
Output:
Value: 1, Open: true
Got: 2
Got: 3
Got: 4
Got: 5
Channel closed, range finished
Rules for closing channels:
- Only the sender should close a channel
- Sending on a closed channel causes a panic
- Receiving from a closed channel returns the zero value immediately
rangeover a channel stops when the channel is closed
Deadlocks
A deadlock happens when goroutines are waiting for each other and none can proceed. Go detects simple deadlocks at runtime:
package main
func main() {
ch := make(chan int)
ch <- 42 // Blocks forever — no goroutine to receive
}
Output:
fatal error: all goroutines are asleep - deadlock!
Common causes of deadlocks:
// Deadlock 1: Send on unbuffered channel with no receiver
ch := make(chan int)
ch <- 1 // Blocks — nobody is receiving
// Deadlock 2: Receive on empty channel with no sender
ch := make(chan int)
<-ch // Blocks — nobody is sending
// Deadlock 3: Forgetting to close a channel used with range
ch := make(chan int)
go func() {
ch <- 1
ch <- 2
// Missing close(ch)!
}()
for val := range ch { // Blocks forever after receiving 2
fmt.Println(val)
}
Solution: Always make sure every send has a receiver, and close channels when done sending.
Pattern: Generator
A generator is a function that returns a receive-only channel. It produces values in a goroutine:
package main
import "fmt"
func fibonacci(n int) <-chan int {
ch := make(chan int)
go func() {
a, b := 0, 1
for i := 0; i < n; i++ {
ch <- a
a, b = b, a+b
}
close(ch)
}()
return ch
}
func main() {
fmt.Println("Fibonacci numbers:")
for val := range fibonacci(10) {
fmt.Printf("%d ", val)
}
fmt.Println()
}
Output:
Fibonacci numbers:
0 1 1 2 3 5 8 13 21 34
The generator pattern is clean. The caller just ranges over the channel. It does not need to know how values are produced.
Pattern: Pipeline
A pipeline connects multiple stages with channels. Each stage is a goroutine:
package main
import "fmt"
// Stage 1: Generate numbers
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}
// Stage 2: Square each number
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
out <- n * n
}
close(out)
}()
return out
}
// Stage 3: Filter — only pass even numbers
func filterEven(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
if n%2 == 0 {
out <- n
}
}
close(out)
}()
return out
}
func main() {
fmt.Println("=== GO-12: Channels Pipeline ===")
fmt.Println()
// Connect the pipeline: generate -> square -> filterEven
nums := generate(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
squared := square(nums)
even := filterEven(squared)
fmt.Println("Even squares of 1-10:")
for val := range even {
fmt.Printf("%d ", val)
}
fmt.Println()
}
Output:
=== GO-12: Channels Pipeline ===
Even squares of 1-10:
4 16 36 64 100
Each stage runs in its own goroutine. Data flows through channels from one stage to the next. This pattern is powerful for data processing.
A Complete Example
Here is a practical example — a concurrent task processor:
package main
import (
"fmt"
"math/rand"
"sync"
"time"
)
type Task struct {
ID int
Name string
}
type Result struct {
TaskID int
TaskName string
Duration time.Duration
}
func processTasks(tasks []Task) []Result {
taskCh := make(chan Task)
resultCh := make(chan Result)
// Start 3 worker goroutines
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for task := range taskCh {
start := time.Now()
// Simulate work
time.Sleep(time.Duration(rand.Intn(200)) * time.Millisecond)
elapsed := time.Since(start)
fmt.Printf("Worker %d processed task %d (%s)\n", workerID, task.ID, task.Name)
resultCh <- Result{TaskID: task.ID, TaskName: task.Name, Duration: elapsed}
}
}(i + 1)
}
// Send tasks to workers
go func() {
for _, task := range tasks {
taskCh <- task
}
close(taskCh)
}()
// Close result channel when all workers are done
go func() {
wg.Wait()
close(resultCh)
}()
// Collect results
var results []Result
for result := range resultCh {
results = append(results, result)
}
return results
}
func main() {
fmt.Println("=== GO-12: Channels ===")
fmt.Println()
tasks := []Task{
{ID: 1, Name: "Parse config"},
{ID: 2, Name: "Load data"},
{ID: 3, Name: "Validate input"},
{ID: 4, Name: "Process records"},
{ID: 5, Name: "Generate report"},
{ID: 6, Name: "Send email"},
}
start := time.Now()
results := processTasks(tasks)
totalTime := time.Since(start)
fmt.Println("\nResults:")
for _, r := range results {
fmt.Printf(" Task %d (%s): %v\n", r.TaskID, r.TaskName, r.Duration)
}
fmt.Printf("\nTotal time: %v\n", totalTime)
}
This example shows a common pattern: multiple workers reading tasks from a channel and writing results to another channel. You will see this pattern often in production Go code.
Common Mistakes
1. Sending on a closed channel.
ch := make(chan int)
close(ch)
ch <- 1 // panic: send on closed channel
Only close a channel from the sender side. Never close it from the receiver.
2. Forgetting to close the channel in a range loop.
ch := make(chan int)
go func() {
ch <- 1
ch <- 2
// Forgot close(ch)!
}()
for val := range ch { // Hangs forever after receiving 2
fmt.Println(val)
}
If you use range over a channel, the sender must close it.
3. Not using directional channels in function signatures.
// Bad — function could accidentally send AND receive
func process(ch chan int) { ... }
// Good — clearly this function only receives
func process(ch <-chan int) { ... }
Directional channels prevent bugs at compile time.
Source Code
You can find the complete source code for this tutorial on GitHub:
Related Articles
- Go Tutorial #11: Goroutines — Lightweight concurrency in Go
- Go Tutorial #13: Select, Context, and Patterns — Advanced channel patterns
What’s Next?
In the next tutorial, Go Tutorial #13: Select, Context, and Concurrency Patterns, you will learn:
- The
selectstatement for multiplexing channels - Timeouts with
time.After context.Contextfor cancellation and deadlines- Fan-out, fan-in, and worker pool patterns
This is part 12 of the Go Tutorial series. Follow along to learn Go from scratch.