Go · lesson 15 of 25
Channels in Go
Go channels run for real: unbuffered handoff, deadlocks, buffers, close and range, directional types, select with default and timeouts, and nil channels.
By SkillByExample · Updated
A channel is a typed conduit between goroutines: one sends with ch <- v, another receives with <-ch. Channels both carry data and synchronise — a receive can’t complete before the matching send. Every example here prints a fixed result: each was run repeatedly and with GOMAXPROCS=1 to confirm the output never changes.
Unbuffered channels: a handoff
On an unbuffered channel, a send waits until a receiver takes the value.
package main
import "fmt"
func main() {
ch := make(chan string)
go func() {
ch <- "ping" // blocks until main receives
}()
msg := <-ch // blocks until the goroutine sends
fmt.Println(msg)
}
$ go run unbuffered.go
ping
No WaitGroup was needed: the receive itself waits for the goroutine. Send in main with nobody to receive, though, and the program can never progress:
package main
import "fmt"
func main() {
ch := make(chan int)
ch <- 1 // no receiver will ever arrive
fmt.Println(<-ch)
}
$ go run deadlock.go
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main.main()
/home/user/gobyex/channels/deadlock/deadlock.go:7 +0x36
exit status 2
The runtime noticed that every goroutine (here just main) was blocked and stopped the program instead of hanging. It only detects the case where all goroutines are stuck; one blocked goroutine among running ones is a silent leak.
Buffered channels
make(chan T, n) gives the channel room for n values; sends block only when it is full.
package main
import "fmt"
func main() {
ch := make(chan int, 3)
ch <- 1
ch <- 2
fmt.Println("len", len(ch), "cap", cap(ch))
ch <- 3
fmt.Println(<-ch, <-ch, <-ch)
ch <- 4
ch <- 5
ch <- 6
ch <- 7 // fourth send into a 3-slot buffer
}
$ go run buffered.go
len 2 cap 3
1 2 3
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main.main()
/home/user/gobyex/channels/buffered/buffered.go:16 +0x25f
exit status 2
Values come out in the order they went in. The first three sends fit without any receiver; the fourth found the buffer full and nothing to drain it, which is the same deadlock as before. A buffer decouples sender and receiver by a bounded amount — it doesn’t remove the need for a receiver.
close, range, and the comma-ok receive
The sender closes a channel to say “no more values”. for range over a channel stops at the close.
package main
import "fmt"
func produce(n int) <-chan int {
ch := make(chan int)
go func() {
defer close(ch)
for i := 1; i <= n; i++ {
ch <- i * i
}
}()
return ch
}
func main() {
for v := range produce(4) {
fmt.Print(v, " ")
}
fmt.Println()
ch := make(chan int, 1)
ch <- 42
close(ch)
v, ok := <-ch
fmt.Println(v, ok)
v, ok = <-ch
fmt.Println(v, ok)
}
$ go run closerange.go
1 4 9 16
42 true
0 false
Values already buffered are still delivered after close (42 true). After that, every receive returns the zero value immediately with ok == false — which is how range knows to stop. Without the close in produce, the range loop would wait forever after the last value.
Closing is a one-way signal, and the runtime enforces it:
package main
func main() {
ch := make(chan int, 1)
close(ch)
ch <- 1
}
$ go run sendclosed.go
panic: send on closed channel
goroutine 1 [running]:
main.main()
/home/user/gobyex/channels/sendclosed/sendclosed.go:6 +0x3a
exit status 2
Sending on a closed channel panics, as does closing one twice. That’s why only the sender closes: a receiver can’t know whether more sends are coming.
Directional channel types
A parameter of type chan<- T can only send; <-chan T can only receive. The compiler enforces the direction.
package main
import "fmt"
func producer(out chan<- int) {
for i := range 3 {
out <- i
}
close(out)
}
func consumer(in <-chan int) {
for v := range in {
fmt.Print(v, " ")
}
in <- 99
}
func main() {
ch := make(chan int)
go producer(ch)
consumer(ch)
}
$ go run direction.go
# command-line-arguments
./direction.go:16:2: invalid operation: cannot send to receive-only channel in (variable of type <-chan int)
A bidirectional chan int converts implicitly to either direction when passed, so main can hand the same channel to both functions. The mistaken send inside consumer is caught at compile time.
select
select waits on several channel operations and runs whichever is ready first. With a default case it doesn’t wait at all.
package main
import "fmt"
func main() {
jobs := make(chan string, 1)
select {
case j := <-jobs:
fmt.Println("got", j)
default:
fmt.Println("no job waiting")
}
jobs <- "build"
select {
case j := <-jobs:
fmt.Println("got", j)
default:
fmt.Println("no job waiting")
}
for _, j := range []string{"test", "deploy"} {
select {
case jobs <- j:
fmt.Println("queued", j)
default:
fmt.Println("queue full, dropping", j)
}
}
}
$ go run selectdefault.go
no job waiting
got build
queued test
queue full, dropping deploy
select with default is a non-blocking send or receive: try it, and fall through if the channel isn’t ready. The loop shows a common use: test fits in the one-slot buffer, and deploy is dropped rather than blocking because nobody is draining the queue — the usual way to shed load or skip a metric when a consumer falls behind. If several cases are ready at once, select picks one at random, so don’t rely on case order for priority.
Timeouts
time.After(d) returns a channel that receives once after d, so it can race a slow operation in a select.
package main
import (
"fmt"
"time"
)
func fetch(fast bool) <-chan string {
ch := make(chan string, 1) // buffered: the sender never blocks
if fast {
ch <- "cached result"
}
// a slow fetch would send later; here it never does
return ch
}
func main() {
for _, fast := range []bool{true, false} {
select {
case r := <-fetch(fast):
fmt.Println("result:", r)
case <-time.After(50 * time.Millisecond):
fmt.Println("timed out after 50ms")
}
}
}
$ go run timeout.go
result: cached result
timed out after 50ms
The fast fetch has its value ready before select starts, so the timer never matters; the slow one never delivers, so the timer wins. Making the result channel buffered matters in real code: if the work finishes after the timeout, its send must not block forever on a channel nobody reads. In larger programs, pass a context.Context instead of separate timers — see the concurrency patterns lesson.
Cancellation with a done channel
Closing a channel wakes every receiver at once, which makes it a broadcast “stop” signal.
package main
import "fmt"
func numbers(done <-chan struct{}, stopped chan<- struct{}) <-chan int {
out := make(chan int)
go func() {
defer close(stopped)
for i := 0; ; i++ {
select {
case out <- i:
case <-done:
fmt.Println("generator: stopping")
return
}
}
}()
return out
}
func main() {
done := make(chan struct{})
stopped := make(chan struct{})
nums := numbers(done, stopped)
for range 5 {
fmt.Print(<-nums, " ")
}
fmt.Println()
close(done)
<-stopped // wait until the goroutine has really exited
fmt.Println("main: generator gone")
}
$ go run done.go
0 1 2 3 4
generator: stopping
main: generator gone
The generator would run forever; closing done makes its select choose the <-done case on the next iteration. Without it, the goroutine would stay blocked on out <- i for the life of the program — a goroutine leak. main waits on stopped so the printed order is guaranteed rather than a matter of timing. context.Context packages this same pattern.
Fan-in and nil channels
Merging several channels into one is a select loop. Setting a closed channel’s variable to nil disables its case, because operations on a nil channel block forever.
package main
import (
"fmt"
"slices"
)
func emit(vals ...string) <-chan string {
ch := make(chan string)
go func() {
defer close(ch)
for _, v := range vals {
ch <- v
}
}()
return ch
}
func main() {
a, b := emit("a1", "a2", "a3"), emit("b1", "b2")
var got []string
for a != nil || b != nil {
select {
case v, ok := <-a:
if !ok {
a = nil // a is finished: never select it again
continue
}
got = append(got, v)
case v, ok := <-b:
if !ok {
b = nil
continue
}
got = append(got, v)
}
}
fmt.Println(len(got), "values")
slices.Sort(got)
fmt.Println(got)
}
$ go run fanin.go
5 values
[a1 a2 a3 b1 b2]
The values arrive interleaved in an order that changes between runs, so the program sorts them before printing. Without the nil assignments, a closed channel would be ready on every iteration (returning "", false) and the loop would spin; with them, the loop ends once both inputs are drained.
Practice
1. Write a pipeline: gen(nums ...int) <-chan int → square(in <-chan int) <-chan int → print the results in main.
Solution
package main
import "fmt"
func gen(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
out <- n
}
}()
return out
}
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
out <- n * n
}
}()
return out
}
func main() {
for v := range square(gen(2, 3, 4)) {
fmt.Print(v, " ")
}
fmt.Println()
}
$ go run p1.go
4 9 16
Each stage closes its output when its input is exhausted, so the close propagates down the pipeline and ends main’s range. With one goroutine per stage, order is preserved.
2. Use a buffered channel of capacity 2 as a semaphore so that at most two of five goroutines are “working” at once, and record the highest concurrency seen.
Solution
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
sem := make(chan struct{}, 2)
var active, peak atomic.Int32
var wg sync.WaitGroup
for range 5 {
wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{} // acquire: blocks while 2 are held
n := active.Add(1)
for {
p := peak.Load()
if n <= p || peak.CompareAndSwap(p, n) {
break
}
}
active.Add(-1)
<-sem // release
}()
}
wg.Wait()
fmt.Println("peak <= 2:", peak.Load() <= 2)
}
$ go run -race p2.go
peak <= 2: true
The buffer’s capacity is the limit: a third send blocks until someone receives. The exact peak (1 or 2) depends on timing, so the program prints the property that must always hold.
3. Make fetch(false) in timeout.go send its result after 10ms from a goroutine, and give the select a 1-second timeout. What prints?
Solution
package main
import (
"fmt"
"time"
)
func fetch() <-chan string {
ch := make(chan string, 1)
go func() {
time.Sleep(10 * time.Millisecond) // simulated slow work
ch <- "slow result"
}()
return ch
}
func main() {
select {
case r := <-fetch():
fmt.Println("result:", r)
case <-time.After(time.Second):
fmt.Println("timed out")
}
}
$ go run p3.go
result: slow result
The work finishes well inside the timeout. The Sleep here simulates latency in the producer; the output doesn’t depend on it, because select waits on whichever channel delivers.
4. What does this print? ch := make(chan int, 2); ch <- 1; close(ch); fmt.Println(<-ch, <-ch, <-ch) — then check.
Solution
package main
import "fmt"
func main() {
ch := make(chan int, 2)
ch <- 1
close(ch)
fmt.Println(<-ch, <-ch, <-ch)
}
$ go run p4.go
1 0 0
The buffered 1 is still delivered after the close; the next two receives get the zero value straight away. No deadlock, no panic.