Workspace IndexAlgorithms › Day 38

RCU — The Standard Technique for Read-Optimized Concurrency TODO

Algorithms · Day 38 / 100 · C. Concurrency & Performance Engineering (Day 36-51)

Concept

RCU is a synchronization technique for read-heavy data structures that lets readers reference data without any locks or atomic writes. Instead of modifying an existing node in place, an updater builds a modified copy and swaps the pointer over in a single atomic publish, so a reader always sees one consistent state — either the old version or the new one, never something in between. At publish time, memory ordering has to guarantee that the new node's initialization becomes visible before the pointer swap does. Freeing the old version immediately would break any reader still reading it, so reclamation waits for a grace period — the point at which every pre-existing reader is guaranteed to have exited its critical section. The net effect is read cost converging to nearly zero, at the cost of delayed reclamation and higher memory usage for the updater.

In routing tables, config snapshots, and symbol tables where the read-to-write ratio is heavily skewed, a mutex or RW-lock destroys scalability purely from cache-line contention, and RCU-family techniques remove that bottleneck.

Code & Formula

# RCU(Read-Copy-Update) — 갱신자는 복사본을 고쳐 포인터를 원자적으로 교체하고, 독자는 락 없이 항상 일관된 스냅샷만 본다.

import threading
import time

class RcuBox:
    def __init__(self, initial):
        self._ptr = initial  # 단일 참조 슬롯 — 이 슬롯 교체 하나가 "발행(publish)" 원자 연산

    def read(self):
        return self._ptr     # 독자: 락 없이 현재 포인터만 읽는다 — 항상 완전한 옛 버전 또는 새 버전

    def update(self, mutate_fn):
        old = self._ptr
        new = dict(old)      # 복사본을 만들어 그 위에서만 수정 (기존 독자가 보는 old 는 절대 안 건드림)
        mutate_fn(new)
        self._ptr = new       # 원자적 포인터 교체 = 발행. 이 순간 이후 신규 독자는 new 만 본다.
        return old            # 회수는 유예 기간 이후에(여기서는 즉시 반환만 시연)

config = RcuBox({"rate_limit": 100, "region": "kr"})
seen_versions = []

def reader(idx):
    for _ in range(5):
        snapshot = config.read()          # 항상 완전한 dict 하나 (반쯤 갱신된 상태를 절대 보지 않음)
        seen_versions.append(dict(snapshot))
        time.sleep(0.001)

readers = [threading.Thread(target=reader, args=(i,)) for i in range(4)]
for t in readers:
    t.start()

old_snapshot = config.update(lambda d: d.__setitem__("rate_limit", 500))  # 갱신자: 복사 -> 수정 -> 교체

for t in readers:
    t.join()

consistent = all(v in ({"rate_limit": 100, "region": "kr"}, {"rate_limit": 500, "region": "kr"}) for v in seen_versions)
print("old snapshot untouched:", old_snapshot)
print("current snapshot:", config.read())
print("every reader saw a fully-consistent old-or-new version:", consistent)

Exercise

In Go or Rust, build a copy-on-write cache that swaps a config struct through an atomic pointer, spin up several reader threads, and benchmark throughput against a mutex-based version.

Practical Connection

For a node's state trie cache or an in-memory order book snapshot, where many lookup goroutines face off against a small number of updates, epoch-based reclamation or atomic snapshot swapping is the standard way to get a consistent view without lock contention.

If you study this on a given day, add a note link and a ✅ to this line in the source curriculum (docs/knowledge/dev-100-curriculum.md) and this spot will lead straight to the note body. You can also write directly on this page — but regenerating overwrites it, so it's safer to keep anything you want to save as markdown under docs/algorithms/.


한국어

RCU TODO

Algorithms · Day 38 / 100 · C. 동시성·성능 엔지니어링 (Day 36–51)

읽기 최적화의 정석

개념

