асинхронность и конкурентностьasynchrony and concurrency

std::rcu (Read-Copy-Update)(Read-Copy-Update)

Механизм синхронизации (P2545) для данных, которые читают часто, а меняют редко — известен по ядру Linux. Читатели обращаются к текущей версии данных вообще без блокировок и атомарных RMW — это обычное чтение указателя. Писатели создают новую копию, атомарно публикуют её и ждут, пока все "старые" читатели не завершатся, прежде чем освободить старую версию.A synchronization mechanism (P2545) for read-mostly, write-rare data — well known from the Linux kernel. Readers access the current version of the data with no locking and no atomic read-modify-write at all — it's an ordinary pointer read. Writers create a new copy, atomically publish it, and wait until all "old" readers finish before freeing the old version.

пришло из ядра Linux, теперь в стандартной библиотекеcame from the Linux kernel, now in the standard library API ещё не окончательно устоялся — деталь до ратификацииthe API hasn't fully settled yet — a pre-ratification detail

Что этоWhat it is

RCU (Read-Copy-Update) разделяет читателей и писателя во времени, а не через взаимоисключение. Читатель входит в "read-side critical section", читает указатель на текущую версию данных (обычная нагрузка на указатель, без атомарных операций чтения-модификации-записи) и работает с ней. Писатель строит новую версию данных целиком, атомарно переключает публичный указатель на неё, а затем ждёт "grace period" — период, пока все читатели, вошедшие в критическую секцию до переключения, не покинут её — и только после этого освобождает старую версию.RCU separates readers and the writer in time rather than through mutual exclusion. A reader enters a "read-side critical section," reads a pointer to the current version of the data (an ordinary pointer load, no atomic read-modify-write), and works with it. A writer builds an entirely new version of the data, atomically swaps the public pointer to it, and then waits out a "grace period" — the period during which every reader that entered the critical section before the swap exits it — only then freeing the old version.

Оговорка: точное имя и форма API P2545 могут ещё измениться до формальной ратификации — ниже приведён набросок, отражающий форму механизма, а не гарантированно окончательные имена функций.Caveat: the exact name and shape of the P2545 API may still shift before formal ratification — the sketch below reflects the shape of the mechanism, not necessarily final function names.

std::rcu_domain& domain = std::rcu_default_domain();
std::atomic<Config*> current_config;

// читатель: обычное чтение указателя, никаких атомарных RMW и блокировок// reader: an ordinary pointer read, no atomic RMW, no locking
{
    std::rcu_reader guard(domain);            // вход в read-side critical section// enters the read-side critical section
    Config* cfg = current_config.load(std::memory_order_acquire);
    use(*cfg);
}   // guard разрушается — выход из критической секции// guard is destroyed — exits the critical section

Зачем ввелиWhy it was introduced

RCU закрывает нишу, для которой даже hazard_pointer оказывается недостаточно дёшев:RCU fills a niche where even hazard_pointer turns out to not be cheap enough:

01

Даже атомарная запись hazard pointer — слишком дорого для экстремально read-heavy случаевEven a hazard pointer's atomic write is too costly for extremely read-heavy cases

Таблицы маршрутизации, снапшоты конфигурации — структуры, которые читаются миллионы раз в секунду и обновляются редко. Небольшая атомарная запись на каждое защищённое чтение (как у hazard_pointer) в такой нагрузке становится заметной статьёй расходов именно из-за огромной частоты чтений.Routing tables, configuration snapshots — structures read millions of times per second and updated rarely. Even the small atomic write per protected read (as with hazard_pointer) becomes a noticeable cost at that scale, precisely because of the sheer read frequency.

02

RCU переносит всю цену на писателяRCU pushes the entire cost onto the writer

Читатели платят практически ноль за синхронизацию — обычное чтение указателя. Цена целиком ложится на (редкого) писателя, которому приходится ждать grace period перед освобождением старой версии данных. Для read:write-соотношения, сдвинутого в сторону чтения на порядки, это выгодный обмен.Readers pay essentially zero synchronization cost — an ordinary pointer read. The cost falls entirely on the (rare) writer, who has to wait out a grace period before freeing the old data version. For a read:write ratio skewed toward reads by orders of magnitude, this is a favorable trade.

03

Модель, обкатанная десятилетиями в ядре LinuxA model battle-tested for decades in the Linux kernel

