Гарантии гарантий безопасности

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

Это означает, что алгоритм без блокировки никогда не может иметь код, в котором один поток зависит от другого потока, чтобы продолжить. Например, код без блокировки не может иметь ситуации, когда Thread A устанавливает флаг, а затем Thread B продолжает цикл, ожидая, пока Thread A отключит флаг. Подобный код в основном реализует блокировку (или то, что я бы назвал мьютексом в маскировке).

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

Один из таких случаев находится в библиотеке (хорошо оцененный, afaik) concurrency, liblfds. Я изучал реализацию очереди с несколькими продюсерскими/многопользовательскими ограничениями в liblfds - реализация очень проста, но я не могу сказать, должно ли оно квалифицироваться как незаблокированное.

Соответствующий алгоритм находится в lfds711_queue_bmm_enqueue.c. Liblfds использует пользовательские атомы и барьеры памяти, но алгоритм достаточно прост для описания в абзаце или около того.

Сама очередь представляет собой ограниченный непрерывный массив (ringbuffer). Существует общий read_index и write_index. Каждый слот в очереди содержит поле для пользовательских данных и значение sequence_number, которое в основном похоже на счетчик эпох. (Это позволяет избежать проблем с ABA).

Алгоритм PUSH выглядит следующим образом:

  • Атомно LOAD write_index
  • Попытайтесь зарезервировать слот в очереди в write_index % queue_size, используя цикл CompareAndSwap, который пытается установить write_index на write_index + 1.
  • Если CompareAndSwap успешно, скопируйте данные пользователя в зарезервированный слот.
  • Наконец, обновите sequence_index на чтобы сделать его равным write_index + 1.

Фактический исходный код использует специальные помехи и барьеры памяти, поэтому для большей ясности в отношении этого алгоритма я кратко перевел его в (непроверенную) стандартную атомику С++ для лучшей читаемости, следующим образом:

bool mcmp_queue::enqueue(void* data)
{
    int write_index = m_write_index.load(std::memory_order_relaxed);

    for (;;)
    {
        slot& s = m_slots[write_index % m_num_slots];
        int sequence_number = s.sequence_number.load(std::memory_order_acquire);
        int difference = sequence_number - write_index;

        if (difference == 0)
        {
            if (m_write_index.compare_exchange_weak(
                write_index,
                write_index + 1,
                std::memory_order_acq_rel
            ))
            {
                break;
            }
        }

        if (difference < 0) return false; // queue is full
    }

    // Copy user-data and update sequence number
    //
    s.user_data = data;
    s.sequence_number.store(write_index + 1, std::memory_order_release);
    return true;
}

Теперь поток, который хочет POP-элемент из слота в read_index, не сможет этого сделать, пока не увидит, что слот sequence_number равен read_index + 1.

Хорошо, поэтому здесь нет мьютексов, и алгоритм, вероятно, работает хорошо (это только один CAS для PUSH и POP), но является ли это незаблокированным? Причина, по которой мне непонятно, состоит в том, что определение "прогресс" кажется мутным, когда существует вероятность того, что PUSH или POP всегда могут просто терпеть неудачу, если очередь считается полной или пустой.

Но для меня сомнительно, что алгоритм PUSH существенно резервирует слот, а это означает, что слот никогда не может быть POP'd, пока нисходящий поток не приблизится к обновлению порядкового номера. Это означает, что поток POP, который хочет получить значение, зависит от потока PUSH, завершившего операцию. В противном случае поток POP всегда будет возвращать false, потому что он считает, что очередь ПУСТОЙ. Мне кажется спорным, действительно ли это относится к определению "прогресса".

