Аннотация

Большинство учебников предложит синхронизировать потоки с помощью мьютекса. Но под реальной high‑performance нагрузкой блокировки превращаются в кошмар планировщика ОС: несвоевременное вытеснение потока после захвата критической секции способно парализовать ваш пайплайн обработки данных, провоцируя многочисленные переключения контекста, уходы в спячку и сбрасывания кэшей процессора.

В качестве панацеи предлагают lock‑free структуры, но и тут полно ловушек — банальный регулярный вызов ядра SetEvent способен сжечь весь выигрыш от lock‑free. Сами алгоритмы lock‑free порой тяжеловесны, не всегда предлагают удачный trade‑off и даже не всегда уместны. Но что ещё хуже: будучи применёнными без должной тщательности, они могут не только не дать выигрыша, но даже навредить.

В этой статье мы разберём устройство нескольких базовых объектов библиотеки wxl и познакомимся с концепцией «алгоритм дешевеет под нагрузкой». Мы пройдём путь от трёх базовых инструкций процессора до готового канала, разберём, как продление release‑последовательности спасает от ABA, как ленивые триггеры arm/disarm позволяют будить поток только тогда, когда он реально спит, и как заставить данные летать между ядрами без обращений к операционной системе.

Продолжаем цикл статей о wxl, предыдущие статьи:

Lock‑free структуры wxl

Спойлер: в рамках одной статьи рассказать обо всех lock‑free структурах и трюках невозможно, эта статья будет первой из цикла публикаций по lock‑free тематике. В рамках данного материала мы познакомимся с двумя базовыми примитивами. Читать лучше подряд — каждый следующий раздел отвечает на вопрос, поднятый в предыдущем.

С чего всё начинается

Представьте два потока. Один производит данные, другой их обрабатывает. Между ними нужна быстрая и надёжная очередь.

Учебный ответ — мьютекс в связке с условной переменной (Condition Variable). Мьютекс защищает целостность очереди, а условная переменная отправляет поток‑потребитель в сон, если данных нет, чтобы не выжигать процессорное время на бессмысленные проверки. Если два потока сталкиваются на одной очереди, спин‑лок в клиентских ОС Windows позволяет ожидающему потоку не уходить в спячку сразу. Эта схема рабочая, пока критическая секция остаётся «короткой». На ней работает более 90% прикладного софта.

Но у блокировок есть неприятное свойство, которое портит идиллию: если текущий владелец мьютекса не успел освободить его до того, как планировщик Windows вытеснит поток из ядра, то остальные потоки‑претенденты тоже вынужденно засыпают (блокируются). В этот момент вся параллельность сводится на нет.

Поток — владелец ресурса проснётся только тогда, когда до него снова дойдёт очередь в планировщике ОС. При этом операционная система ничего не знает о том, насколько важные данные поток держал в кэше процессора и на каком конкретно месте его прервали. Хуже того, потоки иногда вытесняются «по чужой вине», ведь планировщик оперирует ещё и приоритетами! Может случиться так, что захвативший ресурс поток долго не получит необходимый ему (и остальным ожидающим) квант процессорного времени из‑за работы соседних, более приоритетных потоков.

Причём увеличение приоритета ожидающим потокам не помогает, если ресурс захвачен потоком с низким приоритетом. Эту непростую ситуацию планировщик Windows умеет обнаруживать и разрешать через механизм инверсии приоритетов (Priority Inversion), временно повышая приоритет заблокировавшему ресурс потоку. Однако этот механизм включается лишь тогда, когда блокировка на ресурсе стала совсем уж неприлично долгой или к нему выстроилась внушительная очередь. То есть средство спасения активируется тогда, когда катастрофа уже де‑факто происходит.

Разумеется, хотелось бы принципиально другого поведения: чтобы поток, положивший элемент в очередь, физически не мог заблокировать того, кто пытается положить следующий или прочитать имеющийся. Именно это и обещают lock‑free структуры данных. Мы же будем исследовать цену этих обещаний, вооружившись здраво‑циничным подходом «ничто не даётся даром».

Что даёт процессор для lock‑free

Немного. Обычно всего три вида атомарных операций над ячейкой памяти — но их вполне хватает:

  • Безусловный обмен (Swap, Exchange). Положить в ячейку новое (известное) значение A и забрать старое (неизвестное) X. Исполняется одной неделимой операцией.

  • Условный обмен (CAS, Compare And Swap / Compare And Exchange). Положить в ячейку новое значение A и забрать старое X, но только если в момент исполнения операции выполняется условие: неизвестное X равно ожидаемому B. Аналогично — одной неделимой операцией. Это самая интересная из трёх операций, потому что на ней строится большинство нетривиальной lock‑free логики.

  • Атомарная арифметика и логика. Инкременты, декременты и операции вида fetch_add (прибавить значение к ячейке памяти и вернуть то, что там было до сложения). Всё выполняется так, чтобы между фазами «прочитать» и «записать» ни у кого не было физической возможности перехватить или изменить модифицируемое значение целевой ячейки памяти.

