Channels are Go's primary mechanism for communication between goroutines. They enable safe, synchronized data exchange and are fundamental to Go's concurrency model.
What are channels?
Channels are typed conduits that allow goroutines to send and receive values. They provide a safe way to share data between goroutines without explicit locks.
Channels embody Go's philosophy: "Don't communicate by sharing memory; share memory by communicating." This approach eliminates race conditions because only one goroutine accesses data at a time. It makes data flow explicit—it's clear which goroutine sends and which receives. Channels naturally synchronize goroutines, coordinating their execution, and prevent common bugs by eliminating the need to remember to lock and unlock mutexes.
Channels work like pipes: one goroutine sends data into the channel, another receives it. The channel ensures synchronization by automatically coordinating sender and receiver. Type safety means only values of the channel's type can be sent, and when used correctly, channels provide safety with no data races.
Creating channels
// Channel syntax: make(chan Type)
ch := make(chan int) // Unbuffered channel of int
ch2 := make(chan string) // Unbuffered channel of string
ch3 := make(chan int, 10) // Buffered channel of int (capacity 10)
Channels are typed for type safety, which prevents errors. You can't accidentally send a string to an int channel:
ch := make(chan int)
ch <- "hello" // Compile error: cannot use "hello" (string) as int
Understanding the difference between unbuffered and buffered channels is crucial.
Unbuffered channels
An unbuffered channel has no capacity. Every send must have a corresponding receive ready, and vice versa.
Unbuffered channels provide synchronous communication where the sender blocks until the receiver is ready. This creates a handshake pattern—like passing a baton, both hands must be ready. The direct transfer from sender to receiver guarantees delivery.
Example
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan string) // Unbuffered channel
// Goroutine sends a message
go func() {
fmt.Println("Sender: Preparing to send...")
time.Sleep(1 * time.Second)
ch <- "Hello" // Blocks until receiver is ready
fmt.Println("Sender: Message sent!")
}()
// Main goroutine receives
fmt.Println("Receiver: Waiting for message...")
msg := <-ch // Blocks until sender is ready
fmt.Println("Receiver: Received:", msg)
}
Output
Receiver: Waiting for message...
Sender: Preparing to send...
Sender: Message sent!
Receiver: Received: Hello
Unbuffered channels are synchronous because the blocking behavior ensures data is always delivered with no lost messages. The sender and receiver are synchronized, so both know exactly when communication happens. There's no buffering overhead since the transfer is direct.
Buffered channels
A buffered channel has a capacity. Sends only block when the buffer is full; receives only block when the buffer is empty.
Buffered channels work asynchronously when the buffer has space—the sender doesn't block if the buffer has room. They follow queue behavior with first in, first out (FIFO) ordering. The sender blocks when the buffer is full, and the receiver blocks when the buffer is empty.
Example
package main
import "fmt"
func main() {
ch := make(chan int, 3) // Buffered channel with capacity 3
// These sends don't block (buffer has space)
ch <- 1
ch <- 2
ch <- 3
fmt.Println("Sent 3 values without blocking")
// This send would block (buffer is full)
// ch <- 4 // Would block here
// Receives don't block (buffer has values)
fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2
fmt.Println(<-ch) // 3
}
Buffered channels are useful because they allow decoupling where producer and consumer can work at different rates temporarily. They improve performance by reducing blocking when bursts of data occur, and enable batching by accumulating multiple values before processing.
When to use each
Example comparing both
package main
import (
"fmt"
"time"
)
// Unbuffered: Tight synchronization
func unbufferedExample() {
ch := make(chan int)
go func() {
for i := 0; i < 3; i++ {
fmt.Printf("Sending %d...\n", i)
ch <- i // Waits for receiver
fmt.Printf("Sent %d\n", i)
}
}()
time.Sleep(500 * time.Millisecond) // Delay receiver
for i := 0; i < 3; i++ {
fmt.Printf("Received %d\n", <-ch)
}
}
// Buffered: Allows some decoupling
func bufferedExample() {
ch := make(chan int, 3) // Buffer of 3
go func() {
for i := 0; i < 3; i++ {
fmt.Printf("Sending %d...\n", i)
ch <- i // Doesn't wait (buffer has space)
fmt.Printf("Sent %d\n", i)
}
}()
time.Sleep(500 * time.Millisecond) // Delay receiver
for i := 0; i < 3; i++ {
fmt.Printf("Received %d\n", <-ch)
}
}
func main() {
fmt.Println("=== Unbuffered ===")
unbufferedExample()
fmt.Println("\n=== Buffered ===")
bufferedExample()
}
Basic operations
ch <- value // Send value to channel
value := <-ch // Receive value from channel
<-ch // Receive and discard value
Sending and receiving work as follows. When sending (ch <- value), unbuffered channels block until a receiver is ready, while buffered channels block only if the buffer is full. The operation transfers ownership of the value to the channel. When receiving (value := <-ch), unbuffered channels block until a sender is ready, while buffered channels block only if the buffer is empty. The receive operation takes ownership of the value from the channel.
Example demonstrating blocking
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan string)
// This goroutine will block on send
go func() {
fmt.Println("Goroutine: About to send...")
ch <- "message"
fmt.Println("Goroutine: Sent (this may not print if main exits)")
}()
fmt.Println("Main: Waiting 2 seconds before receiving...")
time.Sleep(2 * time.Second)
msg := <-ch
fmt.Println("Main: Received:", msg)
}
Two-value receive
You can check if a channel is closed:
value, ok := <-ch
if !ok {
// Channel is closed
}
The two-value receive is useful because when a channel is closed, receives return the zero value immediately. The ok boolean tells you if the value was actually sent or if the channel is closed.
Example
package main
import "fmt"
func main() {
ch := make(chan int, 3)
ch <- 1
ch <- 2
close(ch) // Close channel
// Receive remaining values
val1, ok1 := <-ch
fmt.Printf("Value: %d, OK: %t\n", val1, ok1) // Value: 1, OK: true
val2, ok2 := <-ch
fmt.Printf("Value: %d, OK: %t\n", val2, ok2) // Value: 2, OK: true
val3, ok3 := <-ch
fmt.Printf("Value: %d, OK: %t\n", val3, ok3) // Value: 0, OK: false (closed)
}
Channels can be restricted to send-only or receive-only.
Channel directions exist for type safety: function parameters can specify if a channel is for sending or receiving, preventing misuse. The syntax includes chan T for bidirectional channels (can send and receive), chan<- T for send-only channels, and <-chan T for receive-only channels.
Here's how to use channel directions:
package main
import "fmt"
// sendOnly can only send to the channel
func sender(sendOnly chan<- int) {
sendOnly <- 42
// val := <-sendOnly // Compile error: cannot receive from send-only channel
}
// receiveOnly can only receive from the channel
func receiver(receiveOnly <-chan int) {
val := <-receiveOnly
fmt.Println("Received:", val)
// receiveOnly <- 42 // Compile error: cannot send to receive-only channel
}
func main() {
ch := make(chan int)
go sender(ch) // Pass bidirectional as send-only
receiver(ch) // Pass bidirectional as receive-only
}
Channel directions make function contracts explicit. A send-only parameter means the function promises to send data, while a receive-only parameter means the function promises to receive data. This prevents errors because the compiler catches misuse.
Common pattern
// Producer function: returns receive-only channel
func producer() <-chan int {
ch := make(chan int)
go func() {
defer close(ch)
for i := 0; i < 5; i++ {
ch <- i
}
}()
return ch
}
// Consumer function: takes receive-only channel
func consumer(input <-chan int) {
for val := range input {
fmt.Println("Consumed:", val)
}
}
Closing a channel signals that no more values will be sent.
Closing channels matters because it signals completion—receivers know when all data has been sent. It prevents deadlocks by allowing waiting receivers to detect closure, and it enables range loops since for range automatically stops when the channel closes.
To close a channel:
close(ch) // Close the channel
Rules for closing
Example
package main
import (
"fmt"
"sync"
)
func sender(ch chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
defer close(ch) // Sender closes the channel
for i := 0; i < 5; i++ {
ch <- i
}
fmt.Println("Sender: Finished sending")
}
func receiver(ch <-chan int, wg *sync.WaitGroup) {
defer wg.Done()
for val := range ch { // Automatically stops when channel closes
fmt.Println("Receiver: Got", val)
}
fmt.Println("Receiver: Channel closed, done")
}
func main() {
ch := make(chan int)
var wg sync.WaitGroup
wg.Add(2)
go sender(ch, &wg)
go receiver(ch, &wg)
wg.Wait()
}
Detecting closed channels
for {
val, ok := <-ch
if !ok {
// Channel is closed
break
}
// Process val
}
You should close channels for several reasons. Range loops require the channel to be closed so for range ch knows when to stop. When there are multiple receivers, all can detect completion. Closing also signals resource cleanup, indicating that a resource is no longer needed.
Common mistake
// ❌ BAD: Receiver closes channel
func badReceiver(ch <-chan int) {
for val := range ch {
fmt.Println(val)
}
close(ch) // ERROR: Cannot close receive-only channel
}
// ✅ GOOD: Sender closes channel
func goodSender(ch chan<- int) {
defer close(ch) // Sender closes
for i := 0; i < 5; i++ {
ch <- i
}
}
The for range loop works with channels, automatically receiving values until the channel is closed.
Range is convenient because it eliminates the need to manually check for closure. Instead of writing:
// Manual approach
for {
val, ok := <-ch
if !ok {
break
}
// Process val
}
You can use the cleaner range approach:
// Range approach (cleaner)
for val := range ch {
// Process val
}
You can use range to iterate over channels:
package main
import (
"fmt"
"time"
)
func producer(ch chan<- int) {
defer close(ch) // Must close for range to work
for i := 0; i < 5; i++ {
ch <- i
time.Sleep(100 * time.Millisecond)
}
}
func main() {
ch := make(chan int)
go producer(ch)
// Range automatically receives until channel closes
for val := range ch {
fmt.Println("Received:", val)
}
fmt.Println("Channel closed, range loop exited")
}
The range loop requires closing because it needs to know when to stop. Without closing the channel, the loop would block forever waiting for more values.
Multiple receivers with range
package main
import (
"fmt"
"sync"
)
func producer(ch chan<- int) {
defer close(ch)
for i := 0; i < 10; i++ {
ch <- i
}
}
func consumer(id int, ch <-chan int, wg *sync.WaitGroup) {
defer wg.Done()
for val := range ch {
fmt.Printf("Consumer %d: %d\n", id, val)
}
fmt.Printf("Consumer %d: Done\n", id)
}
func main() {
ch := make(chan int)
var wg sync.WaitGroup
// One producer
go producer(ch)
// Multiple consumers
for i := 0; i < 3; i++ {
wg.Add(1)
go consumer(i, ch, &wg)
}
wg.Wait()
}
Note With multiple receivers, each value is received by only one receiver (channels distribute values, not broadcast them).
Channels are the foundation of Go's concurrency model. Understanding how they work—why they block, when to use buffered vs unbuffered, and how to properly close them—is essential for writing correct concurrent Go programs.