Buffered Channels

A second argument to make gives a channel room to hold values, so a send can finish before anyone receives.

Syntaxch := make(chan T, capacity) // len(ch) is values waiting, cap(ch) is capacity

An unbuffered channel makes a sender wait for a receiver. Sometimes that is not what you want. A second argument to make gives the channel a buffer: room to hold values nobody has taken yet.

jobs := make(chan string, 3) holds three strings. The first three sends finish straight away. The fourth waits until something is received.

Two built-in functions report on it. len(jobs) is how many values are waiting right now. cap(jobs) is the buffer size, and it never changes. For an unbuffered channel both are 0, which is why make(chan int) and make(chan int, 0) are the same thing.

When a buffer earns its place

Use one when the number of values is known and small, so a sender never blocks: a results channel sized to the number of jobs. Use one as a permit holder, where the capacity is a limit on how many goroutines may do something at once. Use one to absorb a short burst when the receiver is usually fast enough.

When it hides a problem

A buffer added because "the sends were blocking" usually only delays the blocking. If the receiver is permanently slower than the sender, the buffer fills and the sender waits anyway, just later and with more values held in memory. Picking a capacity by guesswork is a sign the real fix is somewhere else.

A buffer also weakens what a finished send tells you. Unbuffered, a completed send means the value was received. Buffered, it only means the value was accepted.

Example

Example · go
package main

import "fmt"

func main() {
	// Capacity 3: three sends can complete with nobody receiving yet.
	jobs := make(chan string, 3)

	jobs <- "resize"
	jobs <- "thumbnail"
	jobs <- "upload"

	// len is how many values are sitting in the buffer. cap is its size.
	fmt.Println("waiting:", len(jobs), "of", cap(jobs))

	// A fourth send here would block forever: the buffer is full and no
	// other goroutine is receiving.

	for i := 0; i < 3; i++ {
		fmt.Println("doing", <-jobs)
	}

	fmt.Println("waiting:", len(jobs), "of", cap(jobs))
}

// Output:
// waiting: 3 of 3
// doing resize
// doing thumbnail
// doing upload
// waiting: 0 of 3

When to use it

  • Size a results channel to the job count so no worker ever blocks reporting.
  • Limit outbound HTTP calls to five at a time using a capacity-5 permit channel.
  • Absorb a burst of log lines without blocking the request handler that produced them.
  • Report len(ch) as a queue-depth metric to see when a consumer is falling behind.

More examples

Capacity as a limit on concurrent work

The strongest reason to buffer a channel. <code>permits</code> carries <code>struct{}{}</code>, a value with no contents, because only the count matters. Sending takes a permit and receiving returns one, so at most two goroutines are past that line at a time. Reach for this when the limit is on something external: five concurrent calls to an API, ten open files, one request per database connection. The <code>defer</code> matters, because a permit that is never handed back is a permit lost for the life of the program. Note that the <code>i</code> captured by each goroutine is that iteration's own copy, which is how loop variables have worked since Go 1.22.

Example · go
package main

import (
	"fmt"
	"slices"
	"sync"
)

func main() {
	// A buffered channel used as a permit holder. Capacity 2 means at most
	// two of these goroutines are inside the guarded part at any moment.
	permits := make(chan struct{}, 2)

	// Capacity 5 means no goroutine ever waits to report its answer.
	out := make(chan int, 5)

	var wg sync.WaitGroup
	for i := 1; i <= 5; i++ {
		wg.Go(func() {
			permits <- struct{}{}        // take a permit, waiting if both are out
			defer func() { <-permits }() // hand it back on the way out
			out <- i * 10
		})
	}
	wg.Wait()

	got := make([]int, 0, 5)
	for i := 0; i < 5; i++ {
		got = append(got, <-out)
	}
	slices.Sort(got) // the finishing order is not fixed
	fmt.Println("results:", got, "permits still out:", len(permits))
}

// Output:
// results: [10 20 30 40 50] permits still out: 0

What a full buffer with no receiver does

A broken program, shown on purpose. A buffer does not remove blocking, it postpones it by exactly <code>cap</code> sends. Here the third send has nowhere to go and no receiver is coming, so the program stops with a fatal error rather than a normal panic you could recover from. This is the exact failure people hit after enlarging a buffer to "fix" a hang: the hang comes back at a larger input size. The lesson is that the receiver, not the capacity, is what keeps a channel moving.

Example · go
package main

import "fmt"

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

	ch <- 1
	ch <- 2
	fmt.Println("buffer full:", len(ch), "of", cap(ch))

	// Nothing in this program receives, so the buffer never drains. This
	// send waits for room that will never appear. The runtime notices that
	// every goroutine is stuck and stops the program:
	//
	//   fatal error: all goroutines are asleep - deadlock!
	ch <- 3

	fmt.Println("never reached")
}

// Output (on standard output, before the crash):
// buffer full: 2 of 2

A buffer between two pipeline stages

Two stages joined by channels, with a small buffer on each so a brief pause in one stage does not immediately stall the other. The useful habit here is reading <code>len</code> on the channel. In a running service, reporting that number as a metric tells you which stage is the bottleneck: a queue that sits at <code>cap</code> means the consumer is too slow, and a queue that sits near 0 means the producer is. Guessing capacities without that number is how buffers end up hiding problems.

Example · go
package main

import "fmt"

// double reads count numbers, doubles each one, and passes it on. The small
// buffer on out lets this stage keep working if the next stage pauses.
func double(in <-chan int, out chan<- int, count int) {
	for i := 0; i < count; i++ {
		v := <-in
		out <- v * 2
	}
}

func main() {
	in := make(chan int, 4)
	out := make(chan int, 4)

	for i := 1; i <= 4; i++ {
		in <- i
	}

	double(in, out, 4)

	// len on a channel is a useful gauge in a real service. If it sits at
	// cap, the consumer is the bottleneck, and the buffer is only delaying
	// the moment you find that out.
	fmt.Println("backlog:", len(out), "of", cap(out))

	doubled := make([]int, 0, 4)
	for i := 0; i < 4; i++ {
		doubled = append(doubled, <-out)
	}
	fmt.Println("doubled:", doubled)
}

// Output:
// backlog: 4 of 4
// doubled: [2 4 6 8]

Discussion

  • Be the first to comment on this lesson.