Channels in Go: Unbuffered, Buffered, close, range and select
Communicate between goroutines with channels, build a concurrent health checker, and implement timeouts with select and context.
On this page
Goroutines run independently; channels let them talk. A channel is a typed pipe: one goroutine sends a value, another receives it. Go's motto summarises the philosophy: "Do not communicate by sharing memory; instead, share memory by communicating."
Sending and receiving
ch := make(chan int) // an unbuffered channel of ints
go func() {
ch <- 10 // send
}()
v := <-ch // receive
fmt.Println(v) // 10The arrow shows the direction data flows: ch <- v sends v into the channel, <-ch takes a value out.
Unbuffered channels synchronise
An unbuffered channel has no storage. A send blocks until a receiver is ready, and a receive blocks until a sender is ready. The handoff is a synchronisation point, like passing a baton:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
ch := make(chan int)
wg.Add(2)
go func() {
defer wg.Done()
fmt.Println("sending...")
ch <- 10 // waits here until the receiver arrives
}()
go func() {
defer wg.Done()
data := <-ch // waits here until a value is sent
fmt.Println("received:", data)
}()
wg.Wait()
fmt.Println("main goroutine done")
}fatal error: all goroutines are asleep - deadlock!.Buffered channels
A buffered channel has capacity. Sends only block when the buffer is full; receives only block when it is empty.
jobs := make(chan int, 3)
jobs <- 1
jobs <- 2 // no receiver needed yet: there is room
fmt.Println(len(jobs), cap(jobs)) // 2 3Buffers smooth out bursts between a fast producer and a slower consumer. They do not fix design problems: if the consumer is permanently slower, a buffer only delays the moment everything blocks.
Closing and ranging
The sender closes a channel to say "no more values". Receivers can then drain it with range, which stops automatically after the last value:
close(jobs)
for j := range jobs {
fmt.Print(j, " ") // 1 2
}
v, ok := <-jobs
fmt.Println(v, ok) // 0 false: closed and emptyThe comma-ok form tells you whether the value is real (ok == true) or the zero value from a closed channel.
Rules: only the sender closes; sending on a closed channel panics; closing twice panics; you do not have to close a channel unless a receiver needs to know there is no more data.
Example: a website health checker
This is the link checker from the repository. Each URL is checked in its own goroutine, and results come back through a channel:
package main
import (
"fmt"
"net/http"
"time"
)
type Result struct {
URL string
Status string
}
func check(client *http.Client, url string, out chan<- Result) {
resp, err := client.Get(url)
if err != nil {
out <- Result{url, "DOWN (" + err.Error() + ")"}
return
}
resp.Body.Close()
out <- Result{url, resp.Status}
}
func main() {
links := []string{"https://go.dev", "https://google.com", "https://github.com"}
client := &http.Client{Timeout: 5 * time.Second}
results := make(chan Result)
for _, link := range links {
go check(client, link, results)
}
for range links { // receive exactly one result per link
r := <-results
fmt.Printf("%-22s %s\n", r.URL, r.Status)
}
}The total time is roughly the slowest single request, not the sum, because all checks run concurrently. The original version also re-checked each link forever every 5 seconds with for l := range ch { go func() { time.Sleep(5 * time.Second); check(l) }() }, which is a neat way to build a simple uptime monitor.
Notice the parameter type chan<- Result. That is a send-only channel: check cannot accidentally receive from it. Directional types (chan<- send-only, <-chan receive-only) document intent and are checked by the compiler.
http.Get uses a client with no timeout, so one slow server can block a goroutine forever.select: waiting on several channels
select waits until one of several channel operations can proceed. Combined with context, it is how you implement timeouts and cancellation:
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
slow := make(chan string)
go func() {
time.Sleep(200 * time.Millisecond)
slow <- "done"
}()
select {
case r := <-slow:
fmt.Println(r)
case <-ctx.Done():
fmt.Println("timeout:", ctx.Err()) // timeout: context deadline exceeded
}slow <- "done" forever after the timeout, which is a leak. Make the channel buffered (make(chan string, 1)) so the late send can complete and the goroutine can exit.A select with a default case never blocks, which lets you try a send or receive without waiting.
Signalling with chan struct{}
When you only need "something happened", use chan struct{}. An empty struct takes zero bytes:
done := make(chan struct{})
go func() {
defer close(done)
// ... work ...
}()
<-done // wait until the worker closes the channelClosing a channel wakes all receivers, so it is a great broadcast signal (for example, "shut down now").
Key takeaways
ch <- v sends, <-ch receives.range or v, ok := <-ch.select plus context gives you timeouts and cancellation; watch for leaked senders.Last updated on