Татьяна Митина, руководитель подразделения C3D Labs в Нижнем Новгороде, рассказывает, как устроена многопоточность ядра C3D, какими механизмами обеспечивается потокобезопасность ядра, какие параллельные вычисления происходят в самом ядре. Особое внимание уделяется правилам использования ядра C3D в нескольких потоках.
Многопоточность – отличный повод
заглянуть в параллельные миры!
Для начала уточним терминологию. Под потокобезопасностью мы понимаем безопасность использования данных в нескольких потоках. А многопоточность – это способность кода выполнять вычисления в нескольких потоках, используя потокобезопасность обрабатываемых данных.

Итак, многопоточность
Иногда необходимость использования многопоточности порождает опасения. Недаром в одном из доступных в Интернете руководств по языку программирования Rust раздел, посвященный многопоточности, так и озаглавлен – «Многопоточность без страха». Слова «многопоточность» и «опасность» часто фигурируют в связке, вероятно, потому, что неправильно организованная многопоточность вызывает достаточно серьезные проблемы в коде, преимущественно проблемы синхронизации, такие как гонки данных, взаимные блокировки и так далее. Очень часто эти проблемы приводят к таким «впечатляющим» результатам, как вылеты приложения или, наоборот, его зависания. Даже если удается избежать подобных затруднений, все равно велика вероятность получения неправильных результатов.
Для того чтобы предотвратить появление подобных проблем, нужен механизм, который обеспечит каждому потоку эксклюзивный доступ к нужным ему данным, причем лучше, если доступ будет неблокирующий. Такой механизм был разработан в ядре C3D, что позволяет беспрепятственно использовать интерфейсы ядра в нескольких потоках.
Подробнее остановимся на реализации многопоточности в ядре – механизмах, которые важны для обеспечения потокобезопасности ядра, режимах многопоточности ядра, понятии многопоточных кэшей. Кроме того, коснемся существующих объектов синхронизации, блокировок, которые мы стараемся использовать минимально, а также параллельных вычислений, которые происходят в самом ядре.
Режимы многопоточности ядра
Режим многопоточности ядра C3D определяет общую конфигурацию поддержки многопоточности, а именно следующее.
Является ли ядро потокобезопасным, то есть может ли оно активировать и использовать механизм обеспечения потокобезопасности,
Насколько распараллелены вычисления в самом ядре.
Все режимы ядра делятся на две группы: группа режимов, которые не обеспечивают потокобезопасность ядра, и группа потокобезопасных режимов.

Первая группа – это режимы, при которых можно использовать ядро в только в одном потоке.
Режим Off – это полностью однопоточное ядро, которое вообще не знает про собственные механизмы обеспечения многопоточности (то есть они полностью деактивированы) и не распараллеливает свои вычисления. В одном потоке ядро может работать успешно, а вот использовать ядро в нескольких потоках в данном режиме нельзя.
В стандартном режиме Standard также нельзя использовать ядро в нескольких потоках. Данный режим отличается от режима Off тем, что в самом ядре ограниченно используются параллельные вычисления – там, где в нескольких потоках обрабатываются абсолютно независимые друг от друга данные.
Вторая группа режимов – это потокобезопасные режимы ядра.
Режим SaveItems – предполагает, что можно активировать механизмы ядра, обеспечивающие потокобезопасность его использования в нескольких потоках, при этом само ядро ограниченно распараллеливает только некоторые свои вычисления.
Режим Items – также потокобезопасный режим ядра, и в данном режиме в ядре распараллеливаются все вычисления, где возможны параллельные вычисления и где они реализованы.
Режим Max на сегодняшний день функционально равен режиму Items (это задел на будущее).

Механизмы потокобезопасности ядра
Рассмотрим важнейший механизм, который делает ядро потокобезопасным. Это многопоточные кэши.
Ядро C3D широко использует кэширование, когда данные, вычисленные для неких входных параметров, затем кэшируются в предположении, что на вход могут поступить те же параметры, и тогда не нужно будет повторно выполнять вычисления.
Кэширование данных – известный способ повышения эффективности вычислений. В одном потоке он отлично работает. Но если алгоритм, применяющий кэширование данных, выполняется в нескольких потоках, то возникает соперничество потоков за обладание кэшем. В связи с этим мы создали и реализовали механизм многопоточных кэшей. Он предоставляет каждому потоку свой кэш, поэтому каждый поток имеет эксклюзивный неблокирующий (lock-free) доступ к тем данным, которые нужно обрабатывать в данном потоке.
Следует отметить, что по умолчанию механизм многопоточных кэшей отключен. Перед использованием ядра в нескольких потоках механизм необходимо включать, а после завершения многопоточности отключать. Почему так сделано?
Во-первых, если ядро используется только в одном потоке, нет смысла использовать механизм многопоточных кэшей, поскольку он требует поддержки, пусть и небольшой. Поэтому использование ядра в одном потоке будет эффективнее, если многопоточные кэши отключены.
Во-вторых, при работе в многопоточности накапливаются кэши, которые становятся невалидными при завершении работы потока, поэтому механизм многопоточных кэшей должен время от времени синхронизировать кэши и проводить сборку мусора, что возможно только в случае, когда многопоточности нет и механизм многопоточных кэшей отключен.
Из вышесказанного следует следующее правило. Если ядро C3D работает в одном потоке, никаких дополнительных действий не требуется. Однако перед запуском вычислений с использованием ядра в нескольких потоках нужно посылать ядру специальное уведомление. Такое уведомление включает механизм многопоточных кэшей, ядро становится потокобезопасным, и его можно использовать в нескольких потоках. После того как все потоки закончили операции с ядром, нужно послать другое уведомление ядру о том, что многопоточное использование закончено.
Интерфейс, который предоставляется для уведомлений ядра о начале и окончании многопоточных вычислений, представляет собой парные функции EnterParallelRegion и ExitParellelRegion. Вместо указанных функций также можно применять парные макросы, которые на вход принимают дополнительный параметр, сообщающий, будет ли реальное распараллеливание или нет. Уведомления посылаются ядру, только если этот параметр равен true. Кроме того, вместо функций или макросов можно использовать класс ParallelRegionGuard, который посылает уведомления ядру в области видимости экземпляра класса.
Предварительные итоги
Подведем предварительные итоги.
Существует режим многопоточности ядра C3D, который либо включает возможность ядра работать в нескольких потоках, либо отключает. Кроме того, он влияет на степень распараллеливания вычислений в ядре.
Для использования ядра в нескольких потоках необходимо активировать многопоточные кэши, а при переходе к однопоточным вычислениям – деактивировать их. Для этого предоставляется специальное API.
Кроме того, отметим, что ядро предоставляет свои объекты синхронизации, которые при желании можно использовать для обеспечения эксклюзивного доступа к каким-либо данным.
Ядро C3D широко использует многопоточные вычисления в своих операциях, например при построении плоских проекций, в расчетах массо-центровочных характеристик, расчете триангуляций, в операциях с оболочками, поверхностями и кривыми, в операциях конвертеров и т. д. Также ядро поддерживает возможность многопоточного чтения и записи данных в формате C3D.