Как правило, поистине алгоритмы блокировки включают фазу, в которой предварительно выпущенный поток фактически пытается связать другой поток при завершении операции. Поэтому, чтобы быть действительно свободным от блокировки, я бы подумал, что поток POP, который наблюдает за PUSH в процессе выполнения, действительно должен попытаться завершить PUSH, а затем только после этого выполнить первоначальную операцию POP. Если поток POP просто возвращает, что очередь является ПУСТОЙ, когда выполняется PUSH, поток POP в основном блокируется до тех пор, пока поток PUSH не завершит операцию. Если нить PUSH замирает или уходит спать в течение 1000 лет или иначе заходит в забвение, поток POP ничего не может сделать, кроме как постоянно сообщать, что очередь ПУСТОЙ.

Так ли это подходит для отказа от блокировки? С одной стороны, вы можете утверждать, что поток POP всегда может прогрессировать, потому что он всегда может сообщить, что очередь является ПУСТОЙ (что, по крайней мере, является некоторой формой прогресса, я думаю). Но для меня это не делает прогресс, поскольку единственная причина, по которой наблюдение считается пустой, заключается в том, что мы заблокированы параллельной операцией PUSH.

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

Ответ 1

Эта структура данных в очереди не строго блокируется тем, что я считаю наиболее разумным определением. Это определение выглядит примерно так:

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

Конечно, это подразумевает подходящее определение пригодного для использования, но для большинства структур это довольно просто: структура должна продолжать подчиняться своим контрактам и позволять элементам вставляться и удаляться, как ожидалось.

В этом случае поток, которому удалось увеличить m_write_increment, но еще не написал s.sequence_number, оставляет контейнер в том, что скоро станет непригодным. Если такой поток будет убит, контейнер в конечном итоге сообщит как "полный", так и "пустой" на push и pop соответственно, нарушив контракт очереди с фиксированным размером.

Здесь есть скрытый мьютекс (комбинация m_write_index и связанного с ним s.sequence_number)), но он в основном работает как мутекс для каждого элемента. Таким образом, отказ становится очевидным только для писателей, когда вы зацикливаетесь, а новый писатель пытается получить мьютекс, но на самом деле все последующие авторы фактически не смогли вставить свой элемент в очередь, так как ни один читатель никогда не увидит его.

Теперь это не означает, что это реализация bad параллельной очереди. Для некоторых целей он может вести себя в основном так, как если бы он был заблокирован. Например, эта структура может иметь большинство полезных свойств производительности действительно незакрепленной структуры, но в то же время ей не хватает некоторых полезных свойств корректности. В основном термин "блокировка" обычно подразумевает целую кучу свойств, только подмножество которых обычно важно для любого конкретного использования. Давайте посмотрим на них один за другим и посмотрим, как это работает. Мы будем широко классифицировать их в рабочие и функциональные категории.

Производительность

Неконтрастная производительность

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

Эта реализация очереди выполняет здесь разумную работу: существует только одна "определенно дорогая" операция: compare_exchange_weak и несколько возможных дорогостоящих операций (загрузка memory_order_acquire и memory_order_release) 1 и немного других накладных расходов.

Это сравнивается с чем-то вроде std::mutex, который подразумевал бы что-то вроде одной атомной операции для блокировки, а другой для разблокировки, а на практике на Linux вызовы pthread также имеют незначительные накладные расходы.

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

Контрактная производительность

Одно из преимуществ блокировочных структур заключается в том, что они часто позволяют лучше масштабировать, когда структура сильно зависит. Это не обязательно является неотъемлемым преимуществом: некоторые блокированные структуры с несколькими блокировками или блокировки чтения и записи могут демонстрировать масштабирование, которое соответствует или превосходит некоторые блокировки, но обычно это тот случай, когда блокирующие структуры демонстрируют лучшее масштабирование, что простая альтернатива "один замок-правило-все-все".

Эта очередь работает в этом отношении разумно. Переменная m_write_index атомарно обновляется всеми читателями и будет предметом спора, но поведение должно быть разумным, если разумная реализация CAS для базового оборудования.

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

Иммунитет контекстного переключателя

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

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

