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

std::hazard_pointer

Стандартный механизм (P2530) безопасного освобождения памяти в lock-free структурах данных. Поток, читающий узел структуры, публикует "hazard pointer" — заявление "я сейчас читаю этот объект" — прежде чем разыменовать его; поток, желающий удалить объект, сначала проверяет, что ни один hazard pointer на него не указывает, иначе откладывает удаление.A standard mechanism (P2530) for safe memory reclamation in lock-free data structures. A thread reading a node publishes a "hazard pointer" — a declaration of "I am currently reading this object" — before dereferencing it; a thread that wants to delete an object first checks that no hazard pointer references it, deferring the deletion otherwise.

стандартизация того, что folly и libcds изобретали зановоstandardizing what folly and libcds each reinvented частый вопрос: чем отличается от shared_ptr и от RCUcommon question: how it differs from shared_ptr and from RCU

Что этоWhat it is

Hazard pointer — это слот, видимый всем потокам, в который читающий поток записывает адрес объекта непосредственно перед тем, как его разыменовать. Пока адрес там записан, любой другой поток, желающий освободить память по этому адресу, обязан отложить освобождение ("retire" вместо немедленного delete) и повторно проверить это позже. Это способ безопасного управления памятью для lock-free структур без сборщика мусора и без атомарного подсчёта ссылок на каждый узел.A hazard pointer is a slot, visible to all threads, into which a reading thread writes the address of an object right before dereferencing it. As long as that address is recorded there, any other thread wanting to free memory at that address must defer the free ("retire" instead of an immediate delete) and re-check later. This is a way to manage memory safely in lock-free structures with no garbage collector and no atomic reference count per node.

std::atomic<Node*> head;

// получить hazard pointer и опубликовать защиту текущего head// obtain a hazard pointer and publish protection of the current head
auto hp = std::hazard_pointer_obtain();
Node* node = hp.protect(head);   // теперь безопасно разыменовать node — его никто не освободит, пока hp жив// now safe to dereference node — nobody will free it while hp is alive

if (node) {
    use(*node);                // безопасное чтение под защитой// a safe read under protection
}
// hp выходит из области видимости — защита снимается автоматически// hp goes out of scope — protection is lifted automatically

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

Lock-free структуры данных страдают одной фундаментальной проблемой, которую hazard_pointer решает раз и навсегда на уровне стандарта:Lock-free data structures suffer from one fundamental problem, which hazard_pointer solves once and for all at the standard-library level:

01

Use-after-free — фундаментальная опасность lock-free кодаUse-after-free — the fundamental danger of lock-free code

Поток A может читать узел, пока поток B конкурентно удаляет его из структуры и освобождает память — без координации между ними чтение потока A превращается в use-after-free. Классический мьютекс решает это ценой блокировки, но в lock-free мире блокировок нет по определению.Thread A may be reading a node while thread B concurrently removes it from the structure and frees its memory — without coordination between the two, thread A's read becomes a use-after-free. A classic mutex solves this at the cost of blocking, but in the lock-free world there is no blocking by definition.

02

До hazard_pointer это решалось вручную и по-разномуBefore hazard_pointer this was solved by hand, differently each time

Программисты либо писали hazard pointers вручную, либо использовали epoch-based reclamation, либо намеренно "текли" памятью (никогда не освобождая узлы), либо тянули полноценный GC-подобный подход. Каждая lock-free библиотека — folly, libcds — реализовывала свой собственный вариант hazard pointers с нуля.Programmers either hand-rolled hazard pointers, used epoch-based reclamation, intentionally leaked memory (never freeing nodes), or pulled in a full GC-like approach. Every lock-free library — folly, libcds — implemented its own variant of hazard pointers from scratch.

03

Стандартизация убирает зависимость от сторонних библиотекStandardization removes the need for a third-party dependency