Как использовать ядро C3D в многопоточности
Итак, как использовать ядро C3D в многопоточности? Во-первых, нужно проверить режим многопоточности ядра.
Отметим, что помимо прочего ядро предоставляет интерфейс для изменения режима многопоточности. Это сделано для того, чтобы можно было настроить ядро в некоторых специальных случаях. Приведем пример. Ядро, как известно, распараллеливает свои вычисления с помощью OpenMP. Если клиентский код также использует OpenMP, то внутреннее распараллеливание ядра может повлиять на эффективность параллельности в клиентском коде. Поскольку в обоих случаях используется OpenMP, можно попытаться повлиять на производительность - например, активировать nested parallelism или наоборот, попробовать ограничить распараллеливание в ядре, выбрав режим ядра SaveItems, при котором ядро остается потокобезопасным, но его внутреннее распараллеливание ограничено.
Однако описанная ситуация достаточно специфична, и в подавляющем большинстве случаев не требуется менять режим многопоточности ядра, максимальный по умолчанию. Именно в режиме по умолчанию лучше всего использовать ядро.
Итак, в режиме потокобезопасного ядра можно выполнять его операции в нескольких потоках при условии:
Если в потоках обрабатываются полностью независимые данные.
Или если операции, вызываемые в потоках, не изменяют общие используемые данные.
В первом случае – если в потоках обрабатываются полностью независимые данные – ограничений нет.
Более интересен второй случай. Предположим, два потока выполняют операции с одним и тем же телом. Какие операции можно совершать в этом случае? У тела можно запросить какие-то данные, рассчитать различные метрики, провести проверки геометрии и другие операции, где тело остается константным. Основное требование потокобезопасности – тело не должно перестраиваться, то есть оно должно быть константным во всех потоках.
Рассмотрим несколько примеров вызова операций ядра в нескольких потоках.

В первом примере в цикле вычисляются габариты граней оболочки.
Сначала проверяем режим многопоточности ядра, инициализируя переменную, которая будет управлять многопоточностью. В данном случае многопоточность организована с помощью прагмы OpenMP, которая будет распараллеливать указанный цикл, если управляющая переменная равна true. Поскольку здесь используется условное распараллеливание (по переменной), то для уведомления ядра используем макросы, которые на вход принимают эту переменную. Таким образом, перед циклом мы уведомляем, что [возможно] начинаются многопоточные вычисления, и после цикла мы уведомляем, что многопоточные вычисления закончены.

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

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

Пример 4 – случай асинхронного вызова интерфейсов ядра в нескольких потоках. Предположим, что есть два постоянно работающих потока, которые время от времени вызывают функции ядра.
Чтобы ядро оказалось потокобезопасным в нужный момент, можно использовать следующую рабочую схему. В каждом потоке перед вызовом операции ядра уведомляем ядро с помощью функции EnterParallelRegion. После того как операция закончила работу, вызываем ExitParallelRegion. Проделываем такие действия в каждом потоке для каждого вызова ядра.
Если мы реализуем такую схему, то ядро всегда будет готово работать в многопоточном режиме.
Обращаем внимание, что не рекомендуется просто ставить EnterParallelRegion в начале и ExitParallelRegion в конце функции main() приложения из-за того, что время от времени ядру нужно синхронизировать кэши и осуществлять сборку мусора в них, что возможно только в моменты, когда многопоточные кэши отключены. Иначе кэши накапливаются и могут стать невалидными из-за невозможности синхронизировать их.
Суммируем
Многопоточность с ядром C3D – это нестрашно!
Ядро C3D поддерживает многопоточность, предоставляет интерфейсы для безопасного использования в многопоточном приложении, само активно выполняет параллельные вычисления внутри себя и, наконец, может быть сконфигурировано в соответствии с особенностями организации многопоточности в клиентском продукте.
Соблюдая несложные правила, можно безопасно использовать ядро в нескольких потоках, что поможет сделать продукт заказчика более быстрым и производительным.

Татьяна Митина
Руководитель подразделения C3D Labs в Нижнем Новгороде