44. Buffered channels

📖 Reading · 10 min
💡 Every code box below is live — edit it and hit Run.

Give make(chan T, n) a capacity and the channel gets a queue. Sends only block when the queue is full; receives only block when it's empty. That small change alters the synchronisation guarantees, so it's worth being deliberate about.

Capacity in action

package main

import "fmt"

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

    ch <- "first"
    ch <- "second"
    fmt.Println("sent two without any receiver — len:", len(ch), "cap:", cap(ch))

    ch <- "third"
    fmt.Println("buffer full — len:", len(ch), "cap:", cap(ch))

    fmt.Println(<-ch)
    fmt.Println("after one receive — len:", len(ch))

    ch <- "fourth"
    fmt.Println(<-ch, <-ch, <-ch)
}

len is how many values are queued; cap is how many fit. On an unbuffered channel both are 0, and every send needs a receiver right now.

A fourth send before any receive would block forever here — the buffer is a queue, not an unlimited mailbox.

Buffered vs unbuffered: the real difference

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup

    unbuf := make(chan int)
    wg.Add(1)
    go func() {
        defer wg.Done()
        fmt.Println("unbuffered: about to send")
        unbuf <- 1
        fmt.Println("unbuffered: send returned (a receiver took it)")
    }()
    fmt.Println("unbuffered: main received", <-unbuf)
    wg.Wait()

    buf := make(chan int, 1)
    fmt.Println("buffered: about to send")
    buf <- 1
    fmt.Println("buffered: send returned immediately, nobody has received yet")
    fmt.Println("buffered: main received", <-buf)
}

The distinction that matters:

  • Unbuffered: a completed send proves a receiver got the value. It's a synchronisation point.
  • Buffered: a completed send proves only that the value is in a queue. The receiver may not exist yet.

So when you need "these two goroutines are now at the same point", use unbuffered. When you need to decouple a producer's speed from a consumer's, use a buffer.

Where buffers genuinely help

A known, finite number of results — sized so no worker ever blocks:

package main

import (
    "fmt"
    "sort"
)

func main() {
    const workers = 4
    results := make(chan int, workers)

    for i := 1; i <= workers; i++ {
        go func(n int) {
            results <- n * n
        }(i)
    }

    var got []int
    for i := 0; i < workers; i++ {
        got = append(got, <-results)
    }
    sort.Ints(got)
    fmt.Println(got)
}

Each worker sends and exits immediately instead of waiting for main to get around to receiving. With an unbuffered channel this still works, but every worker parks until it's read.

A semaphore that limits concurrency — the buffer's capacity is the limit:

package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    sem := make(chan struct{}, 2)

    var wg sync.WaitGroup
    var mu sync.Mutex
    maxConcurrent, current := 0, 0

    for i := 1; i <= 10; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()

            sem <- struct{}{}
            defer func() { <-sem }()

            mu.Lock()
            current++
            if current > maxConcurrent {
                maxConcurrent = current
            }
            mu.Unlock()

            time.Sleep(5 * time.Millisecond) // pretend to do work

            mu.Lock()
            current--
            mu.Unlock()
        }(i)
    }

    wg.Wait()
    fmt.Println("ten jobs ran, peak concurrency:", maxConcurrent)
    fmt.Println("never exceeded the limit:", maxConcurrent <= 2)
}

Ten jobs, but the sem <- struct{}{} at the top blocks whenever two are already inside. struct{} again because the value carries no information — only the slot matters. This is how you cap concurrent database connections or outbound HTTP calls without any extra library.

Choosing a capacity

There is no clever formula. In practice:

  • 0 (unbuffered) — the default. Start here; it gives the strongest guarantees and surfaces deadlocks immediately.
  • exactly N — when you know exactly how many sends will happen and don't want senders to block.
  • a small number — to smooth out bursty producers.

A large buffer is usually a bug in disguise: it hides the fact that your consumer can't keep up, converting a visible block into growing memory and latency. If you find yourself typing make(chan T, 10000), the real problem is somewhere else.

Non-blocking checks

len and cap let you peek, but for a genuine non-blocking send or receive you want select with a default — the next lesson.

package main

import "fmt"

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

    if len(ch) < cap(ch) {
        ch <- 2
        fmt.Println("there was room, sent a second value")
    }

    fmt.Println("queued:", len(ch), "of", cap(ch))
    fmt.Println(<-ch, <-ch)
}

Be careful with that pattern in real concurrent code: between checking len and sending, another goroutine may have filled the buffer. select with default is atomic and correct; a len check is a race.

Closed buffered channels drain first

package main

import "fmt"

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

    for v := range ch {
        fmt.Println("drained", v)
    }

    v, ok := <-ch
    fmt.Println("after draining:", v, ok)
}

Closing doesn't discard queued values — receivers get everything already in the buffer, and only then see the channel as closed. That makes "send everything, close, let the consumer finish at its own pace" a safe shutdown pattern.

Your turn

Use a buffered channel as a semaphore so that ten goroutines run with at most three of them inside the critical section at any moment. Print the total:

55
package main

import (
    "fmt"
    "sync"
)

func main() {
    sem := make(chan struct{}, 3)
    var wg sync.WaitGroup
    var mu sync.Mutex
    total := 0

    for i := 1; i <= 10; i++ {
        wg.Add(1)
        // start a goroutine that acquires sem, adds i to total under mu,
        // then releases sem
    }

    wg.Wait()
    fmt.Println(total)
}
package main

import (
    "fmt"
    "sync"
)

func main() {
    sem := make(chan struct{}, 3)
    var wg sync.WaitGroup
    var mu sync.Mutex
    total := 0

    for i := 1; i <= 10; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()

            sem <- struct{}{}
            defer func() { <-sem }()

            mu.Lock()
            total += n
            mu.Unlock()
        }(i)
    }

    wg.Wait()
    fmt.Println(total)
}

Next: waiting on several channels at once.