Раз механизм стандартный, lock-free коду больше не нужна сторонняя зависимость исключительно ради безопасного освобождения памяти — как и с любой стандартизацией часто изобретаемого велосипеда (сравните с рефлексией и кодогенерацией).Once the mechanism is standard, lock-free code no longer needs a third-party dependency purely for safe memory reclamation — the same pattern as any standardization of a frequently-reinvented wheel (compare with reflection and code generation).

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

std::hazard_pointer_obtain() берёт hazard pointer из внутреннего пула потока. Метод .protect(atomic_ptr) атомарно читает atomic_ptr, публикует прочитанный адрес как защищённый, и перечитывает atomic_ptr ещё раз, чтобы убедиться, что значение не изменилось между чтением и публикацией (иначе цикл повторяется). После этого разыменование прочитанного указателя безопасно. Удаляющая сторона вызывает не delete напрямую, а retire() — объект попадает в список отложенных на удаление, и реальное удаление происходит только тогда, когда сканирование всех активных hazard pointers подтвердит, что ни один из них не защищает этот адрес.std::hazard_pointer_obtain() takes a hazard pointer from a thread-local internal pool. The .protect(atomic_ptr) method atomically reads atomic_ptr, publishes the read address as protected, and re-reads atomic_ptr once more to confirm the value hasn't changed between the read and the publish (otherwise the loop repeats). After that, dereferencing the read pointer is safe. The deleting side doesn't call delete directly but retire() — the object goes onto a list of pending deletions, and the actual deletion happens only once a scan of all active hazard pointers confirms none of them protects that address.

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

void remove_and_retire(std::atomic<Node*>& slot, Node* old_node) {
    slot.store(old_node->next.load());
    // не delete напрямую — retire откладывает удаление до безопасного момента// not a direct delete — retire defers deletion until it's safe
    std::hazard_pointer_retire(old_node, [](Node* p) { delete p; });
}

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

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

pop() lock-free стека защищает head перед чтением nextA lock-free stack's pop() protects head before reading next

Прежде чем прочитать head->next, поток публикует hazard pointer на head — это гарантирует, что конкурентный push/pop из другого потока не освободит этот узел из-под ног.Before reading head->next, the thread publishes a hazard pointer on head — this guarantees a concurrent push/pop from another thread won't free that node out from under it.

T pop(std::atomic<Node*>& head) {
    auto hp = std::hazard_pointer_obtain();
    Node* old_head;
    do {
        old_head = hp.protect(head);
        if (!old_head) throw std::runtime_error("empty stack");
    } while (!head.compare_exchange_weak(old_head, old_head->next.load()));

    T result = old_head->value;
    std::hazard_pointer_retire(old_head, [](Node* p) { delete p; });
    return result;
}
02

dequeue() lock-free очереди защищает узел аналогичноA lock-free queue's dequeue() protects a node similarly

Для очереди Michael-Scott защита нужна и на голове, и на следующем за ней узле — оба читаются, прежде чем структура физически изменится.For a Michael-Scott queue, protection is needed both on the head and the node that follows it — both are read before the structure is physically changed.

T dequeue(MpmcQueue& q) {
    auto hp_head = std::hazard_pointer_obtain();
    auto hp_next = std::hazard_pointer_obtain();

    Node* head = hp_head.protect(q.head);
    Node* next = hp_next.protect(head->next);   // head — dummy-узел, next — реальные данные// head is a dummy node, next holds the real data

    if (!next) throw std::runtime_error("empty queue");
    T value = next->value;

    if (q.head.compare_exchange_strong(head, next)) {
        std::hazard_pointer_retire(head, [](Node* p) { delete p; });
    }
    return value;
}
03

Наглядно: наивный стек с use-after-free рядом с безопаснымSide by side: a naive stack with use-after-free vs the safe version

Слева — код, который выглядит правдоподобно, но допускает освобождение узла между чтением old_head и разыменованием old_head->next другим потоком. Справа — та же операция под защитой hazard pointer.On the left, code that looks plausible but allows the node to be freed between reading old_head and dereferencing old_head->next from another thread. On the right, the same operation under hazard-pointer protection.

