Skip to Content
Course content

31: Buffered vs Unbuffered Channels

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Up until now, we've been using unbuffered channels. In Go, when you call make(chan T), you're creating a synchronous handoff. I like to think of it as a relay race: the runner with the baton cannot let go until the next runner has their hand firmly on it. If the receiver isn't there, the sender just stands there, frozen.

Setting up a synchronous handoff

Let's build a simple image processing pipeline. We have a "Dispatcher" that sends filenames to a "Worker" that simulates a heavy resize operation. I'll start with an unbuffered channel because it's the safest default—it guarantees that the worker has actually received the task before the dispatcher moves on.

package main

import (
	"fmt"
	"time"
)

func worker(id int, jobs <-chan string) {
	for job := range jobs {
		fmt.Printf("Worker %d processing %s\n", id, job)
		time.Sleep(time.Millisecond * 500) // Simulate work
	}
}

func main() {
	jobs := make(chan string) // Unbuffered

	go worker(1, jobs)

	for _, img := range []string{"photo1.jpg", "photo2.jpg", "photo3.jpg"} {
		fmt.Printf("Dispatching %s...\n", img)
		jobs <- img
		fmt.Printf("Finished dispatching %s\n", img)
	}
	close(jobs)
}

If you run this, you'll notice a rhythmic "stop-and-go" pattern. The dispatcher says "Dispatching photo1.jpg...", then it stops. It doesn't print "Finished dispatching" until the worker actually pulls the value off the channel. It's a tight coupling.

The bottleneck of strict synchronization

Now, imagine our dispatcher is actually an HTTP handler receiving uploads. We don't want the user's request to hang just because the worker is currently busy resizing a different image. We want to "drop the task in the inbox" and tell the user "Got it!" immediately.

Here is where I made a mistake in a production service a few years back. I tried to solve this by just spawning a new goroutine for every single send: go func() { jobs <- img }(). I thought I was being clever by making it asynchronous, but I actually just created a memory leak. If the worker was slow, I was just piling up thousands of dormant goroutines in the background, each waiting for their turn to send to the channel.

The correct way to decouple the sender from the receiver—without spawning a million goroutines—is a buffered channel.

Adding a cushion with buffering

By adding a capacity to make(chan T, capacity), we create a buffer. Now, the dispatcher can keep sending images as long as the buffer isn't full. It's like a mailbox; the mailman can leave the letter even if you aren't home to take it.

func main() {
	// Now we have a buffer of 3. 
	// The dispatcher can send 3 images without the worker even touching them.
	jobs := make(chan string, 3) 

	go worker(1, jobs)

	for _, img := range []string{"photo1.jpg", "photo2.jpg", "photo3.jpg", "photo4.jpg"} {
		fmt.Printf("Dispatching %s...\n", img)
		jobs <- img
		fmt.Printf("Finished dispatching %s\n", img)
	}
	close(jobs)
}

Run this, and the output changes. You'll see "Finished dispatching" for the first three images almost instantly. The dispatcher only blocks when it hits "photo4.jpg", because the buffer is full and it has to wait for the worker to clear a spot.

Knowing when to stop buffering

You might be tempted to just give your channels a massive buffer—like 10,000—to "ensure it never blocks." Resist that urge. A buffer is a tool for smoothing out bursty traffic, not a replacement for a proper queue.

If your buffer is always full, you don't have a concurrency problem; you have a throughput problem. Your worker is too slow. Increasing the buffer just hides the latency and consumes more memory until the program eventually crashes or lags. Use buffered channels when you have a known, reasonable burst size, but stick to unbuffered channels when you need a strong guarantee that the data has been handed off.




📋 Practical Task

Build a Burst-Resistant Log Batcher

Create a program that simulates a high-frequency logging system. Your goal is to ensure the main application doesn't freeze during a "log burst."

  • Create a logChan with a buffer size of 5.
  • Implement a logger goroutine that reads from the channel and prints "Writing to disk: [message]". To simulate disk I/O, add a time.Sleep of 200ms inside the logger.
  • In the main function, use a loop to send 10 log messages to the channel as quickly as possible.
  • Print a timestamp or a message "Log sent!" immediately after each send.

Verification: If your buffer is working, you should see the first 5-6 "Log sent!" messages appear almost instantly, while the "Writing to disk" messages appear slowly one by one. The remaining logs should only be sent once the logger creates space in the buffer.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.