Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 47

Bounded Work Queues with Buffered Channels

Recommended resources

Hello! Welcome back to our journey of building a message broker from scratch.

In the last lesson, we significantly improved our broker's design by introducing a Partitioner interface. This refactoring allows us to plug in different strategies for assigning messages to partitions, and we created a DefaultPartitioner that intelligently switches between round-robin and key-based hashing.

Introduction to the Lesson

Currently, our handleProduceRequest function is synchronous. When a producer sends a message, the goroutine handling that connection blocks until the message is fully written to the commit log on disk. While simple, this design has a major performance drawback: the rate at which we can accept new requests is tightly coupled to the speed of our disk I/O. Under heavy load, our broker would quickly become unresponsive.

Today, we will address this bottleneck. Your learning outcome is to use buffered channels to create a bounded work queue for incoming produce requests, providing backpressure.

This lesson will guide you through:

  1. Understanding the producer-consumer pattern and the critical role of backpressure.
  2. Learning how Go's buffered channels provide a natural and efficient way to build a bounded queue.
  3. Refactoring the broker to decouple network handling (the producer) from disk I/O (the consumer) using a work queue.

By implementing this pattern, you will make your broker more resilient and significantly increase its potential throughput—a core requirement for any high-performance system.



1. The Producer-Consumer Pattern and Backpressure

To solve our synchronous I/O problem, we'll implement one of the most fundamental patterns in concurrent programming: the Producer-Consumer pattern.

In our broker's context:

  • Producers: These are the goroutines handling incoming TCP connections. Their job is to receive a produce request, decode it, and pass it on for processing.
  • Consumers (Workers): These are a pool of background goroutines. Their job is to take a processed request and perform the "heavy lifting"—in our case, writing the message to the correct partition's commit log.
  • Queue: A shared, concurrent-safe queue that sits between the producers and consumers. Producers add work to the queue, and consumers take work from it.
Unbuffered vs Buffered Channel
This diagram shows the core difference between unbuffered and buffered channels. An unbuffered channel requires a rendezvous between sender and receiver. A buffered channel decouples them, allowing the sender to deposit an item and continue, as long as there's space. We'll use this decoupling to our advantage.

A naive implementation might use an unbounded queue. However, what happens if producers are generating work much faster than consumers can process it? The queue will grow indefinitely, consuming all available memory and eventually crashing the system.

This is where backpressure comes in. Backpressure is a mechanism that allows a system to handle overload gracefully. Instead of crashing, the overloaded component (our worker pool) signals to the upstream component (our connection handlers) to slow down. We achieve this by using a bounded queue—a queue with a fixed capacity. When the queue is full, any producer attempting to add a new item will block until a consumer frees up space.

This article provides an excellent explanation of this concept and how it's implemented with Go channels.

What are Blocking Queues and Why We Need Them

The article 'What are Blocking Queues and Why We Need Them' by Arpit Bhayani clearly explains the producer-consumer pattern and how bounded queues naturally provide backpressure.

Please read the introduction, 'What is a Blocking Queue?', 'The Producer-Consumer Pattern', and 'Bounded vs Unbounded Queues'. Focus on how a full blocking queue automatically pauses producers, which is the essence of backpressure.

Go's buffered channels are a perfect implementation of a bounded, blocking queue.



2. Buffered Channels: The Heart of Our Work Queue

A Go channel can be either unbuffered or buffered. This distinction is crucial for our use case.

  • Unbuffered Channel (make(chan T)): A sender will block until a receiver is ready to take the value. This creates a synchronization point, or "rendezvous." It's great for guaranteeing a handoff but doesn't provide any decoupling.
  • Buffered Channel (make(chan T, N)): A sender can place up to N items into the channel without blocking. It only blocks when the channel is full. A receiver only blocks when the channel is empty. This is exactly the behavior we need for our bounded work queue.

This video provides a fantastic deep dive into the behavior of buffered vs. unbuffered channels.

Go Class: 26 Channels in Detail

The video 'Go Class: 26 Channels in Detail' by Matt KØDVB clearly illustrates the behavioral differences and explains why you would choose one over the other.

Please watch the following sections: 9:02 - 12:47: This section explains the 'rendezvous' model of unbuffered channels versus the 'mailbox' model of buffered channels. 20:43 - 24:58: This part discusses reasons to buffer channels, including performance and, most importantly for us, as a 'counting semaphore' to limit work in progress. The analogy of limiting people in a store is a perfect description of backpressure.

As the video and article highlight, a buffered channel of size N acts as a work queue that can hold at most N items. When our connection-handling goroutines (producers) try to send a request to a full channel, the Go runtime automatically pauses them. They will remain blocked until a worker goroutine (consumer) processes an item, making space in the channel. This is backpressure, implemented effortlessly.

Let's read a final, concise explanation of this specific pattern.

Building Resilient Connection Handling - Go Optimization Guide

This guide from 'goperf.dev' has a section on 'Buffered Channel Backpressure' that succinctly describes exactly what we are about to implement.

Read the section 'Buffered Channel Backpressure'. Note the diagram and code sketch, which show how a buffered channel sits between producers and consumers to throttle intake automatically.



3. Implementing the Bounded Work Queue

Now, let's apply this pattern to our broker. We will refactor the code to move the partitioning and log-append logic into a pool of worker goroutines fed by a buffered channel.

Step 1: Define the Work Item

First, we need a struct to represent a single unit of work. This struct will carry all the information a worker needs to process a produce request. We'll also include a channel for the worker to send back a result (or an error), although we won't use it for a response in this lesson.

Create a new file, e.g., internal/broker/work.go.

// internal/broker/work.go
package broker

import "our-kafka/internal/protocol"