наивно — use-after-free возможенnaive — use-after-free is possible
T pop_unsafe(std::atomic<Node*>& head) {
    Node* old_head = head.load();
    // окно опасности: другой поток может delete old_head здесь// danger window: another thread may delete old_head right here
    Node* next = old_head->next.load();  // UB, если old_head уже освобождён// UB if old_head has already been freed
    head.compare_exchange_weak(old_head, next);
    delete old_head;  // может удалить узел, который ещё читают другие потоки// may delete a node other threads are still reading
    return old_head->value;
}
с hazard_pointer — безопасноwith hazard_pointer — safe
T pop_safe(std::atomic<Node*>& head) {
    auto hp = std::hazard_pointer_obtain();
    Node* old_head = hp.protect(head);  // публикация закрывает окно опасности// publishing closes the danger window
    Node* next = old_head->next.load();  // безопасно: old_head не может быть освобождён// safe: old_head cannot be freed while protected
    head.compare_exchange_weak(old_head, next);
    std::hazard_pointer_retire(old_head, [](Node* p){ delete p; });
    return next->value;
}
04

Read-mostly hash bucket: итерация под защитой, удаление в фонеA read-mostly hash bucket: iteration under protection, deletion in the background

Поток-читатель обходит связный список бакета, защищая каждый узел по очереди, пока фоновый поток очистки удаляет устаревшие записи — оба процесса не мешают друг другу.A reading thread walks the bucket's linked list, protecting each node in turn, while a background cleanup thread removes stale entries — the two processes don't interfere with each other.

Value* find(Bucket& bucket, const Key& key) {
    auto hp = std::hazard_pointer_obtain();
    Node* cur = hp.protect(bucket.first);
    while (cur) {
        if (cur->key == key) return &cur->value;    // защищённое чтение — безопасно, даже если узел скоро retire()// a protected read — safe even if the node is retired soon
        cur = hp.protect(cur->next);          // перенос защиты на следующий узел// move protection to the next node
    }
    return nullptr;
}

ГраблиGotchas

Не бесплатно — публикация hazard pointer это атомарная запись на каждое защищённое чтениеNot free — publishing a hazard pointer is an atomic write per protected read

.protect() — это как минимум одна атомарная запись плюс повторное чтение для верификации, на каждый защищаемый указатель. Для write-heavy нагрузок это может оказаться не дешевле, а то и дороже обычного мьютекса — hazard pointer не автоматически "лучше" блокировки, это компромисс, эффективный при определённом профиле нагрузки..protect() is at minimum one atomic write plus a re-read for verification, per protected pointer. For write-heavy workloads this can end up no cheaper — sometimes more expensive — than a plain mutex; a hazard pointer is not automatically "better" than a lock, it's a tradeoff effective for a particular workload profile.

Число одновременных hazard pointers на поток ограниченоThe number of simultaneous hazard pointers per thread is bounded

Пул hazard pointers на поток обычно небольшой и фиксированный по размеру. Попытка защитить много объектов одновременно в одном потоке (например, длинную цепочку узлов без освобождения предыдущих защит) может исчерпать пул — это ограничивает шаблоны использования, для которых механизм годится напрямую.The per-thread pool of hazard pointers is typically small and fixed in size. Trying to protect many objects at once in a single thread (e.g. a long chain of nodes without releasing earlier protections) can exhaust the pool — this constrains the usage patterns the mechanism directly fits.

retire() не удаляет немедленно — освобождение отложено и пакетноretire() doesn't delete immediately — deletion is deferred and batched

Объект, переданный в retire(), остаётся в памяти до следующего сканирования активных hazard pointers, которое обычно выполняется пакетно (не после каждого retire). Это значит, что фактическое освобождение памяти отстаёт от логического удаления — важно понимать при анализе всплесков потребления памяти под высокой churn-нагрузкой.An object passed to retire() stays in memory until the next scan of active hazard pointers, which is usually performed in batches (not after every single retire). This means actual memory reclamation lags behind logical removal — important to understand when analyzing memory-usage spikes under high churn.