Unbuffered and Buffered Channels in Go

How Go channels send and receive values between goroutines, the difference between unbuffered and buffered channels, and when a send blocks.

A channel is a typed pipe that goroutines use to pass values to each other. One goroutine sends a value in, another receives it out. Channels also synchronize: a send or receive can make a goroutine wait until the other side is ready. Knowing exactly when that waiting happens is the key to using channels without deadlocks.

Creating, Sending and Receiving

package main

import "fmt"

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

	go func() {
		messages <- "hello from a goroutine"
	}()

	msg := <-messages
	fmt.Println(msg)
}
  • make(chan string) creates a channel that carries strings.
  • messages <- "..." sends a value. The arrow points into the channel.
  • msg := <-messages receives a value. The arrow points out of the channel.

The program prints hello from a goroutine. The receive in main waits until the goroutine sends, so main cannot exit too early.

Unbuffered Channels: a Handoff

A channel made with make(chan T) is unbuffered. It has no storage. A send waits until another goroutine is ready to receive, and a receive waits until another goroutine sends. The value is handed directly from one to the other, like passing a parcel by hand: both people must be present at the same moment.

package main

import (
	"fmt"
	"time"
)

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

	go func() {
		time.Sleep(50 * time.Millisecond)
		fmt.Println("receiver ready")
		fmt.Println("received", <-ch)
	}()

	start := time.Now()
	ch <- 42
	fmt.Println("send finished after at least 50ms:", time.Since(start) >= 50*time.Millisecond)
	time.Sleep(10 * time.Millisecond)
}

The send ch <- 42 waits until the receiver is ready, so the last line prints true. This waiting is what makes unbuffered channels useful for synchronization: when a send completes, you know the other side has the value.

Sending with no receiver:

On an unbuffered channel, a send in main with no other goroutine to receive it blocks forever, and the program crashes with fatal error: all goroutines are asleep - deadlock!. The same happens for a receive that nobody will ever send to.

Buffered Channels: a Small Queue

make(chan T, n) creates a buffered channel with room for n values. Sends only wait when the buffer is full, and receives only wait when it is empty.

package main

import "fmt"

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

	queue <- "first"
	queue <- "second"
	queue <- "third"
	fmt.Println("len:", len(queue), "cap:", cap(queue))

	fmt.Println(<-queue)
	fmt.Println(<-queue)
	fmt.Println(<-queue)
}

Output:

len: 3 cap: 3
first
second
third

All three sends complete with no receiver running, because the buffer holds them. Values come out in the order they went in. A fourth send before any receive would block, and in this program would deadlock.

When to Use a Buffer

Start with unbuffered channels. They make the timing between goroutines obvious. Add a buffer when you have a specific reason:

  • A known number of results: give the channel a buffer of exactly that size, so senders never block even if the receiver stops early.
  • Smoothing bursts: a producer occasionally runs ahead of a consumer.
  • Limiting concurrency: a buffered channel used as a counter of free slots, called a semaphore.
package main

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

func main() {
	slots := make(chan struct{}, 2) // at most 2 at a time
	var wg sync.WaitGroup
	var mu sync.Mutex
	running, maxRunning := 0, 0

	for i := 0; i < 6; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			slots <- struct{}{}        // take a slot, waits if both are taken
			defer func() { <-slots }() // give the slot back

			mu.Lock()
			running++
			maxRunning = max(maxRunning, running)
			mu.Unlock()

			time.Sleep(20 * time.Millisecond)

			mu.Lock()
			running--
			mu.Unlock()
		}()
	}
	wg.Wait()
	fmt.Println("most at the same time:", maxRunning)
}

This prints most at the same time: 2. Six goroutines started, but the channel's capacity of two allowed only two to work at once. struct{} is an empty type that takes no memory, a common choice when only the signal matters.

A buffer does not fix a deadlock:

If a program deadlocks with an unbuffered channel, adding a buffer often only delays the problem until the buffer fills up. Find out which goroutine was supposed to receive and why it is not.

Send-Only and Receive-Only Channel Types

A function parameter can promise to only send or only receive:

package main

import "fmt"

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

func consume(in <-chan int) { // receive-only
	for v := range in {
		fmt.Println("got", v)
	}
}

func main() {
	ch := make(chan int)
	go produce(ch)
	consume(ch)
}

This prints got 10, got 20, got 30. Trying to receive inside produce fails to compile with invalid operation: cannot receive from send-only channel chan<- int out (variable of type chan<- int). Direction types document who does what and let the compiler enforce it.

The nil Channel

The zero value of a channel type is nil. Sending to or receiving from a nil channel blocks forever. Always create channels with make before use.

You understand channel blocking:

If you can say for any send or receive in your program which goroutine is on the other side, and whether a buffer slot is free, you can predict exactly when it will wait.

Summary

Channels carry typed values between goroutines. Unbuffered channels hand values over directly, so both sides wait for each other. Buffered channels queue up to n values and only block when full or empty. Default to unbuffered, add a buffer for a clear reason such as known result counts or limiting concurrency, and use direction types like chan<- and <-chan in function signatures.