Как добиться отсутствия блокировок, но блокирующего поведения?

#c #linux #blocking #lock-free

#c #linux #блокировка #без блокировок

Вопрос:

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

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

Как я могу эффективно блокировать поток до тех пор, пока он не сможет успешно удалить что-либо из очереди или не будет уничтожен / прерван?

Комментарии:

1. Привет, не могли бы вы поделиться со мной rps (количество запросов в секунду), которого вы достигли, используя этот подход? Я выполнял аналогичную работу (реализация простого HTTP-сервера), поэтому мне интересно узнать об этом. Я не знаю, как связаться, кроме как прокомментировать здесь. Извините, если я вас побеспокоил.

2. Производительность @Ayub была в порядке. RPS не является хорошей единицей измерения производительности из-за различных настроек оборудования и т.д. Я переработал приложение, чтобы позволить рабочим потокам работать в полной изоляции, и прирост производительности составил ~ 10 раз. Совместное использование меньшего количества данных действительно было ключевым.

3. Можете ли вы объяснить, почему вы выбрали подход с одной очередью на работника? Для меня звучит довольно неоптимально. Время выполнения заданий в очередях трудно предвидеть.

Ответ №1:

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

Комментарии:

1. Полезное разъяснение! На данный момент я поддержал оба ответа, связанные с Futex. Спасибо.

2. 1 за futex . Его производительность не так хороша, как без блокировок, но она достаточно хороша и является идеальным выбором, когда блокировка мьютекса слишком велика. API мьютекса pthread используется futex за кулисами.

3. Мьютексы в Linux реализованы с коротким cmpxchg вращением для случая низкой конкуренции и возвратом к futex вызову. Я действительно не понимаю, почему вы называете это неблокирующим, когда оно реализует блокировку (быстрый мьютекс пользовательского пространства — происхождение названия).

4. Я называю это неблокирующим из-за случая с низким уровнем конкуренции, который «обычно» возникает, если вы не находитесь под высокой нагрузкой…

Ответ №2:

В Linux для блокировки потока можно использовать futex. Но имейте в виду, что фьютексы — штука сложная!

ОБНОВЛЕНИЕ: переменные условия намного безопаснее в использовании, чем фьютексы, и они более переносимы. Однако переменная условия используется в сочетании с мьютексом, поэтому, строго говоря, результат больше не будет безблокировочным. Однако, если вашей основной целью является производительность (а не гарантия глобального прогресса), а заблокированная часть (т. Е. Условие для проверки после пробуждения потока) невелика, может случиться так, что вы получите удовлетворительные результаты без необходимости вдаваться в тонкости интеграции futexes в алгоритм.

Комментарии:

1. Это выглядит очень интересно. Я вскоре рассмотрю это и вернусь со своим вердиктом. Спасибо.

2. futex Вызов является реализацией блокировки . API mutex просто включается cmpxchg немного, а затем возвращается к futex (быстрый мьютекс пользовательского пространства).

Ответ №3:

Если вы работаете в Windows, вы не сможете использовать futexes, но в Windows Vista есть аналогичный механизм, называемый событиями с ключом. К сожалению, это не часть опубликованного API (это собственный API NTDLL), но вы можете использовать его, если принимаете предостережение о том, что он может измениться в будущих версиях Windows (и вам не нужно запускать на ядрах до Vista). Обязательно прочитайте статью, на которую я ссылался выше. Вот непроверенный набросок того, как это может работать:

 /* Interlocked SList queue using keyed event signaling */

struct queue {
    SLIST_HEADER slist;
    // Note: Multiple queues can (and should) share a keyed event handle
    HANDLE keyed_event;
    // Initial value: 0
    // Prior to blocking, the queue_pop function increments this to 1, then
    // rechecks the queue. If it finds an item, it attempts to compxchg back to
    // 0; if this fails, then it's racing with a push, and has to block
    LONG block_flag;
};

void init_queue(queue *qPtr) {
    NtCreateKeyedEvent(amp;qPtr->keyed_event, -1, NULL, 0);
    InitializeSListHead(amp;qPtr->slist);
    qPtr->blocking = 0;
}

void queue_push(queue *qPtr, SLIST_ENTRY *entry) {
    InterlockedPushEntrySList(amp;qPtr->slist, entry);

    // Transition block flag 1 -> 0. If this succeeds (block flag was 1), we
    // have committed to a keyed-event handshake
    LONG oldv = InterlockedCompareExchange(amp;qPtr->block_flag, 0, 1);
    if (oldv) {
        NtReleaseKeyedEvent(qPtr->keyed_event, (PVOID)qPtr, FALSE, NULL);
    }
}

