Skip to content
gohacks.dev
Concurrency

Channels in Go From Unbuffered to Buffered and Back Again

A full guide to Go channels: send and receive, blocking, buffered and unbuffered behavior, close and range, directions, nil channels, ownership and costs.

12 min read
Summarize in AI
Flat illustration of a buffered channel between a sender and a receiver.

Channels are the part of Go that gets drawn on slides. They are how goroutines hand values to each other, and they are the origin of the slogan "do not communicate by sharing memory; share memory by communicating". They are also, in practice, a tool with sharp edges: a send that never completes, a receive that returns a zero value you mistook for data, a close that happens twice. The rules are few, and each one explains a bug someone has shipped.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This guide builds the picture from the first send to the costs, with a runnable example for every rule. It follows goroutines explained and leads to buffered versus unbuffered channels, select and closing channels. Everything ran on Go 1.25.6.

Making, sending and receiving

A channel has an element type and is created with make. The operator <- sends and receives, with the arrow pointing the way the value moves:

main.goGo
package main

import "fmt"

func main() {
	ch := make(chan string)

	go func() {
		ch <- "hello from a goroutine" // send
	}()

	msg := <-ch // receive; waits until a value arrives
	fmt.Println(msg)
}
Text
hello from a goroutine

The main goroutine reaches <-ch and waits, because nothing has been sent yet. The other goroutine sends, the value is handed over directly, and both continue. No sleep, no flag, no lock: the channel is the synchronisation. If the goroutine had never sent, main would wait forever and the runtime would report a deadlock (the deadlock error).

Unbuffered: a handshake

A channel made with make(chan T) has no buffer. A send cannot complete until a receiver takes the value, and a receive cannot complete until a sender provides one. The two goroutines meet:

main.goGo
package main

import (
	"fmt"
	"time"
)

func main() {
	ch := make(chan int)

	go func() {
		for i := 1; i <= 3; i++ {
			fmt.Println("sending", i)
			ch <- i
			fmt.Println("sent", i)
		}
		close(ch)
	}()

	for v := range ch {
		time.Sleep(20 * time.Millisecond) // a slow consumer
		fmt.Println("received", v)
	}
}
Text
sending 1
sent 1
sending 2
received 1
sent 2
sending 3
received 2
sent 3
received 3

The sender cannot get ahead. It prints sending 1, blocks until the consumer takes it, and sent 1 appears only once the receive has happened. The timing of the interleaved lines varies slightly, which is why the ordering of "sent" against "received" is not guaranteed even though the handoff is. What is guaranteed is the happens-before relationship: everything the sender did before the send is visible to the receiver after the receive (the memory model). That makes an unbuffered channel a clean way to pass ownership of a value from one goroutine to another.

Buffered: a queue of fixed size

make(chan T, n) adds a buffer of n elements. A send completes immediately while there is room, and blocks only when the buffer is full. A receive takes from the front and blocks only when it is empty.

main.goGo
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
	select {
	case ch <- 4:
		fmt.Println("sent 4")
	default:
		fmt.Println("buffer full: a fourth send would block")
	}

	fmt.Println(<-ch, <-ch, <-ch)
}
Text
len: 2 cap: 3
buffer full: a fourth send would block
1 2 3

The three sends do not block, len reports the queued elements and cap the buffer size, and the fourth send would block, which the select with a default shows without hanging. Values come out in the order they went in. A buffer decouples the two sides by a fixed amount: a producer can run n items ahead of a slow consumer. It does not make the pipeline faster in steady state, because throughput is set by the slower side, and it can hide a deadlock for as long as the buffer has room. Choosing between them covers the decision, and the short version is to start unbuffered and add a buffer for a reason you can state.

Closing and ranging

Closing a channel says that no more values will be sent. Receivers can still drain what is buffered, then every receive returns the zero value at once, with a second result that tells you the channel is closed:

main.goGo
package main

