Course contentsShow
← Rust
Lesson 1197 of 1,91010. Concurrency PrimitivesFree lesson

Batching Concurrent Requests

You will reduce the frequency of synchronization by coalescing small requests into single atomic operations.

In this lesson, you will learn how to reduce system overhead and synchronization contention by coalescing individual, fine-grained requests into a single atomic operation.

The Problem: The Cost of Chatty Communication

When building high-concurrency systems, our tasks often perform small, frequent I/O operations—like hitting a database for a single row or sending a tiny packet to a network peer. If every single async task attempts to lock a resource or send a request independently, the system spends more time managing synchronization primitives and context switching than performing actual work. This is the "chatty I/O" problem, which leads to high lock contention and increased latency.

The Solution: Coalescing with Channels

Instead of letting every task execute its own request, we can use a "Batcher" pattern. We represent our requests as messages sent through a mpsc (multi-producer, single-consumer) channel. A dedicated worker task waits for these messages to accumulate over a tiny time window or until a buffer size is reached.

Once the threshold is met, the worker flushes the entire collection in one "batch" operation, significantly reducing the number of syscalls or network packets:

// A simplified view of the batching pattern
async fn batcher_loop(mut receiver: mpsc::Receiver<Request>) {
    let mut buffer = Vec::new();
    loop {
        // Collect requests until we have a batch or timeout
        while let Ok(req) = receiver.try_recv() {
            buffer.push(req);
            if buffer.len() >= 100 { break; }
        }
        
        if !buffer.is_empty() {
            // Perform one atomic I/O operation for the entire batch
            let results = db_pool.execute_batch(&buffer).await;
            // Distribute results back to the callers via oneshot channels
            distribute_results(buffer, results);
            buffer.clear();
        }
        tokio::time::sleep(Duration::from_millis(5)).await;
    }
}

Why this works

By shifting from "request-per-task" to "batch-per-process," you serialize the actual I/O while keeping the submission of those requests highly concurrent. This pattern reduces the frequency of synchronization, minimizes the overhead of task waking, and improves hardware cache locality by processing data in contiguous blocks. This is particularly effective in high-throughput database drivers and protocol clients where the latency of a single round-trip is higher than the latency of processing a few extra items in a buffer.

Key Takeaway: Batching transforms high-frequency synchronization overhead into infrequent, high-efficiency atomic operations by decoupling request submission from request execution.