[Перевод] Предпочитайте SRW-блокировки критическим секциям
Легковесность
SRWL-объект занимает в памяти всего 8 байт на x64-архитектуре, в то время как критическая секция — 40 байт. Критическая секция требует инициализации и деинициализации через вызовы функций ядра ОС, в то время как SRWL инициализируется простым присваиванием ему константы SRWLOCK_INIT, а затрат на удаление нет вообще никаких. Использование SRWL генерирует более компактный код и использует меньше оперативной памяти при работе.
Если у вас будет 100 000 объектов, требующих некоторой внутренней синхронизации, экономия памяти будет уже существенной. Прирост производительности от избегания лишних промахов кэша будет ещё более ощутимым. В современных процессорах (начиная с Intel Nehalem, вышедшего в 2008-ом) одна кэш-линия занимает 64 байта. Если вы используете на объект синхронизации 40 из них — это существенно ударит по производительности доступа к небольшим объектам в вашем ПО.
Скорость
Прежде всего, имейте в виду, что реализация SRWL в ядре ОС была существенно переработана за предыдущие несколько лет. Если вы читаете в Интернете какой-то бенчмарк, касающийся измерения скорости работы различных примитивов синхронизации в ОС Windows — обращайте внимание на дату написания.
И критическая секция и SRWL некоторое время крутятся в цикле в пользовательском режиме, а уже потом переходят в режим ожидания в ядре. Только критическая секция позволяет настраивать время ожидания в пользовательском режиме.
Я не исследовал детали реализации глубже. Также я никогда не пытался провести правильный бенчмарк, чтобы полностью корректно сравнить скорости критических секций и SRWL. Построить одновременно теоретически обоснованный и практически полезный бенчмарк очень сложно.
Но я заменял критические секции на SRWL в своих приложениях около 20 раз в различных сценариях. SRWL всегда был быстрее (или по меньшей мере не медленнее) и часто давал видимый прирост производительности.
Я не буду приводить здесь конкретные числа. Количество работы при захвате блокировки, гранулярность блокировок, уровень параллелизма, соотношение количества чтений и записи, использование кэша, загруженность процессора и другие факторы имеют слишком большое влияние на конечный результат.
Я не буду утверждать, что SRWL абсолютно всегда быстрее критических секций. В каждом отдельном случае необходимо профилирование для выяснения всей картины.
Отсутствие реентерабельности у SRWL
Это не баг, а фича.
Если у нас нет реентерабельности блокировок — это сразу ведёт к более прозрачным публичным контрактам, требует взвешенности при принятии решений о захвате и освобождении блокировки, что в итоге позволяет избегать дедлоков. Ну, по крайней мере пока вы не делаете глупых вещей, вроде вызова колбеков внутри захваченной блокировки.
Реентерабельные блокировки, конечно, тоже бывают полезны. Например, когда вы пытаетесь добавить параллельность к некоторому старому коду и не хотите слишком глубоко влезать в его рефакторинг. Оригинальный мьютекс из стандарта POSIX был создан реентерабельным случайно. Можно лишь представить, скольких проблем, связанных с параллельным кодом и блокировками, можно было бы счастливо избежать, если бы реентерабельные примитивы синхронизации не стали мейнстримом.
Поток, который попробует дважды захватить для записи один и тот же SRWL сам себя поймает в дедлок. Такой тип ошибки легко выявить и исправить прямо в момент первого появления. Просто посмотрите на колстек — там будет вся необходимая информация. Никакого там влияния таймингов и параллельных потоков.
Рекурсивная блокировка на чтение раньше тоже вызывала дедлоки, ну по крайней мере я уверен в этом где-то на 90% :). Если я не ошибаюсь, Microsoft тихонько изменила поведение то ли в каком-то обновлении, то ли при переходе от Win8 к Win10 и теперь дедлока нет. К сожалению, это усложнило поиск ошибок, связанных с реентерабельностью. Ошибочно вложенные блокировки на чтение приводят к неприятным багам в случаях когда внутрення блокировка освобождается слишком рано. Возможно даже хуже, внешняя блокировка может освободить блокировку, захваченную другим читателем. Аннотации Microsoft SAL для блокировок теоретически могут помочь обнаружить данный тип проблем на этапе компиляции, но я лично никогда не пробовал их на практике.
Параллельное чтение
Параллельное чтение на практике случается достаточно часто. Критическая секция никак не поддерживает параллелизм в данном случае.
Проблемы производительности записи
Обратной стороной преимущества параллельного чтения является тот факт, что блокировка на запись не сможет быть получена, пока не будут освобождены все блокировки на чтение. Более того, SRWL не гарантирует запросу блокировки на запись вообще никаких преференций или даже справедливости в порядке выдачи права на блокировку (новые блокировки на чтение могут быть успешно захвачены в то время, как блокировка на запись будет продолжать находиться в состоянии ожидания). Критические секции в этом плане с одной стороны ничем не лучше (приоритеты для захвата на чтение или на запись там выставлять также нельзя), но с другой стороны из-за отсутствия возможности параллельных захватов на чтение проблема будет возникать реже.
Планировщик задач Windows обеспечивает некоторую справедливость в плане предоставления ресурсов всем потокам. Это помогает во время блокировки некоторого ресурса в одном потоке завершить цикл ожидания в пользовательском режиме во всех остальных потоках. Но, поскольку алгоритм работы планировщика не является частью какого-либо публичного контракта, не стоит писать какой-либо код в расчёте на его текущую реализацию.
Если важна непрерывность прогресса при записи, то ни критическая секция ни SRWL не подходят в качестве механизма синхронизации. Другие конструкции, такие как очередь типа «читатель-писатель» может быть более предпочтительным механизмом.
Конкуренция на этапе выполнения
concurrency: reader_writer_lock даёт более строгие гарантии приоритетов, чем SRWL и спроектирован специально для работы в условиях частых захватов. Это имеет свою цену. По моему опыту данный примитив синхронизации существенно медленнее критических секций и SRWL, а также занимает больше места в памяти (72 байта).
Лично я считаю слишком уж избыточным выполнять отдельные задачи (jobs) лишь для попытки захвата блокировки, но, наверное, кому-то это подойдёт.
Ошибочное попадание в кэш
Ошибочное попадание в кэш значительно более вероятно для SRWL, чем для критических секций — снова таки в силу разницы в размере (8 байт против 40). Если критическая секция попадает в кэш, то её 40 байт занимают большую часть 64-байт кэш-линии, что исключает возможность попадания в неё же другой критической секции. Если вы создаёте массив блокировок — постарайтесь учитывать размер кэш-линии вашей платформы.
На этом, однако, не стоит концентрироваться раньше времени. Даже SRWL редко попадают в одну кэш-линию. Это случается лишь когда очень большое число потоков одновременно модифицируют некоторое относительно небольшое число объектов. Если у вас есть, например, несколько тысяч небольших объектов, то вряд ли стоит из-за вероятностью ошибочного попадания блокировки в кэш существенно менять их размер — игра, как правило, не стоит свеч. Точно, конечно же, можно утверждать лишь после профилирования каждой отдельно взятой ситуации.
Баг в ядре ОС
Я должен упомянуть баг в ядре ОС Windows, который вынудил меня слегка потерять веру в SRWL да и вообще в Windows. Несколько лет назад мы с коллегами начали замечать странные баги, когда некоторые потоки иногда не могли захватить те или иные SRWL. Это случалось в основном на двухядерных процессорах, но иногда, очень редко, и на одноядерных тоже. Отладка показала что в момент попытки захвата ни один другой поток не удерживал данную блокировку. Что ещё более удивительно, мгновением позже в том же потоке попытка захвата той же блокировки уже была успешной. После длительного исследования мне удалось уменьшить время воспроизведения данного бага с нескольких дней до получаса. В конце концов я доказал, что это проблема в ядре ОС, которая также касается IOCP.
От момента обнаружения бага до выхода хотфикса прошло 8 месяцев и, конечно, ещё какое-то время заняло распространение обновления на пользовательские ПК.
Выводы
Большинство блокировок защищают информацию в некоторых объектах от случайного одновременного доступа из различных потоков. Здесь ключевое слово — «случайного», поскольку требование именно одновременного доступа редко бывает преднамеренно запрограммированным. И критическая секция и SRWL имеют хорошую производительность при захвате и освобождении свободной в данный момент блокировки. В этом случае на первый план выходит общий размер защищаемого объекта. Если объект достаточно мал, чтобы вместе с блокировкой попасть в одну кэш-линию — это сразу даёт прирост производительности. На 32 байта меньший размер SRWL является главной причиной использовать его для этих целей.
Для сценариев кода, когда блокировка в большинстве случаев на момент попытки захвата уже будет занята, делать таких однозначных выводов нельзя. Здесь требуется измерения каждой сделанной оптимизации. Но в этом случае сама по себе скорость захвата и освобождения блокировки вряд ли будет узким местом в коде. Во главу угла встанет уменьшение времени работы кода внутри блокировки. Всё, что может быть сделано до или после блокировки — должно быть сделано именно там. Рассмотрите возможность использования нескольких отдельных блокировок вместо одной. Попробуйте дёрнуть необходимые данные перед вызовом блокировки (это даёт шанс на то, что код внутри блокировки отработает быстрее, поскольку получит их из кеша). Не выделяйте память в глобальной куче внутри блокировки (попробуйте использовать какой-нибудь аллокатор с предварительным выделением памяти). И так далее.
Ну и наконец, нереентерабельные блокировки значительно легче читаются в коде. Реентерабельные блокировки — это своего рода «goto параллелизма», поскольку они усложняют понимание текущего состояния блокировки и причин появления дедлоков.