RCU는 읽기가 압도적으로 많은 자료구조에서 읽기 측이 락이나 원자적 쓰기 없이 데이터를 참조하도록 만드는 동기화 기법이다. 갱신자는 기존 노드를 제자리에서 고치는 대신 복사본을 만들어 수정한 뒤 포인터를 한 번의 원자적 발행으로 교체하므로, 독자는 항상 낡은 버전이거나 새 버전이거나 둘 중 하나의 일관된 상태만 본다. 발행 시점에는 새 노드의 초기화가 포인터 교체보다 먼저 보이도록 메모리 순서 보장이 필요하다. 낡은 버전을 즉시 해제하면 아직 그것을 읽고 있는 독자가 깨지므로, 모든 기존 독자가 임계 구역을 빠져나갔음이 보장되는 시점인 유예 기간이 지난 뒤에 회수한다. 결과적으로 읽기 비용은 거의 0에 수렴하고 그 대가는 갱신자의 지연된 회수와 메모리 사용량 증가다.

읽기 대 쓰기 비율이 크게 치우친 라우팅 테이블, 설정 스냅샷, 심볼 테이블에서 뮤텍스나 RW락은 캐시 라인 경합만으로 확장성을 무너뜨리고, RCU 계열 기법이 그 병목을 없앤다.

코드 · 수식

# RCU(Read-Copy-Update) — 갱신자는 복사본을 고쳐 포인터를 원자적으로 교체하고, 독자는 락 없이 항상 일관된 스냅샷만 본다.

import threading
import time

class RcuBox:
    def __init__(self, initial):
        self._ptr = initial  # 단일 참조 슬롯 — 이 슬롯 교체 하나가 "발행(publish)" 원자 연산

    def read(self):
        return self._ptr     # 독자: 락 없이 현재 포인터만 읽는다 — 항상 완전한 옛 버전 또는 새 버전

    def update(self, mutate_fn):
        old = self._ptr
        new = dict(old)      # 복사본을 만들어 그 위에서만 수정 (기존 독자가 보는 old 는 절대 안 건드림)
        mutate_fn(new)
        self._ptr = new       # 원자적 포인터 교체 = 발행. 이 순간 이후 신규 독자는 new 만 본다.
        return old            # 회수는 유예 기간 이후에(여기서는 즉시 반환만 시연)

config = RcuBox({"rate_limit": 100, "region": "kr"})
seen_versions = []

def reader(idx):
    for _ in range(5):
        snapshot = config.read()          # 항상 완전한 dict 하나 (반쯤 갱신된 상태를 절대 보지 않음)
        seen_versions.append(dict(snapshot))
        time.sleep(0.001)

readers = [threading.Thread(target=reader, args=(i,)) for i in range(4)]
for t in readers:
    t.start()

old_snapshot = config.update(lambda d: d.__setitem__("rate_limit", 500))  # 갱신자: 복사 -> 수정 -> 교체

for t in readers:
    t.join()

consistent = all(v in ({"rate_limit": 100, "region": "kr"}, {"rate_limit": 500, "region": "kr"}) for v in seen_versions)
print("old snapshot untouched:", old_snapshot)
print("current snapshot:", config.read())
print("every reader saw a fully-consistent old-or-new version:", consistent)

연습

Go나 Rust에서 설정 구조체를 원자적 포인터로 교체하는 copy-on-write 캐시를 만들고, 독자 스레드를 여럿 띄운 상태에서 뮤텍스 버전과 처리량을 벤치마크로 비교하라.

실무 · Verex 연결

노드의 상태 트라이 캐시나 인메모리 오더북 스냅샷처럼 다수의 조회 고루틴이 소수의 갱신을 상대하는 구조에서, 에폭 기반 회수나 원자적 스냅샷 교체는 락 경합 없이 일관된 뷰를 제공하는 표준 해법이다.

공부한 날 원본 커리큘럼(docs/knowledge/dev-100-curriculum.md)의 이 줄에 노트 링크와 ✅ 를 붙이면, 이 자리는 노트 본문으로 바로 이어집니다. 노트 없이 이 페이지에 바로 적어도 됩니다 — 다만 다시 생성하면 덮어쓰이므로, 남길 글은 docs/algorithms/ 의 마크다운으로 쓰는 편이 안전합니다.

← 37. 락프리·wait-free, ABA 문제, 해저드 포인터·에포크 회수39. false sharing·캐시라인 정렬·NUMA 지역성 →