import "fmt"

func main() {
	ch := make(chan int, 2)
	ch <- 10
	ch <- 20
	close(ch)

	v, ok := <-ch
	fmt.Println(v, ok)
	v, ok = <-ch
	fmt.Println(v, ok)
	v, ok = <-ch // closed and drained
	fmt.Println(v, ok)

	data := make(chan string)
	go func() {
		defer close(data)
		for _, w := range []string{"a", "b", "c"} {
			data <- w
		}
	}()
	for w := range data { // ends when data is closed
		fmt.Print(w, " ")
	}
	fmt.Println()
}
Text
10 true
20 true
0 false
a b c

The first two receives return the buffered values with ok == true. The third returns 0 false: the zero value, and a signal that it did not come from a send. A for range over a channel does the same check for you and ends when the channel is closed and empty, which is the usual way to consume a stream. The sender does the closing, with defer close(data) so it happens on every path out of the goroutine. A receiver must not close, because the sender may still be sending, and a send on a closed channel panics (send on closed channel and who may close). Closing is optional: a channel with no more users is garbage collected like anything else, and you close only to tell receivers to stop.

Directions in types

A function that only sends or only receives can say so, and the compiler enforces it:

main.goGo
package main

import "fmt"

func produce(out chan<- int) { // can only send
	for i := 1; i <= 3; i++ {
		out <- i
	}
	close(out)
}

func consume(in <-chan int) int { // can only receive
	sum := 0
	for v := range in {
		sum += v
	}
	return sum
}

func main() {
	ch := make(chan int)
	go produce(ch)
	fmt.Println(consume(ch))
}
Text
6

chan<- int is send-only and <-chan int is receive-only. A bidirectional channel converts to either implicitly, so main passes ch to both. The payoff is that consume cannot close the channel, which is not its job, and trying to would be a compile error. Directions in signatures are the cheapest documentation of ownership that Go offers (goroutine ownership). A function that returns a channel should return <-chan T when the caller should only read it.

Nil channels

The zero value of a channel is nil, and a nil channel blocks forever on both send and receive. That sounds like a bug generator, and it is also a feature: inside a select, a case on a nil channel is never chosen, so setting a channel variable to nil switches that case off.

main.goGo
package main

import "fmt"

func main() {
	a := make(chan int)
	b := make(chan int)
	go func() { a <- 1; close(a) }()
	go func() { b <- 2; close(b) }()

	for a != nil || b != nil {
		select {
		case v, ok := <-a:
			if !ok {
				a = nil // stop selecting on a
				continue
			}
			fmt.Println("a:", v)
		case v, ok := <-b:
			if !ok {
				b = nil
				continue
			}
			fmt.Println("b:", v)
		}
	}
	fmt.Println("both done")
}
Text
b: 2
a: 1
both done

The loop merges two channels and stops cleanly when both have closed, because a closed channel would otherwise return zero values forever and spin the loop. Nilling it removes it from the select (nil channels in select explores the pattern). It is also the reason to initialise channels before use: a forgotten make produces a goroutine that hangs without any error.

Signalling with struct{}

When the channel carries a signal and not data, use chan struct{}. The empty struct takes no memory, and the type says "there is no payload". Closing the channel broadcasts to every receiver at once, which a send cannot do:

main.goGo
package main

import (
	"fmt"
	"sync"
)

func main() {
	start := make(chan struct{})
	var wg sync.WaitGroup
	var mu sync.Mutex
	order := 0

	for i := range 3 {
		wg.Go(func() {
			<-start // all three wait here
			mu.Lock()
			order++
			mu.Unlock()
			_ = i
		})
	}

	close(start) // releases all of them
	wg.Wait()
	fmt.Println("released:", order)
}
Text
released: 3

One close wakes every waiter. This is the base of the done-channel pattern and of how context.Done() works (the done channel, context).

What a channel costs

