Channels in Go
Use buffered and unbuffered channels, the select statement, done channels for cancellation, and fan-out/fan-in patterns.
Unbuffered Channels
Channels are Go’s primary mechanism for goroutines to communicate safely without shared memory. An unbuffered channel acts as a synchronisation point: the sender blocks until a receiver is ready, and the receiver blocks until a sender is ready. This makes them perfect for coordinating two goroutines that need to hand off a value at an exact moment, guaranteeing the data arrives before either side continues.
ch := make(chan int) // unbuffered — no internal queue
go func() {
ch <- 42 // sender blocks here until the main goroutine receives
}()
val := <-ch // receiver blocks here until the goroutine sends
fmt.Println(val) // 42 — guaranteed to be the value the goroutine sent
Buffered Channels
A buffered channel decouples the sender and receiver by adding an internal queue. The sender can deposit up to N values without waiting for a receiver — it only blocks when the buffer is full. This is useful when the producer naturally outpaces the consumer in short bursts, or when you want to limit concurrency using the channel as a counting semaphore.
ch := make(chan string, 3) // buffer holds up to 3 values
// All three sends complete immediately — no receiver needed yet
ch <- "first"
ch <- "second"
ch <- "third"
// ch <- "fourth" // would block here — buffer is full
fmt.Println(<-ch) // first — values come out in FIFO order
fmt.Println(<-ch) // second
fmt.Println(<-ch) // third
Closing Channels
Closing a channel signals to receivers that no more values will be sent. This is the idiomatic way to broadcast “work is done” to one or many receivers. A range loop over a channel automatically stops when the channel is closed — you don’t need to track the count manually. The rule is simple: only the sender should ever close a channel.
ch := make(chan int)
go func() {
for i := 0; i < 5; i++ {
ch <- i // send values one by one
}
close(ch) // signal: no more values coming — safe because this goroutine is the sole sender
}()
// range receives all values, then exits automatically when the channel is closed
for v := range ch {
fmt.Println(v) // 0, 1, 2, 3, 4
}
// Two-value form lets you distinguish "received a value" from "channel closed"
v, ok := <-ch
fmt.Println(v, ok) // 0 false — channel is closed, v is the zero value
Rules:
- Only the sender should close a channel
- Closing a closed channel panics
- Sending to a closed channel panics
- Receiving from a closed channel returns zero value with
ok = false
Directional Channel Types
Function signatures can restrict a channel to send-only (chan<-) or receive-only (<-chan). This communicates intent clearly and prevents mistakes — a consumer function that accidentally tries to close or send on a channel it should only read from will fail at compile time. It also makes pipelines self-documenting: the types show the data flow direction.
func producer(out chan<- int) { // send-only — this function may only send
for i := 0; i < 5; i++ {
out <- i
}
close(out) // only the sender closes
}
func consumer(in <-chan int) { // receive-only — this function may only receive
for v := range in {
fmt.Println(v)
}
}
func main() {
ch := make(chan int) // bidirectional — converted automatically when passed
go producer(ch)
consumer(ch)
}
select Statement
select lets a goroutine wait on multiple channel operations simultaneously and react to whichever one becomes ready first. Without select, you would have to choose which channel to block on — and miss activity on the others. With select, a goroutine can multiplex many channels, implement non-blocking operations, and add timeouts, all in a single readable construct.
ch1 := make(chan string)
ch2 := make(chan string)
go func() { time.Sleep(1 * time.Second); ch1 <- "one" }()
go func() { time.Sleep(2 * time.Second); ch2 <- "two" }()
// select blocks until one of the cases is ready, then executes it
for i := 0; i < 2; i++ {
select {
case msg := <-ch1:
fmt.Println("received from ch1:", msg)
case msg := <-ch2:
fmt.Println("received from ch2:", msg)
}
}
Non-blocking operations with select
Adding a default case makes the select non-blocking — if no channel is ready, default runs immediately. This is useful for polling patterns where you want to check for a message but continue if none is available.
// Non-blocking receive — check for a message without waiting
select {
case msg := <-ch:
fmt.Println("received:", msg)
default:
fmt.Println("no message available right now")
}
// Non-blocking send — drop the value if nobody is listening
select {
case ch <- data:
fmt.Println("sent")
default:
fmt.Println("channel full — message dropped")
}
Timeout with select
Timeouts are one of the most important uses of select. By racing a result channel against time.After, you can bound how long a goroutine waits for an operation. This prevents a slow or unresponsive dependency from hanging your program indefinitely.
func fetchWithTimeout(url string, timeout time.Duration) (string, error) {
result := make(chan string, 1) // buffered so the goroutine never leaks
go func() {
// simulate HTTP request
time.Sleep(200 * time.Millisecond)
result <- "response data"
}()
select {
case data := <-result:
return data, nil // operation completed in time
case <-time.After(timeout):
// timeout fired before result arrived
return "", fmt.Errorf("request to %s timed out after %v", url, timeout)
}
}
Done Channels — Cancellation
A done channel is a lightweight way to broadcast a stop signal to one or more goroutines. Closing the channel (rather than sending a value) is the right approach because a single close wakes up all receivers simultaneously — you don’t need to send one value per goroutine. In production code, context.Context is the standard way to do this, but understanding done channels first makes context cancellation much clearer.
func worker(id int, jobs <-chan int, done <-chan struct{}) {
for {
select {
case job, ok := <-jobs:
if !ok {
return // jobs channel was closed — normal shutdown
}
fmt.Printf("worker %d processing job %d\n", id, job)
case <-done:
// done channel was closed — immediate cancellation
fmt.Printf("worker %d cancelled\n", id)
return
}
}
}
func main() {
jobs := make(chan int, 10)
done := make(chan struct{})
for i := 1; i <= 3; i++ {
go worker(i, jobs, done)
}
for i := 1; i <= 9; i++ {
jobs <- i
}
time.Sleep(100 * time.Millisecond)
close(done) // broadcasts stop signal to all three workers simultaneously
time.Sleep(50 * time.Millisecond)
}
In production code, use context.Context instead of raw done channels — it’s the standard Go pattern.
Fan-Out Pattern
Fan-out distributes work from a single input channel across multiple goroutines. The benefit is parallelism: instead of one goroutine processing items one at a time, N workers process N items concurrently. This pattern is the foundation of worker pools and is effective whenever individual items take non-trivial time to process.
// fanOut starts `workers` goroutines, each reading from `in` and writing results to its own output channel
func fanOut(in <-chan int, workers int) []<-chan int {
channels := make([]<-chan int, workers)
for i := range channels {
out := make(chan int)
channels[i] = out
go func(ch chan<- int) {
for v := range in {
ch <- v * v // each worker squares its received values
}
close(ch) // signal downstream that this worker is done
}(out)
}
return channels
}
Fan-In Pattern
Fan-in is the complement to fan-out: it merges multiple input channels into a single output channel. This lets a single consumer receive results from many concurrent producers without needing to know how many there are. The sync.WaitGroup ensures the output channel is closed only after all input channels have been drained.
// fanIn merges any number of input channels into one output channel
func fanIn(channels ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
// launch one forwarding goroutine per input channel
forward := func(ch <-chan int) {
defer wg.Done()
for v := range ch {
out <- v // forward every value to the single output
}
}
wg.Add(len(channels))
for _, ch := range channels {
go forward(ch)
}
// close the output channel once all forwarders have finished
go func() {
wg.Wait()
close(out)
}()
return out
}
Practical Example — Pipeline
Pipelines chain goroutines together using channels so that each stage processes data as it arrives, without waiting for the previous stage to complete all its work. This creates a streaming processing model: the first item reaches the last stage before the first stage has finished all items. The result is lower latency and better throughput compared to processing everything in one sequential pass.
package main
import (
"fmt"
"strings"
)
// Stage 1: emit words one at a time onto a channel
func generate(words ...string) <-chan string {
out := make(chan string)
go func() {
for _, w := range words {
out <- w
}
close(out) // no more words — signal downstream stages
}()
return out
}
// Stage 2: transform each word to uppercase
func toUpper(in <-chan string) <-chan string {
out := make(chan string)
go func() {
for s := range in {
out <- strings.ToUpper(s)
}
close(out)
}()
return out
}
// Stage 3: append an exclamation mark to each word
func exclaim(in <-chan string) <-chan string {
out := make(chan string)
go func() {
for s := range in {
out <- s + "!"
}
close(out)
}()
return out
}
func main() {
// Connect the pipeline stages — each returns a channel the next stage reads from
words := generate("hello", "world", "go", "channels")
upper := toUpper(words)
result := exclaim(upper)
// Consume the final stage — range exits when exclaim closes its channel
for word := range result {
fmt.Println(word)
}
// HELLO!
// WORLD!
// GO!
// CHANNELS!
}