Традиционно арифметические и логические атомарные операции присутствовали не во всех архитектурах (они почти всегда были в CISC и почти никогда в RISC), но их можно выразить через CAS примерно таким образом:

int fetch_add(int volatile& target, int value) {
    int old_value = target; 
    
    while (true) { 
        // CAS(ячейка, что записать, что должно быть в ячейке на момент операции)
        const int actual_value = CAS(target, old_value + value, old_value);
        
        // Если актуальное значение совпало с ожидаемым, обмен удался
        if (actual_value == old_value)
            return old_value;
    
        // Если не совпало, значит, кто-то нас опередил.
        // Пробуем ещё раз...
        old_value = actual_value;
    }
}

Плюс две вспомогательные команды, которые не меняют данные в памяти напрямую, но управляют поведением железа:

  • Барьер (std::atomic_thread_fence). Строгий запрет переупорядочивать инструкции чтения и записи. Процессор и компилятор обожают менять порядок независимых операций ради оптимизации, и в обычном коде это никого не волнует. В многопоточном коде — волнует, и ещё как. Без барьера некоторые алгоритмы, зависящие от упорядоченных причинно‑следственных связей, банально не работают. Существуют менее строгие барьеры, например, обеспечивающие одностороннюю зависимость. Весь этот «градиент гарантий» принято называть «моделью памяти» и об этом будет подробней в одной из следующих статей.

  • Пауза (core::cpu_pause()). Сигнал процессору: «я сейчас кручусь в холостом цикле ожидания, займи мои исполнительные блоки чем‑нибудь полезным». На архитектурах с поддержкой SMT (Hyper‑Threading) эта команда временно отдаёт вычислительные ресурсы ядра соседнему аппаратному потоку.

Что значит «lock‑free»

Рассмотрим типичный отрывок lock‑free кода — это цикл до успешного выполнения CAS:

Node* top = top_.load(std::memory_order_relaxed);
do {
    element->next_ = top;                                   // подготовились
} while (!top_.compare_exchange_weak(top, element, ...));   // попробовали

Такая конструкция на первый взгляд может вызвать беспокойство: а что, если обмен не произойдёт никогда? Формально в рамках одного потока такое возможно. Но присмотритесь, что на самом деле означает неудачный CAS? Сравнение не прошло только по одной причине: кто‑то другой успел изменить top_ мгновением раньше.

В этом и заключается гарантия lock‑free: система в целом движется всегда. Отдельному потоку в конкретный момент может не повезти, но затормозить остальных он физически не способен — в отличие от зависшего владельца мьютекса, которого ОС не вовремя сняла с процессора. Лишний круг в цикле CAS — не дефект алгоритма, а плата за то, что соседнему потоку прямо сейчас повезло больше.

При отсутствии конкуренции этот цикл выполняется ровно один раз. И отсюда же понятно, почему вероятность столкновения и повторного захода в цикл невероятно мала — из‑за крайне узкого временного окна потенциального столкновения «шириной» в длительность одной interlocked‑инструкции.

Именно поэтому lock‑free структуры ведут себя так эффективно в реальных условиях: фактическая конкуренция за одну и ту же ячейку памяти — это невероятно редкое событие в хорошо спроектированных системах.

Именно поэтому в wxl нет специальных средств обыгрывания конкуренции на атомарных операциях. В классической теории конкурентных lock‑free алгоритмов предлагается обыгрывание через экспоненциальное увеличение времени между попытками при повторяющемся невезении на CAS (этот приём называется exponential back‑off), но в наших структурах никакого дополнительного обыгрывания невезения нет — тем более что любое обыгрывание небесплатно.

Вместо обобщённого обыгрывания на низком уровне можно использовать эти знания на более высоких слоях. Это знание выражается не через ухудшение метрики зацепления статического API типов, а через учёт особенностей динамики lock‑free алгоритмов низкого уровня.

Как это работает в целом? Неудачная попытка на нашей стороне означает, что выигравший гонку конкурентный поток не будет прямо сейчас повторно долбить в ту же ячейку. Это позволяет ограничиться самым простым (и полезным для системы в целом) ожиданием на единичном cpu_pause(), а иногда и вовсе обходиться без таких ожиданий за ненадобностью.

