Article · 2025-02-25

Design Trade-offs in Hot-Reloading Load Balancer Weights

Building high-performance network services such as load balancers or gateways often requires solving the "hot reload" problem: how do you dynamically adjust backend server weights without restarting the process, dropping active connections, or introducing lock contention?

Two common architectural approaches exist:

  1. Centralized Push: A Control Plane actively pushes new configurations to the Data Plane via RPC.
  2. Local Observation: The Data Plane passively monitors local state changes (e.g., the file system).

This post demonstrates the second pattern—hot reloading via file monitoring and atomic swaps—and analyzes the engineering trade-offs behind it.

The Core Challenge: Zero-Blocking Reads

The primary mission of the Data Plane is traffic forwarding. In scenarios handling hundreds of thousands of requests per second, any form of locking (Mutex/RwLock) on the hot path becomes a performance killer.

If every request forwarding thread had to acquire a Read Lock to check current weights, a single configuration update thread acquiring a Write Lock would stall all traffic, resulting in unacceptable latency spikes.

The design requirement is strict: configuration reads by Worker Threads must be wait-free or, at minimum, contention-free.

Demonstration (Rust)

To achieve this, we use the arc-swap pattern in Rust (conceptually similar to std::atomic<std::shared_ptr<T>> in C++ or RCU mechanisms). The core logic involves:

  1. Hot Path: Worker threads hold a snapshot of the atomic pointer. Reading configuration is a single atomic load—extremely cheap.
  2. Cold Path: A background thread constructs the new configuration object. Once ready, it atomically "swaps" the global pointer. The old configuration is automatically deallocated once all references to it are dropped.

Here is a clean-room implementation:

use std::sync::Arc;
use std::thread;
use std::time::Duration;
use arc_swap::ArcSwap;

/// Configuration structure: holds weight and version
#[derive(Debug, Clone, Copy)]
struct WeightConfig {
    pub value: usize,
    pub version: u64,
}

/// State Watcher: holds an atomic reference to the global config
struct StateWatcher {
    // ArcSwap allows atomically replacing the content inside the Arc
    config: Arc<ArcSwap<WeightConfig>>,
}

impl StateWatcher {
    fn new(default_weight: usize) -> Self {
        let initial = WeightConfig { value: default_weight, version: 0 };
        Self {
            config: Arc::new(ArcSwap::from_pointee(initial)),
        }
    }

    /// Hot Path: Get a snapshot of the current config
    /// This is an extremely low-cost operation; no blocking, no waiting for writers
    fn get_current(&self) -> Arc<WeightConfig> {
        self.config.load().clone()
    }

    /// Cold Path: Background monitoring and updating
    fn start_monitor(&self) {
        let config_clone = Arc::clone(&self.config);
        
        thread::spawn(move || {
            let mut current_version = 0;
            loop {
                // Simulation: Polling file system or waiting for Inotify events
                // In production, this would watch a config file on disk
                thread::sleep(Duration::from_millis(500));
                
                // Simulation: A new config has been loaded
                current_version += 1;
                let new_weight = if current_version % 2 == 0 { 50 } else { 200 };
                
                println!("[Monitor] Weight changed -> {} (v{})", new_weight, current_version);
                
                // Key Point: Atomic Swap
                // The store operation is atomic. Readers see either the old value or the new value, never a partial state.
                config_clone.store(Arc::new(WeightConfig {
                    value: new_weight,
                    version: current_version,
                }));
            }
        });
    }
}

fn main() {
    let watcher = Arc::new(StateWatcher::new(100));
    
    // Start background update thread
    watcher.start_monitor();
    
    // Simulate concurrent worker threads
    let mut handles = vec![];
    for id in 1..=3 {
        let w = Arc::clone(&watcher);
        handles.push(thread::spawn(move || {
            for i in 1..=5 {
                // Fetch the latest config snapshot before processing each request
                let cfg = w.get_current();
                println!("  [Worker-{}] Processing req #{} | Weight: {} (v{})", 
                    id, i, cfg.value, cfg.version);
                
                // Simulate business logic processing time
                thread::sleep(Duration::from_millis(300));
            }
        }));
    }
    
    for h in handles {
        let _ = h.join();
    }
}

Deep Dive: Why Local File Monitoring?

This design highlights a distinct architectural choice: local state synchronization based on files. Why not use a centralized configuration center pushing updates directly?

1. Fault Isolation

With centralized push (e.g., gRPC streams), if the Control Plane goes down or a network partition occurs, new instances of the Data Plane might fail to fetch their initial configuration upon startup.

A file-based design persists state to the local disk. Even if the Control Plane is completely unreachable, an Agent can write the config to disk, and the Data Plane process can restart and load the "Last Known Good" state. This significantly increases system resilience.

2. Decoupling and Simplicity

This pattern separates "distribution" from "activation."

This separation keeps application logic minimal, removes the need for complex RPC client libraries, and simplifies testing (you can trigger an update manually with vim).

3. The Cost of Consistency

This approach introduces trade-offs:

Summary

Combining ArcSwap (RCU mechanics) with local file monitoring creates a robust hot-reload mechanism with zero impact on the data plane. We sacrifice millisecond-level global consistency for exceptional read performance and architectural robustness.

In distributed systems, slight state latency on the critical path is often preferable to lock contention.

© 2026 Yuxu Ge ·