RCU пришёл из ядра Linux, где используется повсеместно (таблицы маршрутизации, списки процессов и т.д.). C++26 стандартизирует аналогичный механизм для пользовательского пространства — раньше это означало либо ручную реализацию, либо использование сторонних библиотек (userspace-rcu и аналоги).RCU came from the Linux kernel, where it's used pervasively (routing tables, process lists, and so on). C++26 standardizes an analogous mechanism for userspace — previously this meant either a hand-rolled implementation or a third-party library (userspace-rcu and similar).

Как это работает под капотомHow it works under the hood

std::rcu_domain — область, в рамках которой отслеживаются читатели и grace periods (можно использовать std::rcu_default_domain() или завести свой домен). Читатель обозначает начало и конец read-side critical section (в наброске API — через RAII-объект наподобие rcu_reader, концептуально аналогичный rcu_read_lock/rcu_read_unlock из ядра). Писатель после атомарной публикации новой версии вызывает rcu_synchronize(domain) — эта функция блокируется, пока не завершатся все read-side critical sections, начатые до публикации, то есть пока не пройдёт grace period.std::rcu_domain is a scope within which readers and grace periods are tracked (you can use std::rcu_default_domain() or set up your own domain). A reader marks the start and end of a read-side critical section (in the API sketch, via an RAII object like rcu_reader, conceptually analogous to the kernel's rcu_read_lock/rcu_read_unlock). After atomically publishing the new version, the writer calls rcu_synchronize(domain) — this function blocks until every read-side critical section started before the publish has finished, i.e. until the grace period has elapsed.

// набросок API по P2545 — точные имена могут измениться до ратификации// API sketch per P2545 — exact names may change before ratification
std::atomic<Config*> current_config = new Config{load_defaults()};
std::rcu_domain& domain = std::rcu_default_domain();

void update_config(Config new_values) {
    auto* new_cfg = new Config(std::move(new_values));   // писатель строит новую версию целиком// the writer builds an entirely new version
    Config* old_cfg = current_config.exchange(new_cfg, std::memory_order_acq_rel);

    std::rcu_synchronize(domain);   // ждёт grace period: все "старые" читатели завершились// waits out the grace period: all "old" readers have finished
    delete old_cfg;               // теперь безопасно освободить старую версию// now it's safe to free the old version
}

Примеры из реального кодаReal-world examples

типичные сценарии использованияtypical usage scenarios
01

Таблица маршрутизации: чтение на каждый пакет, обновление control-plane потокомA routing table: read per packet, updated by a control-plane thread

Data-plane поток читает таблицу маршрутизации на каждый входящий пакет — миллионы раз в секунду; редкий control-plane поток пересобирает таблицу при изменении топологии.A data-plane thread reads the routing table on every incoming packet — millions of times per second; a rare control-plane thread rebuilds the table when the topology changes.

Route lookup_route(std::atomic<RoutingTable*>& table, IpAddr dest) {
    std::rcu_reader guard(std::rcu_default_domain());
    RoutingTable* rt = table.load(std::memory_order_acquire);
    return rt->find_best_match(dest);   // без единой атомарной RMW операции// no atomic read-modify-write anywhere in this path
}

void apply_topology_change(std::atomic<RoutingTable*>& table, const Topology& topo) {
    auto* new_table = new RoutingTable(topo);
    RoutingTable* old_table = table.exchange(new_table);
    std::rcu_synchronize(std::rcu_default_domain());
    delete old_table;
}
02

Снапшот конфигурации (feature flags) в веб-сервереA configuration snapshot (feature flags) in a web server

Каждый обрабатывающий запрос поток читает набор feature flags; отдельный поток раз в несколько секунд подгружает новую версию конфигурации из файла и публикует её без блокировки запросов.Every request-handling thread reads the set of feature flags; a separate thread reloads a new configuration version from a file every few seconds and publishes it without blocking requests.

bool is_feature_enabled(std::atomic<FlagSet*>& flags, std::string_view name) {
    std::rcu_reader guard(std::rcu_default_domain());
    FlagSet* current = flags.load(std::memory_order_acquire);
    return current->contains(name);
}

void hot_reload_flags(std::atomic<FlagSet*>& flags, const std::string& path) {
    auto* fresh = new FlagSet(parse_flags_file(path));
    FlagSet* stale = flags.exchange(fresh);
    std::rcu_synchronize(std::rcu_default_domain());
    delete stale;
}
03

RCU против shared_mutex на той же read-heavy нагрузкеRCU vs shared_mutex on the same read-heavy workload

shared_mutex всё ещё требует атомарной операции захвата на каждое чтение — под высокой конкурентностью это означает "прыгающую" между ядрами кэш-линию счётчика блокировки. RCU-читатель вообще не трогает разделяемую атомарную переменную состояния блокировки — только собственный указатель.shared_mutex still requires an atomic acquire operation on every read — under high contention this means the lock counter's cache line bounces between cores. An RCU reader never touches a shared lock-state atomic at all — only its own pointer.

shared_mutex — конкурирующие читатели бьются за кэш-линиюshared_mutex — competing readers fight over a cache line
std::shared_mutex mtx;
Config config;

Config read_config() {
    std::shared_lock lock(mtx);   // атомарный инкремент счётчика читателей на каждый вызов// an atomic increment of the reader count on every call
    return config;               // под высокой конкурентности — cache-line bouncing// under high contention — cache-line bouncing
}
RCU — читатели не трогают общее состояниеRCU — readers touch no shared state
std::atomic<Config*> config_ptr;

Config read_config() {
    std::rcu_reader guard(std::rcu_default_domain());
    return *config_ptr.load(std::memory_order_acquire);  // обычное чтение указателя// an ordinary pointer read
}
04

Lock-free связный список: удаление узла с отложенным освобождениемA lock-free linked list: node removal with deferred freeing

Читатели обходят список через обычные, RCU-защищённые чтения указателей; писатель, удаляя узел, отсоединяет его от списка немедленно, но откладывает delete до конца grace period.Readers traverse the list via ordinary, RCU-protected pointer reads; a writer removing a node unlinks it from the list immediately but defers the delete until the end of the grace period.

struct Node { int key; std::atomic<Node*> next; };

bool contains(std::atomic<Node*>& head, int key) {
    std::rcu_reader guard(std::rcu_default_domain());
    for (Node* cur = head.load(std::memory_order_acquire); cur; cur = cur->next.load(std::memory_order_acquire))
        if (cur->key == key) return true;
    return false;
}

void remove(std::atomic<Node*>& head, int key) {
    Node* prev_next_slot_value = head.load();  // упрощено: без обхода конкурентных вставок// simplified: ignoring concurrent insert races
    Node* removed = unlink_node_with_key(head, key);
    if (removed) {
        std::rcu_synchronize(std::rcu_default_domain());  // ждать всех читателей, начатых до отсоединения// wait for every reader that started before the unlink
        delete removed;
    }
}

ГраблиGotchas

Grace period не мгновенен — освобождение всегда отложеноGrace periods are not instant — freeing is always deferred

RCU меняет местами задержку записи и скорость чтения по сравнению с обычным мьютексом: писатель платит latency ожидания grace period, а пиковое потребление памяти растёт, пока одновременно живы старая и новая версии данных. Это прямо противоположный компромисс тому, что даёт простой мьютекс.RCU swaps write latency and read speed relative to a plain mutex: the writer pays the latency of waiting out the grace period, and peak memory usage rises while the old and new data versions are briefly both alive. This is the exact opposite tradeoff to what a plain mutex gives you.

RCU — инструмент для read-mostly, не для write-heavy данныхRCU is a tool for read-mostly data, not write-heavy data

Для нагрузки с частыми записями RCU — плохой выбор по сравнению с обычным мьютексом или hazard_pointer: каждая запись означает полную копию данных и ожидание grace period, что при частых обновлениях становится дороже блокировки.For write-heavy workloads, RCU is a poor choice compared to a regular mutex or hazard_pointer: every write means a full data copy and a grace-period wait, which under frequent updates becomes more expensive than locking.

Нельзя держать read-side critical section открытой бесконечно долгоA read-side critical section must not be held open indefinitely

Если читатель не покидает критическую секцию (например, застревает на блокирующем I/O-вызове внутри неё), это стопорит grace period для всех писателей — классическая ошибка, унаследованная из дисциплины работы с RCU в ядре Linux, где то же правило действует десятилетиями.If a reader never exits its critical section (e.g. it stalls on a blocking I/O call inside it), it stalls the grace period for every writer — a classic bug carried over from Linux kernel RCU discipline, where the same rule has held for decades.