SLIST_ENTRY *queue_pop(queue *qPtr) {
    SLIST_ENTRY *entry = InterlockedPopEntrySList(amp;qPtr->slist);
    if (entry)
        return entry; // fast path

    // Transition block flag 0 -> 1. We must recheck the queue after this point
    // in case we race with queue_push; however since ReleaseKeyedEvent
    // blocks until it is matched up with a wait, we must perform the wait if
    // queue_push sees us
    LONG oldv = InterlockedCompareExchange(amp;qPtr->block_flag, 1, 0);

    assert(oldv == 0);

    entry = InterlockedPopEntrySList(amp;qPtr->slist);
    if (entry) {
        // Try to abort
        oldv = InterlockedCompareExchange(amp;qPtr->block_flag, 0, 1);
        if (oldv == 1)
            return entry; // nobody saw us, we can just exit with the value
    }

    // Either we don't have an entry, or we are forced to wait because
    // queue_push saw our block flag. So do the wait
    NtWaitForKeyedEvent(qPtr->keyed_event, (PVOID)qPtr, FALSE, NULL);
    // block_flag has been reset by queue_push

    if (!entry)
        entry = InterlockedPopEntrySList(amp;qPtr->slist);
    assert(entry);

    return entry;
}
  

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

Комментарии:

1. Да, но ответы на futex уже были получены, и я подумал, что это может быть полезно для кого-то еще, кто будет искать по этому вопросу позже 🙂

2. Для полноты картины это интересно — я иногда разрабатываю для Windows. 🙂

3. 1, Также события с ключом отлично работают и на версии до Vista (изначально они были реализованы как единая глобальная блокировка для всех в случае неуправляемой ситуации взаимоблокировки с критическими разделами до 2k). Единственное различие между 2k / XP и Vista / 7 / 8 заключается в деталях реализации (связанный список против хэша), что делает Vista и более поздние версии KEs намного более эффективными, если вы просматриваете множество ячеек памяти с помощью одного дескриптора (никакой практической разницы для 99% всех приложений).

Ответ №4:

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

https://computing.llnl.gov/tutorials/pthreads/#ConditionVariables

Комментарии:

1. Любые вызовы, выполняемые с использованием функций pthread, вызовут вызов в ядро, что будет значительно медленнее, чем атомарные операции. Прелесть futex в том, что вы используете комбинацию atomics и очереди ожидания на уровне ядра, но если нет конфликта блокировок, то вы никогда не касаетесь очереди ожидания на уровне ядра. С другой стороны, использование переменных условий автоматически помещает поток в очередь ожидания, поэтому с переменными условий вы проходите через ядро, несмотря ни на что, что будет намного медленнее, если 90% времени атомарная операция будет в порядке.

2. @Jason: мьютексы pthread в Linux не попадают в ядро в случае, когда не оспаривается; также не подается сигнал cvar без официантов. Конечно, ожидание cvar должно войти в ядро, но так же должна поступать и любая операция блокировки

3. @Jason, Futex — это базовый примитив, используемый для создания мьютексов NPTL pthread. Немного сложно безопасно использовать futex () самостоятельно, поэтому библиотека pthread оборачивает некоторые из наиболее распространенных протоколов futex и называет их мьютексами и условными переменными. Таким образом, он также более переносим — futex () является системным вызовом только для Linux.

4. @bdolan: Ценю разъяснение. Означает ли это, что фьютексы и мьютексы pthread имеют одинаковую скорость в Linux, и нет никакой пользы в использовании фьютексов, поскольку мьютексы являются фьютексами? Или все еще существует снижение производительности для мьютексов, и если да, то от чего?

5. Мьютекс реализуется с коротким вращением для обработки случая с низким уровнем конкуренции, а затем системного вызова через futex . Не имеет смысла использовать futex напрямую, если вы не знаете, что делаете.

Ответ №5:

Вы можете перевести поток в спящий режим с помощью функции sigwait(). Вы можете запустить поток с помощью pthread_kill. Это намного быстрее, чем переменные условия.

Комментарии:

1. 1) Пожалуйста, предоставьте ссылку на ваше утверждение о том, что механизм, основанный на сигналах, будет «намного быстрее», чем переменная условия. В случае, когда поток должен проснуться, в обоих случаях аспекты планирования и кэширования играют огромную роль. 2) Пожалуйста, предоставьте схему того, как обрабатывать условия гонки и случай, когда поток не переходит в спящий режим, а скорее выбирает запись из непустой очереди. На практике этот быстрый путь является ключевым фактором масштабируемости / производительности под нагрузкой. И это становится немного сложнее, если у вас смешанная концепция.

Ответ №6:

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

 WAIT_TIME = 100; // Set this to whatever you're happy with
while(loop_condition) {
   thing = get_from_queue()
   if(thing == null) {
       sleep(WAIT_TIME);
   } else {
       handle(thing);
   }
}
  

Даже что-то короткое, например, спящий режим в 100 мс, должно значительно снизить загрузку процессора. Я не уверен, в какой момент переключение контекста сделает это хуже, чем ожидание занятости.

Комментарии:

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

2. Это не решает проблему так элегантно, как другие ответы.

3. @rmcclellan «Не самое лучшее» обычно не является веской причиной для снижения голосов, но делайте, что хотите. Я понимаю, что это решение не самое лучшее для операционной системы (как они прокомментировали выше), но ответы Stack Overflow также появляются в результатах поиска, и это хорошее решение в некоторых случаях (массовый трафик, когда вам не нужны блокировки при обработке результатов, но вы не хотите тратить время процессора, когда ничего не происходит).

4. Было бы неплохо упомянуть в ответе места, где это решение является хорошим.