Общая же рекомендация простая — избегать постоянного давления на одну ячейку, чтобы не генерировать паразитный трафик поддержания когерентности между кэшами ядер. Стараться делать максимум полезной работы с локальными данными между операциями обращения к разделяемой памяти. Большинство необходимых прикладных операций дано «из коробки» — эти рекомендации уже зашиты в ДНК кода wxl. Но поскольку все структуры открыты для построения кастомных пайплайнов, для вашего собственного кода озвученные рекомендации становятся не просто «пожеланиями», а строгими инженерными требованиями.

atomic_trigger: кто из нас первый?

Простейшая, но крайне полезная lock‑free структура. Иногда между потоками нужно передать не массив данных, а одно‑единственное событие или скоординировать некоторое решение: например, «пора останавливаться». Причём сделать это так, чтобы связанные с решением действия выполнились ровно один раз — сколько бы потоков одновременно ни пытались взвести или сбросить этот триггер.

core::atomic_trigger — это легковесный флаг, решающий задачи такого рода. Он заменяет классическую связку из булевой переменной и мьютекса, позволяя конкурирующим потокам не блокировать друг друга.

Основное API:

bool set();                 // true, если флаг взвёл именно я
bool reset();               // true, если флаг сбросил именно я
[[nodiscard]] bool read();  // прочитать и сбросить

И жёсткие гарантии — целевое действие будет совершено строго однократно и именно тем потоком, который выиграл гонку на триггере:

if (stop_trigger_.set()) 
    task_queue_.signal();   // выполнится ровно один раз

После вызова метода set() (или зеркального reset()) вы достоверно знаете не просто текущий статус флага, а сам факт того, стали ли именно вы автором этого изменения. Для проигравших потоков ответ false означает: «кто‑то уже совершил это действие до нас либо прямо сейчас меняет состояние флага».

Небольшой нюанс: метод read() делает ровно то же самое, что и reset(), но назван исходя из своей семантики и снабжён атрибутом [[nodiscard]]. Смысл вызова read() — в обязательном получении возвращаемого значения, тогда как смысл reset() — в самом факте сброса флага. Разница между ними продиктована принципом «комментировать следует только тот код, который при чтении вызывает вопросы». Различающиеся идентификаторы и добавочное ограничение на возвращаемый по read() результат при одинаковой механике служат сугубо для удобства выражения прикладной логики над триггером.

drain_stack: стек, у которого забирают всё сразу

Еще одна простая структура экосистемы — это интрузивный односвязный список, у которого модифицируется исключительно голова.

Благодаря интрузивности стек не требует алокаций в процессе работы:

void push(Node* element, std::memory_order order = std::memory_order_release) noexcept {
    Node* top = top_.load(std::memory_order_relaxed);

    do {
        element->next_ = top;                                    // (1)
    } while (!top_.compare_exchange_weak(top, element, order,    // (2)
                                         std::memory_order_relaxed));
}

Без конкуренции всё это стоит ровно одну атомарную операцию на элемент. При этом вызывать push можно параллельно хоть из сотни потоков.

А теперь — главный архитектурный ход. Потребитель не снимает элементы по одному. Он забирает весь стек целиком, одной атомарной командой:

Node* pop_all() noexcept {
    if (top_.load(std::memory_order_relaxed) == nullptr) [[likely]] 
        return nullptr;
        
    return top_.exchange(nullptr, std::memory_order_acquire);
}

Вершина обнуляется, и вся накопившаяся цепочка элементов целиком переходит во владение вызвавшего потока. Производители тем временем могут сразу же набивать новую. Нам потребовалась всего одна атомарная операция — причём на сколько угодно элементов сразу.

Почему эта схема обходит проблему ABA

ABA — классический шоу‑стоппер CAS‑алгоритмов над данными. Классический стек Трайбера, забирающий элементы по одному, болеет этой проблемой с рождения и требует дополнительных действий для обеспечения корректности работы. С решением этой проблемы полезно познакомиться именно на примере drain_stack, поскольку здесь ABA физически не способна нам навредить.

Посмотрите на точки (1) и (2) в коде push. Между этими инструкциями другой поток может забрать элемент, на который сейчас смотрит наш next_, а затем вернуть этот же элемент обратно на вершину до того, как мы доберёмся до строки (2). Это и есть классический признак ABA‑проблемы. Целевой узел мог быть даже освобождён в общую кучу, затем повторно выделен и снова помещён в стек.

