Спинлоки, насколько они полезны?
Как часто вы на самом деле используете спин-блокировки в своем коде? Насколько часто встречается ситуация, когда использование занятого цикла фактически превосходит использование блокировок?
Лично, когда я пишу какой-то код, который требует безопасности потоков, я склонен сравнивать его с различными примитивами синхронизации, и, насколько это возможно, использование блокировок дает лучшую производительность, чем использование спин-блокировок. Независимо от того, как мало времени я фактически удерживаю блокировку, количество разногласий, которые я получаю при использовании спин-блокировок, намного больше, чем количество, которое я получаю от использования блокировок (конечно, я запускаю свои тесты на многопроцессорной машине).
Я понимаю, что в "низкоуровневом" коде более вероятно встретить спин-блокировку, но мне интересно знать, находите ли вы его полезным даже в более высокоуровневом программировании?
9 ответов
Это зависит от того, что вы делаете. В общем коде приложения вам нужно избегать спин-блокировок.
В низкоуровневых вещах, где вы будете удерживать блокировку только для пары инструкций, а задержка важна, коврик для спин-блокировки будет лучшим решением, чем блокировка. Но такие случаи редки, особенно в тех приложениях, где обычно используется C#.
В C# "спин-блокировки", по моему опыту, почти всегда хуже, чем взятие блокировки - это редкое явление, когда спин-блокировки превосходят блокировку.
Однако это не всегда так. .NET 4 добавляет структуру System.Threading.SpinLock. Это дает преимущества в ситуациях, когда блокировка удерживается в течение очень короткого времени и повторно захватывается. Из документов MSDN о структурах данных для параллельного программирования:
В сценариях, где ожидание блокировки, как ожидается, будет коротким, SpinLock предлагает лучшую производительность, чем другие формы блокировки.
Спин-блокировки могут превзойти другие механизмы блокировки в тех случаях, когда вы делаете что-то вроде блокировки через дерево - если у вас есть блокировки на каждом узле в течение очень, очень короткого периода времени, они могут выполнить традиционную блокировку. Я столкнулся с этим в механизме рендеринга с многопоточным обновлением сцены, в какой-то момент - профилированные спин-блокировки, превосходящие блокировку с помощью Monitor.Enter.
Для моей работы в реальном времени, особенно с драйверами устройств, я использовал их немного. Оказывается, что (когда я это делал в последний раз) ожидание синхронизирующего объекта, такого как семафор, привязанный к аппаратному прерыванию, жует не менее 20 микросекунд, независимо от того, сколько времени на самом деле требуется для возникновения прерывания. Однократная проверка аппаратного регистра, отображаемого в памяти, с последующей проверкой RDTSC (чтобы обеспечить тайм-аут, чтобы вы не блокировали машину) находится в высоком наносекундном диапазоне (в основном из-за снижения уровня шума). Для рукопожатия на аппаратном уровне, которое не должно занимать много времени, действительно сложно победить спин-блокировку.
Мой 2c: Если ваши обновления удовлетворяют некоторым критериям доступа, то они являются хорошими кандидатами на спин-блокировку:
- быстро, т. е. у вас будет время приобрести спин-блокировку, выполнить обновления и освободить спин-блокировку в виде однопотоковых квантов, чтобы вы не получили приоритет при удержании спин-блокировки
- локализованные все данные, которые вы обновляете, находятся предпочтительно на одной загруженной странице, вы не хотите пропустить TLB, пока вы удерживаете спин-блокировку, и вы определенно не хотите, чтобы считывание свопа при сбое страницы читалось!
- атомарный вам не нужно никакой другой блокировки для выполнения операции, т.е. никогда не ждите замков под спин-блокировкой.
Для всего, что может принести прибыль, вы должны использовать уведомленную структуру блокировки (события, мьютекс, семафоры и т. Д.).
Один из вариантов использования спин-блокировок - если вы ожидаете очень низкую конкуренцию, но их будет много. Если вам не нужна поддержка для рекурсивной блокировки, спин-блокировка может быть реализована в одном байте, а если конфликт очень низкий, то потери в цикле ЦП незначительны.
Для практического использования у меня часто есть массивы тысяч элементов, где обновления для различных элементов массива могут безопасно происходить параллельно. Шансы двух потоков на одновременное обновление одного и того же элемента очень малы (низкая конкуренция), но мне нужен один замок для каждого элемента (у меня их будет много). В этих случаях я обычно выделяю массив ubytes того же размера, что и массив, который я обновляю параллельно, и реализую встроенные спин-блокировки (на языке программирования D):
while(!atomicCasUbyte(spinLocks[i], 0, 1)) {}
myArray[i] = newVal;
atomicSetUbyte(spinLocks[i], 0);
С другой стороны, если бы мне пришлось использовать обычные блокировки, мне пришлось бы выделить массив указателей на объекты, а затем выделить объект Mutex для каждого элемента этого массива. В сценариях, подобных описанному выше, это просто расточительно.
Если у вас есть код, критичный к производительности, и вы определили, что он должен быть быстрее, чем он есть в настоящее время, и вы определили, что критическим фактором является скорость блокировки, то было бы неплохо попробовать спин-блокировку. В других случаях зачем? Обычные замки проще правильно использовать.
Обратите внимание на следующие моменты:
Большинство реализаций мьютекса вращаются некоторое время, прежде чем поток фактически не запланирован. Из-за этого трудно сравнить эти мьютексы с чистыми спин-блокировками.
Несколько потоков, вращающихся "как можно быстрее" в одном и том же спин-блокировке, будут поглощать всю полосу пропускания и резко снижать эффективность вашей программы. Вам нужно добавить крошечное "спящее" время, добавив noop в цикле вращения.
Вам вряд ли когда-нибудь понадобится использовать спин-блокировки в коде приложения, во всяком случае, вам следует избегать их.
Я не могу по какой-либо причине использовать спин-блокировку в коде C#, работающем на нормальной ОС. Занятые блокировки в большинстве случаев являются пустой тратой на уровне приложений - вращение может привести к тому, что вы будете использовать весь временной интервал ЦП, а блокировка немедленно вызовет переключение контекста, если это необходимо.
Высокопроизводительный код, в котором у вас есть nr потоков = nr процессоров / ядер, может быть полезен в некоторых случаях, но если вам нужна оптимизация производительности на этом уровне, вы, вероятно, создаете 3D-игру следующего поколения, работая на встроенной ОС с плохими примитивами синхронизации, создавая ОС / драйвер или в любом случае не использовать C#.
Я использовал спин-блокировки на этапе остановки мира сборщика мусора в моем проекте HLVM, потому что они просты, и это - игрушечная виртуальная машина. Тем не менее, спин-блокировки могут быть контрпродуктивными в этом контексте:
Одна из ошибок в сборщике мусора компилятора Glasgow Haskell настолько раздражает, что у него есть имя, " последнее замедление работы ядра". Это является прямым следствием их неуместного использования спин-блокировок в их ГХ и усугубляется в Linux из-за его планировщика, но, фактически, этот эффект можно наблюдать всякий раз, когда другие программы конкурируют за процессорное время.
Эффект очевиден на втором графике здесь, и можно увидеть, что он влияет не только на последнее ядро, здесь, где программа на Haskell видит снижение производительности за пределами только 5 ядер.
Всегда помните об этих моментах при использовании спин-блокировок:
- Быстрое выполнение пользовательского режима.
- Синхронизирует потоки в рамках одного процесса или нескольких процессов, если они находятся в общей памяти.
- Не возвращается, пока объект не станет владельцем.
- Не поддерживает рекурсию.
- В режиме ожидания потребляет 100% ресурсов ЦП.
Я лично видел так много тупиков только потому, что кто-то подумал, что будет неплохо использовать спин-блокировку.
Будьте очень осторожны при использовании спин-блокировок
(Я не могу этого достаточно подчеркнуть).