it-swarm.com.ru

Гарантии прогресса без блокировок

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

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

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

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

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

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

Алгоритм Push заключается в следующем:

  1. АТОМНО ЗАГРУЗИТЬ write_index 
  2. Попытайтесь зарезервировать слот в очереди в write_index % queue_size, используя цикл CompareAndSwap, который пытается установить для write_index значение write_index + 1
  3. Если CompareAndSwap успешен, скопируйте данные пользователя в зарезервированный слот
  4. Наконец, обновите sequence_index в слоте , Установив его равным write_index + 1.

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

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, пока поток Push не сможет обновить порядковый номер. Это означает, что поток POP, который хочет получить значение зависит от потока Push, завершившего операцию. В противном случае поток POP всегда будет возвращать false, поскольку считает, что очередь пуста. Мне кажется спорным, действительно ли это подпадает под определение «достижения прогресса».

Как правило, по-настоящему безблокировочные алгоритмы включают в себя этап, на котором поток с опущенным потоком фактически пытается ПОМОЧЬ другому потоку в завершении операции. Поэтому, чтобы быть по-настоящему свободным от блокировки, я бы подумал, что поток POP, который наблюдает за выполняемым Push, должен будет на самом деле попытаться завершить Push, а затем только после этого выполнить исходную операцию POP. Если поток POP просто возвращает, что очередь пуста, когда выполняется Push, поток POP будет в основном заблокирован, пока поток Push не завершит операцию. Если поток Push умирает, или переходит в спящий режим на 1000 лет, или иным образом попадает в забвение, поток POP ничего не может сделать, кроме как постоянно сообщать, что очередь пуста. 

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

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

16
Siler

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

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

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

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

Здесь is скрытый мьютекс (комбинация 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 в очередь, пока они не будут полностью окружены in- progress элемент из застопорившегося потока. Потоки могут также pop элементы, но только до элемента in-progress.

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

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

Асинхронное завершение потока

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

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

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

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

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

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

.


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

8
BeeOnRope

Я автор 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), и я только понял, что она не была без блокировки, пока я выполнял тестовый код.

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

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

2
libflds-admin

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

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

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

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

1
Matt Timmermans

«Без блокировки» - это свойство алгоритма , которое реализует некоторую функциональность. Свойство не коррелирует с тем, как данная функциональность используется программой.

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

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

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

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

0
Saman Barghi