Skip to main content
Go · lesson 15 of 25
Go intermediate 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.

Frequently Asked Questions

What is the difference between a buffered and unbuffered channel?
An unbuffered channel has no storage: a send blocks until another goroutine receives, so the two meet at the handoff. A buffered channel, made with make(chan T, n), lets up to n sends complete without a receiver and only blocks the sender when the buffer is full.
Who should close a Go channel?
The sender, and only when receivers need to know no more values are coming, for example to end a for range loop. Sending on a closed channel panics, and so does closing a channel twice. Receivers never close, and you do not have to close a channel for it to be garbage collected.
What does 'all goroutines are asleep - deadlock!' mean?
The runtime found that every goroutine is blocked, typically on a channel send or receive that nothing can ever complete, such as sending on an unbuffered channel with no receiver. It aborts the program with this fatal error instead of hanging forever.
What happens when I receive from a closed channel?
Once a closed channel's buffer is drained, every receive returns immediately with the zero value of the element type. The two-value form v, ok := <-ch reports ok == false in that case, which is how you tell a real zero from a closed channel.
Why would I set a channel to nil?
Sending to or receiving from a nil channel blocks forever, so a select case on a nil channel can never be chosen. Setting a channel variable to nil after it is closed disables its case in a select loop while the other cases keep working.