// ProduceWork represents a single produce request to be processed by a worker.
type ProduceWork struct {
    // We pass the full request payload for processing.
    Request *protocol.ProduceRequest
    // A channel to signal completion and return an error if one occurred.
    ResultChan chan error
}

Step 2: Update the Broker and Start Workers

Next, we'll add the buffered channel to our Broker struct. We'll also add a Start method to launch our worker goroutines and a Stop method to shut them down cleanly.

// internal/broker/broker.go
import (
    "sync" // Add sync for WaitGroup
    // ... other imports
)

const (
    // The number of concurrent workers processing produce requests.
    // This can be tuned based on performance testing.
    produceWorkerCount = 4
    // The size of the queue for pending produce requests.
    // This determines how many requests can be buffered before applying backpressure.
    produceQueueSize = 100
)

type Broker struct {
    // ... existing fields: topics, mu, partitioner
    
    // New fields for the worker pool
    produceChan chan ProduceWork // The bounded work queue
    wg          sync.WaitGroup   // To wait for workers to stop
    shutdown    chan struct{}    // To signal shutdown to workers
}

func NewBroker() *Broker {
    return &Broker{
        topics:      make(map[string]*Topic),
        partitioner: NewDefaultPartitioner(),
        produceChan: make(chan ProduceWork, produceQueueSize),
        shutdown:    make(chan struct{}),
    }
}

// Start launches the broker's background worker goroutines.
func (b *Broker) Start() {
    b.wg.Add(produceWorkerCount)
    for i := 0; i < produceWorkerCount; i++ {
        go b.produceWorker()
    }
    log.Printf("Broker started with %d produce workers", produceWorkerCount)
}

// Stop signals all workers to shut down and waits for them to finish.
func (b *Broker) Stop() {
    close(b.shutdown) // Signal shutdown
    b.wg.Wait()       // Wait for all workers to exit
    log.Println("Broker stopped")
}

Step 3: Implement the Worker Logic

The produceWorker method is the heart of our consumer logic. It runs in a loop, continuously pulling work from produceChan and processing it. The select statement allows it to listen for both new work and the shutdown signal.

// internal/broker/broker.go (continued)

func (b *Broker) produceWorker() {
    defer b.wg.Done()

    for {
        select {
        case work := <-b.produceChan:
            // Process the work item
            err := b.processProduceWork(work)
            // Signal completion (and error) back to the producer.
            // In a real system, you'd handle this more robustly.
            work.ResultChan <- err
        case <-b.shutdown:
            // Shutdown signal received, exit the loop.
            log.Println("Produce worker shutting down")
            return
        }
    }
}

// processProduceWork contains the logic that was previously in handleProduceRequest.
func (b *Broker) processProduceWork(work ProduceWork) error {
    req := work.Request

    b.mu.RLock()
    topic, ok := b.topics[req.TopicName]
    b.mu.RUnlock()
    if !ok {
        return fmt.Errorf("topic '%s' not found", req.TopicName)
    }

    partitionCount := len(topic.Partitions)
    if partitionCount == 0 {
        return fmt.Errorf("no partitions for topic '%s'", req.TopicName)
    }

    // Use the partitioner to select the target partition
    partitionerMsg := &protocol.Message{Key: req.Key}
    partitionID := b.partitioner.Balance(partitionerMsg, partitionCount)
    partition := topic.Partitions[partitionID]

    // Append to the commit log
    _, err := partition.Log.Append(req.Value)
    if err != nil {
        return fmt.Errorf("failed to append to commit log for partition %d: %w", partition.ID, err)
    }
    
    log.Printf("Worker appended message to topic '%s', partition %d", req.TopicName, partition.ID)
    return nil
}

Step 4: Refactor the Request Handler (The Producer)

Finally, we refactor handleProduceRequest. It's now much simpler. Its only job is to decode the request, wrap it in a ProduceWork struct, and send it into the produceChan.

// internal/broker/broker.go (continued)

func (b *Broker) handleProduceRequest(conn net.Conn, msg *protocol.Message) error {
    req, err := protocol.DecodeProduceRequest(msg.Payload)
    if err != nil {
        return fmt.Errorf("failed to decode produce request: %w", err)
    }
    
    resultChan := make(chan error, 1)
    work := ProduceWork{
        Request:    req,
        ResultChan: resultChan,
    }

    // This is the backpressure point. If produceChan is full, this send will block
    // until a worker makes space.
    b.produceChan <- work

    // Wait for the worker to signal completion.
    // In a full implementation, you might use a timeout here.
    err = <-resultChan
    if err != nil {
        // Here you would send an error response back to the client.
        return fmt.Errorf("worker failed to process produce request: %w", err)
    }
    
    // Send success response back to client (logic to be added).
    log.Printf("Request for topic '%s' queued successfully", req.TopicName)
    return nil
}

Don't forget to call b.Start() in your main.go after creating the broker, and b.Stop() during graceful shutdown.



Conclusion

Congratulations! You have successfully implemented a foundational pattern for building resilient, high-throughput systems. By introducing a bounded work queue, you have decoupled network I/O from disk I/O, allowing your broker to handle bursts of traffic gracefully without being overwhelmed.

Key Takeaways:

  • The Producer-Consumer pattern is ideal for decoupling fast request handlers from slower processing logic like disk I/O.
  • Buffered channels in Go are a native, efficient implementation of a bounded, blocking queue.
  • The fixed capacity of a buffered channel provides natural backpressure, automatically throttling producers when consumers can't keep up, which prevents memory exhaustion and system failure.
  • This asynchronous design improves both throughput (we can accept new requests while others are being written to disk) and resilience.

Next Up

Our request handler is now asynchronous, but our producer clients are still sending one message per network request. This is inefficient. In the next lesson, we will implement message batching for producers, allowing them to send multiple messages in a single request to significantly improve throughput and reduce network overhead.