Эта структура обеспечивает частичную защиту в этой области -— специфика которых зависит от размера очереди и поведения приложения. Даже если поток отключен в критической области между обновлением m_write_index и порядковым номером записи, другие потоки могут продолжать push элементы в очереди, если они не обертывают все вокруг -progress из заторможенного потока. Темы могут также pop элементы, но только до текущего элемента.

В то время как поведение push не может быть проблемой для высокопроизводительных очередей, поведение pop может быть проблемой: если очередь имеет высокую пропускную способность по сравнению со средним временем, в котором поток отключен контекстом, и средняя заполненность, очередь будет быстро казаться пустой для всех потребительских потоков, даже если есть много элементов, добавленных за элемент прогресса. Это не зависит от емкости очереди, а просто от поведения приложения. Это означает, что потребительская сторона может полностью остановиться, когда это произойдет. В этом отношении очередь не выглядит абсолютно незатронутой!

Функциональные аспекты

Асинхронное завершение резьбы

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

Это не относится к этой очереди, как описано выше.

Доступ к очереди через прерывание или сигнал

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

Эта очередь в основном поддерживает этот прецедент. Даже если сигнал или прерывание происходит, когда другой поток находится в критическом регионе, асинхронный код может все еще push содержать элемент в очереди (который будет отображаться только потом, потребляя потоки) и все еще может pop удалить элемент из очередь.

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


1 В частности, на x86 это будет использовать только атомную операцию для CAS, так как модель памяти достаточно сильна, чтобы избежать необходимости атомизации или ограждения для других операций. Недавние ARM могут действительно получать и выпускать достаточно эффективно.

Ответ 2

Я автор liblfds.

ОП правильно в своем описании этой очереди.

Это единственная структура данных в библиотеке, которая не блокируется.

Это описано в документации для очереди;

http://www.liblfds.org/mediawiki/index.php?title=r7.1.1:Queue_%28bounded,_many_producer,_many_consumer%29#Lock-free_Specific_Behaviour

"Следует понимать, что на самом деле это не структура данных без блокировки".

Эта очередь является реализацией идеи от Дмитрия Вьюкова (1024cores.net), и я только понял, что она не была без блокировки, пока я выполнял тестовый код.

К тому времени это работало, поэтому я включил его.

У меня есть некоторые мысли, чтобы удалить его, так как он не без блокировки.

Ответ 3

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

Однако я бы не сказал, что он квалифицируется как "очередь" - по крайней мере, не такая очередь, которую вы могли бы публиковать в виде очереди в библиотеке или что-то еще, - потому что это не гарантирует много поведение, которое вы обычно можете ожидать от очереди. В частности, вы можете PUSH и элемент, а затем попробуйте и FAIL для POP, потому что другой поток занят нажатием более раннего элемента.

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

Однако для многих приложений я буду беспокоиться о том, что потребительские потоки будут голодать, пока поток производителей будет предварительно упущен. Может быть, liblfds что-то с этим поделает?

Ответ 4

"Lock-free" - это свойство алгоритма, которое реализует некоторые функции. Свойство не коррелирует с каким способом, как данная функциональность используется программой.

Когда вы говорите о функции mcmp_queue::enqueue, которая возвращает FALSE, если основная очередь заполнена, ее реализация (заданная в сообщении вопроса) без блокировки.

Однако внедрение mcmp_queue::dequeue в режиме блокировки было бы затруднительным. Например, этот шаблон, очевидно, не блокируется, поскольку он вращается по переменной, измененной другим потоком:

while(s.sequence_number.load(std::memory_order_acquire) == read_index);
data = s.user_data;
...
return data;

Ответ 5

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

Ответ 6

Я провел формальную проверку этого кода с использованием Spin пару лет назад для курса по параллельному тестированию, и он определенно не свободен от блокировки.

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

  • Блокировка/блокировка: если другой поток отменяется по расписанию, и это может заблокировать мой прогресс, то он блокируется.

  • Без блокировок/без блокировок: если я в конечном итоге смогу добиться прогресса в отсутствие конкуренции со стороны других потоков, то он максимально блокирован.

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