Многие lock‑free алгоритмы, которые полагаются на принцип «тот же самый адрес означает тот же самый объект», ломаются прямо здесь, перезаписывая свои ячейки устаревшими невалидными данными.

А что у нас? Наш CAS увидит на вершине знакомый адрес и пройдёт успешно, хотя стек между чтением и модификацией изменился. Однако, что бы ни случилось между шагами (1) и (2), после успешного CAS состояние стека гарантированно остаётся корректным, и ни один узел не теряется. Мы всего лишь дописываем свой новый элемент в голову той цепочки, которую увидели по факту.

Итог: стратегия изъятия всей цепочки данных разом вместо поштучного запроса элементов не устраняет сам сценарий ABA, но делает алгоритм иммунным к этой проблеме — состояние стека остаётся достоверно валидным при любой комбинаторике возникающих ABA‑ситуаций.

Порядок памяти

Процедура push публикует данные через release, потребитель в pop_all забирает их через acquire — и этой пары гарантированно хватает для синхронизации всей цепочки элементов, а не только самой вершины. Причина здесь по‑своему красивая: каждый следующий push — это тоже атомарная операция класса read‑modify‑write над вершиной. Согласно стандарту C++, такой шаг продлевает общую release‑последовательность (release sequence), начатую самым первым элементом. В итоге одного‑единственного acquire на стороне потребителя достаточно, чтобы надёжно синхронизироваться со всеми писателями сразу.

Параметр order существует здесь ради специфического сценария, когда компонент mpsc_channel читает другую атомарную переменную сразу после вызова push, и ему критически важно, чтобы эти два обращения были упорядочены между собой. Классический release обеспечить упорядочивание в духе «сначала моя запись, затем чужое чтение» (Store‑Load) не способен. В одной из следующих статей будет подробный разбор Store‑Load гонок. Мы увидим, как достигаемые минимальными затратами межпоточные гарантии позволяют всё так же красиво — простыми операциями над всего одной атомарной переменной — решать нетривиальные прикладные задачи, которые обычно требуют сложных структур данных и непростых алгоритмов поверх них.

mpsc_queue: очередь, которая дешевеет под нагрузкой

У базового lock‑free стека есть два очевидных неудобства: элементы из него выходят пачкой и в строго обратном порядке (LIFO). Реализацияmpsc_queue убирает оба недостатка — и попутно приобретает уникальное свойство, ради которого всё и замышлялось.

Раз нижележащий контейнер умеет выдавать данные только по принципу «забрать всё разом», то очередь хранит локальный указатель на текущую обработанную порцию и монопольно раздаёт элементы из неё поштучно:

Node* pop_one() noexcept {
    if (Node* node = reader_node_) {          // дёшево: ни одной атомарной операции
        reader_node_ = node->next_;
        return node;
    }

    if (Node* element = stack_.pop_all()) {   // одна атомарная операция на всю порцию
        if constexpr (order == drain_order::fifo)
            element = details::reverse_linked_list(element);
        reader_node_ = element->next_;
        return element;
    }

    return nullptr;
}

Вся порция разворачивается ровно один раз прямо в момент захвата (если выбран режим FIFO, хотя для многих задач сохранения порядка и не требуется — именно поэтому дисциплина обработки зашита в параметр шаблона, а не в аргумент конструктора). После этого каждый последующий вызов pop_one сводится к дешёвому чтению неатомарного поля и присваиванию. Никаких атомарных операций, никакой синхронизации.

А теперь считаем накладные расходы:

  • Запись в очередь стоит одну атомарную инструкцию на элемент.

  • Чтение стоит одну атомарную инструкцию на целую пачку.

Чем сильнее нагружена очередь, тем крупнее пачка элементов, успевшая накопиться к моменту захвата, и тем меньше атомарных операций процессора приходится на каждый отдельный элемент. Структура данных становится дешевле именно тогда, когда нагрузка на систему максимальна. В традиционном коде обычно бывает строго наоборот.

Расплата за такую скорость — жёсткий контракт MPSC (Multi‑Producer Single‑Consumer): отправлять данные в очередь можно из какого угодно количества потоков, но читать их следует строго из одного потока. Поле reader_node_ — это самое обычное неатомарное поле, принадлежащее читателю, и городить вокруг него потоковую синхронизацию значило бы потерять весь выигрыш. По этой же причине метод empty() возвращает валидный статус только при вызове на потоке‑потребителе. Это фундаментальное свойство всех MPSC‑объектов в экосистеме wxl, и оно зафиксировано в док‑комментариях к коду.

False sharing