Channels are not free, and a comparison puts the cost in proportion. This benchmark measures one handoff between two goroutines through an unbuffered channel, through a buffered one, and, for scale, an uncontended mutex lock and unlock:

chan_test.goGo
package main

import (
	"sync"
	"testing"
)

func BenchmarkUnbufferedHandoff(b *testing.B) {
	ch := make(chan int)
	done := make(chan struct{})
	go func() {
		for range ch {
		}
		close(done)
	}()
	for i := 0; i < b.N; i++ {
		ch <- i
	}
	close(ch)
	<-done
}

func BenchmarkBufferedHandoff(b *testing.B) {
	ch := make(chan int, 128)
	done := make(chan struct{})
	go func() {
		for range ch {
		}
		close(done)
	}()
	for i := 0; i < b.N; i++ {
		ch <- i
	}
	close(ch)
	<-done
}

func BenchmarkMutexLockUnlock(b *testing.B) {
	var mu sync.Mutex
	n := 0
	for i := 0; i < b.N; i++ {
		mu.Lock()
		n++
		mu.Unlock()
	}
	_ = n
}
Text
goos: darwin
goarch: arm64
pkg: example.test/lab
cpu: Apple M1 Max
BenchmarkUnbufferedHandoff-10    	 7688710	       138.2 ns/op	       0 B/op	       0 allocs/op
BenchmarkUnbufferedHandoff-10    	 8051131	       135.9 ns/op	       0 B/op	       0 allocs/op
BenchmarkUnbufferedHandoff-10    	 8902704	       135.4 ns/op	       0 B/op	       0 allocs/op
BenchmarkBufferedHandoff-10      	37044562	        32.37 ns/op	       0 B/op	       0 allocs/op
BenchmarkBufferedHandoff-10      	36946238	        32.65 ns/op	       0 B/op	       0 allocs/op
BenchmarkBufferedHandoff-10      	36712309	        32.00 ns/op	       0 B/op	       0 allocs/op
BenchmarkMutexLockUnlock-10      	86835913	        13.65 ns/op	       0 B/op	       0 allocs/op
BenchmarkMutexLockUnlock-10      	89807197	        13.53 ns/op	       0 B/op	       0 allocs/op
BenchmarkMutexLockUnlock-10      	88523594	        13.57 ns/op	       0 B/op	       0 allocs/op
PASS
ok  	example.test/lab	11.377s

On an Apple M1 Max the medians were about 136 ns for an unbuffered handoff, 32 ns for a buffered one and 13.6 ns for an uncontended mutex lock and unlock, with no allocations in any of them. The unbuffered case is the slowest because each handoff involves waking the other goroutine, a buffered handoff is cheaper per item since the sender rarely blocks, and the mutex is cheaper again. The lesson is not "avoid channels"; at roughly a hundred nanoseconds, a channel is invisible next to a network call or a disk read. It is "don't put a channel in the inner loop of a hot computation", and "use a mutex or an atomic for a counter, a channel for handing over work" (channels or mutexes).

A request and its reply

A channel can carry another channel. The pattern turns a goroutine into a small server: callers send a request that includes a channel for the answer, and the server replies on it. It is the basis of the actor style (actor-style concurrency).

main.goGo
package main

import "fmt"

type request struct {
	n     int
	reply chan int
}

func server(reqs <-chan request) {
	for r := range reqs {
		r.reply <- r.n * r.n
	}
}

func main() {
	reqs := make(chan request)
	go server(reqs)

	for _, n := range []int{3, 4, 5} {
		reply := make(chan int, 1)
		reqs <- request{n, reply}
		fmt.Println(n, "squared is", <-reply)
	}
	close(reqs)
}
Text
3 squared is 9
4 squared is 16
5 squared is 25

Each request carries its own reply channel with capacity 1, so the server's send never blocks even if the caller has stopped listening. That detail is the difference between a server that survives a client walking away and one that hangs. The server goroutine owns whatever state it keeps, and nothing else touches it, so there is no lock.

