асинхронность и конкурентностьasynchrony and concurrency
Механизм синхронизации (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.
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
RCU закрывает нишу, для которой даже hazard_pointer оказывается недостаточно дёшев:RCU fills a niche where even hazard_pointer turns out to not be cheap enough:
Таблицы маршрутизации, снапшоты конфигурации — структуры, которые читаются миллионы раз в секунду и обновляются редко. Небольшая атомарная запись на каждое защищённое чтение (как у 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.
Читатели платят практически ноль за синхронизацию — обычное чтение указателя. Цена целиком ложится на (редкого) писателя, которому приходится ждать 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.
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).
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
}
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;
}
Каждый обрабатывающий запрос поток читает набор 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;
}
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.
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
}
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
}
Читатели обходят список через обычные, 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;
}
}
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 — плохой выбор по сравнению с обычным мьютексом или 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.
Если читатель не покидает критическую секцию (например, застревает на блокирующем 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.