Отдельного упоминания стоит внутренняя топология класса. Взгляните на поля mpsc_queue:

alignas(std::hardware_destructive_interference_size) drain_stack<Node> stack_;
alignas(std::hardware_destructive_interference_size) size_t pops_since_claim_;
Node* reader_node_;

При доступе к соседним ячейкам памяти из разных потоков в полный рост встаёт проблема false sharing (ложного разделения).

Писатели постоянно бьют в атомарную вершину stack_, переводя кэш‑линию в состояние Modified, в то время как единственный читатель монопольно крутит счётчик pops_since_claim_ и неатомарный reader_node_. Если бы эти переменные оказались в одной 64-байтной кэш‑линии процессора, потоки непрерывно инвалидировали бы кэши друг другу, перегружая аппаратные механизмы обеспечения когерентности приватной памяти ядер процессора.

Использование alignas со стандартной константой деструктивной интерференции разносит эти данные по разным физическим кэш‑линиям. Это классический пример того, как осмысленная раскладка данных в памяти защищает ваш код от аппаратных тормозов.

mpsc_channel: ждать, не заходя в ядро

Осталось решить последнюю критическую задачу: что делать потребителю, когда внутренняя очередь пуста? Ответ очевиден — ждать. Но именно на этом шаге традиционные lock‑free подходы сталкиваются с суровой рантайм‑реальностью.

При проведении измерений выяснилось, что вызовы функций ОС, работающих с тяжёлыми объектами ядра (семафорами, событиями), стоят непозволительно дорого. В сценариях, максимально близких к боевым, минимизация количества системных вызовов подняла общую пропускную способность каналов передачи данных от 7 до 30+ раз. Причём этот эффект проявляется тем ярче, чем мельче идут порции данных.

При большом количестве параллельных потоков просадка традиционной схемы становится ещё заметнее: активно вызываемые функции ядра начинают тормозить всю операционную систему, а не только наше приложение. В итоге вся экономия на дешёвых атомарных операциях сгорает на вызовах SetEvent на каждый отправленный элемент.

Компонент mpsc_channel спроектирован поверх mpsc_queue и решает именно эту проблему. Ключевая идея: производитель трогает системный примитив синхронизации только тогда, когда потребитель действительно заснул. Узнаёт он об этом по специальному атомарному триггеру, который читатель «взводит» перед тем, как уйти в сон — отсюда и названия методов механизма: arm() и disarm().

Выигрыш от этой схемы снова масштабируется вместе с ростом нагрузки. Если потребитель прямо сейчас активно разгребает текущую порцию данных, а производители за это время успели набросать в канал ещё десяток элементов, читатель не уходит в сон. Триггер ожидания остаётся невзведённым, и вся новая пачка элементов проваливается в очередь вообще без дёрганья примитивов синхронизации уровня ядра ОС.

Этот протокол взаимодействия выглядит обманчиво просто, но в нём скрыто классическое «тонкое место» — race condition окно, когда писатель уже проверил статус триггера, а читатель физически ещё не успел уснуть. Полный разбор этого граничного сценария с математическим доказательством того, что пробуждение потока никогда не потеряется, а также пояснения к детальной расстановке барьеров памяти вынесены в отдельную обещанную статью.

При интеграции межпоточного канала в ваши проекты стоит обратить внимание на следующее:

  • Примитив ожидания вынесен в параметр шаблона. Внешнему коду часто требуется ожидать данные не на одном изолированном канале, а в рамках групповой операции — например, через WaitForMultipleObjects либо IOCP‑хэндл в Windows или select/poll‑подобные механизмы в других системах. Для поддержки таких сценариев класс предоставляет методы get_wait_object() и wait_handle(). Если внешний код самостоятельно обслуживает уход потока в сон и пробуждение, то он должен сам же вести протокол синхронизации arm() / disarm().

  • Метод receive() работает по контракту, аналогичному Condition Variable. Он возвращает bool, при этом значение false не является гарантированным свидетельством таймаута: здесь легитимны ложные пробуждения (spurious wakeups). Вызывающий поток обязан крутить проверку в цикле по собственному предикату ровно так же, как это делается при работе с условными переменными:

while (!channel.receive(element)) {
    if (stop_requested) return;
}

Отсюда же автоматически вытекает ответ на частый вопрос: «Как принудительно остановить поток‑читатель?». Сам канал этого делать не умеет и по правилам разделения ответственности уметь не должен. Метод signal(force = true) всего лишь принудительно будит спящий поток один раз, а финальное решение о выходе из цикла обработки принимает внешний флаг остановки, который контролируется владельцем бизнес‑логики.

