Article · 2026-02-25

The Game Between Read-Only and Copies: Industrial-Scale RCU Design Practice

Building high-concurrency systems forces a difficult choice: sacrifice write convenience for extreme read performance, or introduce complex locking mechanisms for consistency? Read-Copy-Update (RCU) offers a different perspective: through a playback of "copy-then-update" and "atomic switch," it achieves near-lockfree read operations in read-heavy scenarios.

This article explores an industrial-scale RCU design approach and uses Rust to reconstruct its core mechanisms, showing how to leverage ownership semantics to implement this pattern elegantly.

The Core Trade-off: Why RCU?

Traditional read-write locks (RwLock) allow concurrent readers but must block all readers when a writer arrives. In high-frequency read workloads, write lock contention often becomes the source of system tail latency.

RCU's design philosophy is fundamentally different: readers never wait for writers.

The logic is straightforward:

  1. Readers: always obtain a snapshot (or reference) of current data, with the guarantee that data remains valid during the read.
  2. Writers: never modify live data directly; instead, copy the data first, modify the copy.
  3. Publish: after modifications complete, use atomic operations to redirect the global pointer to the new copy.
  4. Reclaim: old data is reclaimed only after confirming no readers still reference it.

This mechanism's cost is higher write overhead (due to copying and allocation), but in exchange it delivers extreme read throughput and deterministic latency.

Industrial-Scale Design Patterns

In real industrial implementations, RCU is rarely just bare pointer manipulation—it becomes a complete asynchronous update framework. Several key aspects demand attention:

1. Accessor Pattern

Readers never touch raw pointers directly; instead, they acquire data through an RAII-style accessor. This accessor holds a smart pointer (such as Arc<T>) to the data, ensuring it cannot be freed during the access.

2. Asynchronous Update Queue

To avoid blocking the main thread, update operations are designed as async tasks. The system maintains an update queue that accepts closures as update logic.

3. Atomic Switch and Versioning

This is RCU's heart. Once update logic finishes on the copy, the global data pointer must be replaced atomically. All subsequent read requests immediately see the new data, while readers holding old data continue using it until their lifetime ends.

Rust Reconstruction

Rust's ownership model aligns naturally with RCU's principles. Arc (atomic reference counting) provides thread-safe shared ownership, perfect for RCU's read side; Mutex or dedicated write-end control serializes updates.

Below we build a component called RcuCell to demonstrate the mechanism.

Defining the Data Structure

First, we need a core structure holding data. To support multithreaded concurrent access, the internal data is wrapped in Arc.

use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;

/// RCU 容器:支持并发读取和序列化更新
pub struct RcuCell<T> {
    // 核心数据,通过 Arc 实现多读者共享
    // 使用 Mutex 保护 inner 指针的切换,确保写者之间的互斥
    inner: Arc<Mutex<Arc<T>>>,
}

impl<T: Clone + Send + Sync + 'static> RcuCell<T> {
    /// 创建一个新的 RCU 容器
    pub fn new(data: T) -> Self {
        RcuCell {
            inner: Arc::new(Mutex::new(Arc::new(data))),
        }
    }
}

Implementing Efficient Reads

Read operations are extremely lightweight. All we do is clone the internal Arc<T>. Thanks to Arc's design, this merely increments a reference count with minimal lock contention—aside from a brief moment acquiring the outer Mutex, or we could optimize this with RwLock, but to demonstrate RCU's "read-snapshot" semantics, we keep it simple here.

Even further, true RCU reads are lockfree. In Rust, we could use libraries like arc-swap to achieve lockfree reads, but to maintain standard library purity, we demonstrate the logical "snapshot acquisition."

impl<T: Clone + Send + Sync + 'static> RcuCell<T> {
    /// 获取当前数据的快照
    /// 读者拿到的实际上是数据的 Arc,保证了数据在读者持有期间有效
    pub fn read(&self) -> Arc<T> {
        let lock = self.inner.lock().unwrap();
        lock.clone()
    }
}

Asynchronous Update Mechanism

This is where the design shines. The update function does not modify data directly; instead, it accepts a closure f: FnOnce(&mut T).

impl<T: Clone + Send + Sync + 'static> RcuCell<T> {
    /// 提交一个更新任务
    /// 注意:这是一个阻塞操作演示,工业级实现通常会放入队列异步执行
    pub fn update<F>(&self, f: F)
    where
        F: FnOnce(&mut T),
    {
        // 1. 获取写锁,确保同一时间只有一个写者
        let mut lock = self.inner.lock().unwrap();
        
        // 2. Read-Copy: 获取当前数据的副本
        // Arc::make_mut 的逻辑是:如果引用计数为 1 则直接修改,否则克隆
        // 在 RCU 场景下,通常都有读者,所以这里几乎总是会发生克隆
        let mut new_data = (**lock).clone();
        
        // 3. Update: 在副本上应用修改
        f(&mut new_data);
        
        // 4. Publish: 原子替换
        *lock = Arc::new(new_data);
        
        // 旧数据的释放由 Arc 机制自动处理:
        // 当旧 Arc 的最后一个读者(包括之前的 *lock)被 drop 时,数据被回收
    }
}

Scenario Demonstration

Consider a configuration center: multiple worker threads read configuration, a management thread periodically updates it.

fn main() {
    // 初始配置
    let config = RcuCell::new(vec!["server-1".to_string()]);
    
    // 模拟读者线程
    let reader_config = RcuCell { inner: config.inner.clone() };
    let reader = thread::spawn(move || {
        for _ in 0..5 {
            let data = reader_config.read();
            println!("Reader: Current config contains {} servers", data.len());
            thread::sleep(Duration::from_millis(100));
        }
    });

    // 模拟写者线程
    let writer_config = RcuCell { inner: config.inner.clone() };
    let writer = thread::spawn(move || {
        thread::sleep(Duration::from_millis(250));
        println!("Writer: Updating config...");
        
        writer_config.update(|data| {
            data.push("server-2".to_string());
            data.push("server-3".to_string());
        });
        
        println!("Writer: Update complete.");
    });

    reader.join().unwrap();
    writer.join().unwrap();
}

Deep Analysis: The Art of Trade-off

In the implementation above, we see RCU's core compromise:

  1. Memory overhead vs. lock contention: Each update triggers a clone. If T is a massive hash table, this is expensive. So RCU does not suit write-heavy or structurally large workloads. It excels for configuration data, routing tables—scenarios with "extremely heavy reads, sparse writes, moderate data size."

  2. Data freshness: At the moment read() executes, the reader captures a snapshot. Even if data updates microseconds later, the reader holds the old version until it releases the Arc and calls read() again. This is unacceptable in strong-consistency systems but perfect in eventual-consistency ones (like DNS or service discovery).

  3. Reclamation delay: Old data release depends on the last reader's departure. If a "lazy" reader holds Arc indefinitely, old data's memory cannot be reclaimed. This is the "grace period" problem in RCU systems. Rust's Arc handles this automatically, but developers must still avoid holding read() snapshots across long-running operations.

Closing Thoughts

Through Rust's reconstruction, we strip away complex C++ templates and pointer arithmetic to reveal RCU's simplest essence: data immutability and atomic version switching.

This design is no silver bullet, but it exemplifies an elegant retreat in system design—by shouldering more responsibility at the write end (copying, allocation), we grant the read end complete freedom. This is the beauty of industrial-scale system design: not pursuit of perfect lockfreedom, but placing lock contention where it hurts least.

© 2026 Yuxu Ge ·