How a channel works inside

Knowing the structure explains the costs and the rules. The runtime represents a channel as a small struct holding a lock, a circular buffer for buffered channels, and two queues of waiting goroutines, one of senders and one of receivers. A send on a channel that has a waiting receiver hands the value straight to that goroutine and wakes it, with no trip through the buffer. A send with no receiver and a full (or absent) buffer parks the sender in the send queue, and a later receive takes the value from it and wakes it. close wakes every waiter. That is why an unbuffered handoff costs a goroutine wake-up, and why a buffered send that finds room is cheap: it copies into the ring and returns (the channel implementation walks through the source). Because the struct has a lock, a channel shared by many senders and receivers contends like a mutex does, and a hot channel can become a bottleneck that sharding or batching fixes.

Testing code that uses channels

Tests of channel code have two ways to go wrong: hanging forever, and passing by luck. Defend against both. Give every test a timeout, either with go test -timeout or with a select on time.After around a receive, so a missing send fails the test and does not hang the suite. Check that goroutines exit: close the input, wait for the output to close, and assert the count of goroutines afterwards (testing concurrent code). Run with -race, which finds the unsynchronised access that a channel was supposed to prevent. And avoid sleeps as synchronisation: wait for a value, not for time.

Channels in the standard library

You meet channels in several standard packages, and they follow the rules above. time.After and time.Ticker deliver on a channel (tickers). os/signal.Notify sends signals to a channel you provide, which should be buffered so the sender never blocks (signals). context.Done() returns a channel that is closed on cancellation, the broadcast pattern from earlier. Reading these as ordinary channels makes them easy to combine in a select with your own.

Choosing channels deliberately

It helps to have a rule for when a channel is the right tool. Reach for one when the main event is a value changing hands: work items going to workers, results coming back, events being delivered, a signal that something finished. The channel gives you ordering, a place for backpressure and a clean way to end (close). Reach for a mutex when many goroutines read and update one shared structure, such as a cache or a counter, where there is no handoff to model. Reach for sync.WaitGroup or errgroup when the question is only "have they all finished". And reach for nothing when the work is sequential. Code that wraps every shared variable in a goroutine and a pair of channels, a guard goroutine per map, is usually a mutex written the hard way; the cost is a goroutine, two channels and a lot of code, for what sync.Mutex does in two lines.

The converse also holds. A mutex-protected queue that consumers poll in a loop with a sleep is a channel written badly, with latency and wasted wake-ups that a channel would avoid. The honest test is to describe what the code does in a sentence. If the sentence contains "passes", "hands over", "delivers" or "notifies", a channel probably fits. If it contains "protects", "updates" or "reads and writes", a lock probably does.

Rules to remember

  1. A send and a receive on an unbuffered channel complete together; on a buffered one, they complete when there is room or a value.
  2. Only the sender closes, once, normally with defer.
  3. A receive from a closed channel never blocks; use v, ok or range to tell data from closure.
  4. A send on a closed channel panics; a close of a closed or nil channel panics.
  5. A nil channel blocks forever and disables its select case.
  6. Give functions directional types.
  7. Size buffers deliberately, and know what happens when they fill.

Common mistakes

Forgetting to close, so a range loop never ends and a goroutine leaks (goroutine leaks). Closing from the receiver, which races the sender into a panic. Using a buffered channel to paper over a deadlock, which defers the failure to production load. Treating the zero value from a closed channel as data. Sending to a channel nobody reads after an early return, the blocked sender from returning errors from goroutines.

Where to go next

Channels combine through select, covered next, and with context for cancellation. The patterns built from them, worker pools, pipelines and fan-out, are in worker pools and pipelines. For the language definition see the specification's sections on channel types and send statements, and for the original treatment read the Go blog's Share Memory By Communicating.

Taggedchannels
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.