Исходники:

Впереди по этой теме:

  • Протокол arm/disarm. Схема Деккера и немного дискретной математики, приложенной к моделям памяти.

  • Турникет. Как надёжно закрыться, когда все вышли.

  • Lock‑free Future с монадическим интерфейсом. Почему они ходят задом наперёд.

  • “Дышащая” SPSC‑очередь. Решение, которое работает примерно вдвое эффективнее схемы, реализованной в LMAX Disruptor.

  • task_tree. Как счётчик с флагом в одной атомарной переменной делает динамику на порядки мощнее статики.

Комментарии (36)


  1. dyadyaSerezha
    30.09.2026 01:05

    Дошёл пока только до этого:

    несвоевременное вытеснение потока после захвата критической секции

    И вопрос: а если бы там не было критической секции, его бы не вытеснило? Или вытеснило бы с гораздо меньшей вероятностью? Или что? Непонятно.


    1. vdimas Автор
      30.09.2026 01:05

      Речь шла не о вероятности вытеснения как таковой, а последствиях вытеснения в неудачный момент при использовании классического подхода на мьютексах.


      1. dyadyaSerezha
        30.09.2026 01:05

        Так чем же последствия в этом случае хуже, чем последствия вытеснения на таком же участке аналогичного кода с lock-free вариантом?

        Я отнюдь не спорю, я лишь хочу, чтобы вы четко и ясно объяснили различия.


        1. vdimas Автор
          30.09.2026 01:05

          Разница в том, что вытесненный поток, исполняющий lock-free алгоритм над разделяемой между потоками структуре, не провоцирует блокировку (внужденный уход в спячку) других потоков, конкурирующих за доступ к этой структуре.

          Прошу пояснить саму причину этого вопроса - из текста статьи был не понятен сценарий, при котором вытесненный поток, владеющий мьютексом (или критической секцией) блокирует остальные потоки, которым нужен тот же самый ресурс?

          Я исходил из того, что проблематика эта известная, поэтому считал, что достаточно упомянуть сам сценарий, который является основным мотивом применения lock-free структур.


          1. eao197
            30.09.2026 01:05

            Разница в том, что вытесненный поток, исполняющий lock-free алгоритм над разделяемой между потоками структуре, не провоцирует блокировку (внужденный уход в спяку) других потоков, конкурирующих за доступ к этой структуре.

            Если говорить об абстрактных конях в вакууме, то есть некий общий ресурс R, к которому хотят обратиться потоки T1, T2, T3. На “тяжелых” примитивах синхронизации T1 захватывает замок ресурса R после чего ОС снимает T1 с CPU. Когда потоки T2 и T3 пытаются захватить тот же самый замок ресурса R, то ОС их приостанавливает и ставит в очередь. Полезной работы T2 и T3 не делают пока T1 не проснется и не освободит замок R.

            В случае с lock-free T1 начинает операцию с R и выставляет какие-то атомарные флаги, которые говорят, что R в данный момент принадлежит T1. После чего ОС снимает T1 с CPU. Когда потоки T2 и T3 захотят получить доступ к R они увидят те самые атомарные флаги, выставленные T1 и войдут в цикл ожидания. Однако, полезной работы T2 и T3 не делают, пока T1 не проснется и не освободит R.

            С этой точки зрения нет особой разницы, будут ли T2 и T3 честно спать в каких-то очередях ОС, или же они будут крутить циклы с чтением атомиков.

            Поэтому сделаное в статье введение и вызывает вопрос, озвученный @dyadyaSerezha.


            1. vdimas Автор
              30.09.2026 01:05

              В случае с lock-free T1 начинает операцию с R и выставляет какие-то атомарные флаги, которые говорят, что R в данный момент принадлежит T1. После чего ОС снимает T1 с CPU. Когда потоки T2 и T3 захотят получить доступ к R они увидят те самые атомарные флаги, выставленные T1 и войдут в цикл ожидания. Однако, полезной работы T2 и T3 не делают, пока T1 не проснется и не освободит R.

              Спасибо, теперь вопрос понятен.

              Ответ: в случае lock-free T1 никогда не переводит общую структуру в такое состояние, в котором структура "принадлежит" выделенному потоку. В любой момент времени структура доступна конкурирующим потокам Tn согласно обещанной модели потоковой безопасности.

              Например, drain_stack является MPMC-структурой (Multiple Producers / Multiple Consumers), т.е. произвольное количество потоков могут одновременно писать и читать из этой структуры.

              Далее, mpsc_queue обеспечивает уже другую дисциплину - Multiple Producers / Single Consumer. Писать в эту очередь может произвольное количество потоков, а читать только один (либо требуется синхронизация на стороне чтения).

              И даже в случае SPSC сценария (один писатель и один читатель), всё еще присутствует межпоточная конкуренция за ресурс между писателем и читателем, пусть даже конкуренция за каждую из операций записи и чтения ограничена одним участником.

              Поэтому сделаное в статье введение и вызывает вопрос

              Про введение понятно, оно заведомо ограничено по объёму, и ставит целью кратко обрисовать проблематику.

              Вызывает ли дополнительные вопросы материал, начинающийся со слов "хотелось бы принципиально другого поведения: чтобы поток, положивший элемент в очередь, физически не мог заблокировать того, кто пытается положить следующий или прочитать имеющийся"?


              1. eao197
                30.09.2026 01:05

                Вызывает ли дополнительные вопросы материал, начинающийся со слов “хотелось бы принципиально другого поведения: чтобы поток, положивший элемент в очередь, физически не мог заблокировать того, кто пытается положить следующий или прочитать имеющийся”?

                Это вряд ли ко мне вопрос.

                С моей колокольни тут важнее бы поставить акцент на то, про какую блокировку идет речь – блокировка потока на уровне ОС (т.е. физическая) или же на уровне прикладной логики (т.е. логическая).

                Т.к. с точки зрения прикладной логики пока поток крутится на атомарных чтениях в ожидании пока очередь перестанет быть пустой, то логически он заблокирован и полезной работы не делает.

                Соответственно, тут важный вопрос: если очередь пуста, мы ничего полезного не делаем, а тупо жгем электричесво в цикле на атомарном чтении, то в чем выгода от использования lock-free в сравнении с “тяжелыми” и “честными” примитивами? И если статья вводная, то можно было бы пару слов сказать, чтобы эти самые выгоды lock-free явным образом озвучить.


                1. vdimas Автор
                  30.09.2026 01:05

                  "Голая" очередь spsc_queue ничего не знает про распределение ресурсов ОС. Её задача — обеспечить максимум пропускной способности когда данные есть.

                  Если данных нет, крутиться в цикле 10 GOTO 10 и жечь электричество — это удел либо HFT-систем, либо не до конца проработанных библиотек. Пример — популярный логгер quill. В базовых сценариях он именно так себя и ведет: ему монопольно отдают целое ядро процессора, и он полирует пустую очередь в своём spin-wait. В реальном телекоме (например, в софте для базовых станций, не буду покаывать пальцем, хотя ты в курсе, о ком речь) такое встречается сплошь и рядом просто потому, что низкоуровневой оптимизацией там часто некому заняться. Да и в целом выглядит так, что всем несколько пофик - высокоуровневые спецы там лишние, потому что код надо будет обслуживать армией "обычных разработчиков" - такова забавная политика отечественного софтостроения.

                  В масштабируемой архитектуре поверх очереди садится компонент spsc_channel, который решает эту проблему через ленивый протокол arm / disarm:

                  1. Когда читатель видит, что очередь пуста, он не крутится в цикле бесконечно (для HFT обычно ограничивают холостые обороты в 10 микросекунд - за это время успевает "остыть" память, т.е. дальше жечь ресурсы обычно нет смысла по причине особенностей современного железа). Читатель переводит канал в состояние arm («взводит курок») и делает финальную проверку.

                  2. Если данных всё еще нет, читатель уходит в сон на системном примитиве (будь то futex, event, семафор, поток со множеством сокетов на epoll/IOCP), освобождая ресурсы другим страждущим.

                  3. Писатель на своем горячем пути просто бросает данные в блоки. Он выполняет системный вызов пробуждения (wake) только тогда, когда видит флаг arm (то есть знает, что читатель точно спит и его точно надо пнуть).

                  В итоге получаем лучшее из обоих миров:

                  • На горячем пути (всплеск трафика): Полный lock-free, ноль системных вызовов, даже минимум interlocked-операций и невероятная пропускная способность, от озвучивания цифр которой икают те, "кто в теме".

                  • В моменты "простоя" (собщения приходят реже, чем раз в 20+ микросекунд): канал отдаёт ресурсы другим участникам, которые за столь ужаcно долгое время (по меркам lock-free) успевают сделать массу полезной работы.

                  Т.е.за цену базовой механики многих популярных диспетчеров можно выполнить вообще всю полезную бизнес-логику.

                  А освободившиеся ресурсы можно отдать под числодробления (тоже правильно организовав граф скольжения данных, ес-но), т.е. под то, для чего современные мощности и нужны, собсно.

                  «Тяжелые» примитивы (вроде std::mutex / std::condition_variable) на горячем пути неприменимы, потому что любой mutex при конкуренции уводит поток в контекст-свитч ядра ОС. Это потеря от 500 до 2000 нс на каждое переключение, если произошло столкновение на критической секции. В грамотно раскиданном lock-free за время одного такого системного "вздоха" успевается обслужиться сотни прикладных сообщений.

                  Про детальную математику протокола arm/disarm и защиту от гонок в момент засыпания подробно будет. Оно того стоит, ИМХО. Очень просто, сама идея "на поверхности", но в реальном софте применяется на удивление редко.


                  1. eao197
                    30.09.2026 01:05

                    Т.е. в сухом остатке речь идет о стоимости “пробуждения” нити, которая была вынуждена ждать. В случае, если мы честно физически заснули на “тяжелом” примитиве ОС, то разбудить заснувший тред дорого. Тогда как если вместо “сна” мы в цикле делаем опрос, то при обнаружении готовности (не пустоты) очереди мы тут же начинаем обработку без дополнительных дорогих телодвижений.


                    1. vdimas Автор
                      30.09.2026 01:05

                      Эти рассуждения зависят от реальный таймингов операций. Да, регулярный заход в ядро и обратно на одном и том же примитиве — примерно микросекунда (плюс‑минус полтора раза), если редко — уже под 5–10 микросекунд.

                      Доступ к горячей памяти — почти ноль наносекунд (от долей одной до полутора). Доступ к остывающей ухудшается ступеньками, смотря из какого уровня кеша успели улететь. Причём, улетают тоже ступеньками — через 10 микросекунд деградация из L1 в L2, через 40–50 микросекунд деградация до L3, через 100+ микросекунд гарантированно вымывает из кеша проца.

                      Поэтому, простое спин‑ожидание даёт аж ничего. Да, чтение одной изолированной ячейки из холодной RAM — это под 100 наносекунд. Но в реальном приложении поток обрабатывает не одно число, а графы объектов. В зависимости от лейаута в памяти чтение сообщения порой превращается в последовательный обход цепочки указателей. Если эти структуры успели остыть, то каждый прыжок по ссылке генерирует свой независимый промах мимо кэша. В итоге суммарная задержка на расковыривание холодного и кучерявого бизнес‑объекта (банально просмотр строки длиннее 15 символов, то есть которая уже в куче) легко набегает на 4–5 микросекунд при средних паузах и улетает далеко за 10 микросекунд, если данные совсем вымыло из кэша.

                      Это сопоставимо с ценой захода в ядро ОС, что делает ожидание на примитивах сигнализации легальным и оно же диктует способ борьбы с ситуацией, если хочется тру спин‑лока: в HFT‑системах на спин‑ожиданиях гоняют по кругу так называемые «warmup»‑вызовы — это проход по реальному коду обработки приходящего пакета с флагом «warmup», то есть вся цепочка делает что и должна, но конечный результат никуда не отправляет или отправляет в юзер‑спейсный драйвер тоже с пометкой warup. И только тогда по приходу пакета можно уложиться в субмикросекундный полный цикл.

                      ===

                      И да, любые такие рассуждения дожны отталкиваться от системы оценок - что именно мы оптимизируем? Если общую производительность, то увод простаивающего потока вычислений в сон - обязательная опция ради освобождения ресурсов. А когда оптимизируют только latency, выжимая лишние пару сотен наносекунд за счёт деградации общего throughput системы - я такое расточительство видел исключительно и только в HFT, и оно имеет смысл только если брокерские сервера физически ОЧЕНЬ близко расположены к серверами биржи - желательно в соседних стойках, как и организованы многие деривативные эти ипподромы лохотроны. ))

                      При этом, можно на архитектурном уровне так раскидать примитивы синхронизации, что они будут обслуживать "сразу всех" - например, как привязка кучи сокетов к одному потоку, тогда стоимость вечно горячего примитива сигнализации становится минимально возможной - до микросекунды, и основные тормоза получаются уже такие, на которые мы не можем повлиять - это простые тормоза простого доступа к памяти у остывших блоков.


                  1. dyadyaSerezha
                    30.09.2026 01:05

                    Спасибо, вот теперь всё вроде понятно. Но порадовало это:

                    Если данных нет, крутиться в цикле 10 GOTO 10 и жечь электричество — это удел либо HFT-систем…

                    Вау, HFT на Фортране, это круто)


                    1. vdimas Автор
                      30.09.2026 01:05

                      На BASIC ))