Использование типа Struct и модификатора readonly иногда может порождать падения производительности. Сегодня мы расскажем о том, как этого избегать, используя один Open Source анализатор кода — ErrorProne.NET.
Как вы, вероятно, знаете из моих предыдущих публикаций «The 'in'-modifier and the readonly structs in C#» («Модификатор in и структуры readonly в C#») и «Performance traps of ref locals and ref returns in C#» («Ловушки производительности при использовании локальных переменных и возвращаемых значений с модификатором ref»), работать со структурами сложнее, чем может показаться. Оставив в стороне вопрос изменяемости, замечу, что поведение структур с модификатором readonly (только для чтения) и без него в контекстах readonly сильно различается.
Предполагается, что структуры используются при программировании сценариев, требующих высокой производительности, и для эффективной работы с ними вы должны кое-что знать о различных скрытых операциях, порождаемых компилятором для обеспечения неизменности структуры.
Вот краткий перечень предостережений, которые вы должны помнить:
- Использование больших структур, которые передаются или возвращаются по значению, может вызвать проблемы с производительностью на критичных путях выполнения программы.
x.Y
вызывает создание защитной копии x, если:x
является полем readonly;- тип
x
— это структура без модификатора readonly; Y
— не поле.
Те же правила работают, если x является параметром с модификатором in, локальной переменной с модификатором ref readonly или результатом вызова метода, который возвращает значение посредством ссылки readonly.
Вот несколько правил, которые следует запомнить. И, что наиболее важно, код, который опирается на эти правила, очень хрупкий (т. е. изменения, вносимые в код, немедленно порождают существенные изменения в других частях кода или документации — прим. перев.). Сколько человек заметят, что замена public readonly int X
; на public int X { get; }
в часто используемой структуре без модификатора readonly существенно влияет на производительность? Или насколько легко увидеть, что передача параметра с помощью модификатора in вместо передачи по значению может снизить производительность? Это действительно возможно при использовании свойства in параметра в цикле, когда защитная копия создается при каждой итерации.
Такие свойства структур буквально взывают к разработке анализаторов. И зов был услышан. Встречайте ErrorProne.NET — набор анализаторов, который информирует вас о возможности изменения программного кода для улучшения его дизайна и производительности при работе со структурами.
Анализ кода с выводом сообщений «Сделайте структуру X readonly»
Лучший способ избежать трудноуловимых ошибок и негативного влияния на производительность при использовании структур — по возможности сделать их readonly. Модификатор readonly в объявлении структуры четко выражает намерение разработчика (подчеркивая, что структура неизменяема) и помогает компилятору избежать порождения защитных копий во многих контекстах, упомянутых выше.
Объявление структуры readonly не нарушает целостности кода. Вы можете без опасений запустить фиксер (процесс исправления кода) в пакетном режиме и объявить все структуры всего программного решения доступными только для чтения.
Дружественность по отношению к модификатору ref readonly
Следующий шаг — оценка безопасности использования новых возможностей (модификатора in, локальных переменных ref readonly и т. п.). Это означает, что компилятор не будет создавать скрытые защитные копии, способные снизить производительность.
Можно рассмотреть три категории типов:
- структуры, дружественные к ref readonly, использование которых никогда не приводит к созданию защитных копий;
- структуры, недружественные к ref readonly, использование которых в контексте readonly всегда приводит к созданию защитных копий;
- нейтральные структуры — структуры, использование которых может порождать защитные копии в зависимости от члена, применяемого в контексте readonly.
Первая категория включает в себя структуры readonly и POCO-структуры. Компилятор никогда не породит защитную копию, если структура является readonly. Также безопасно в контексте readonly использовать POCO-структуры: доступ к полям считается безопасным, и защитные копии не создаются.
Вторая категория — это структуры без модификатора readonly, не содержащие открытых полей. В этом случае любой доступ к открытому члену в контексте readonly вызовет создание защитной копии.
Последняя категория — это структуры с полями public или internal и свойствами или методами public либо internal. В этом случае компилятор создает защитные копии в зависимости от используемого члена.
Такое разделение помогает мгновенно выводить предупреждения, если «недружественная» структура передается с модификатором in, сохраняется в локальной переменной ref readonly и т. д.
Анализатор не выводит предупреждения, если «недружественная» структура используется как поле readonly, поскольку альтернатива в этом случае отсутствует. Модификаторы in и ref readonly разработаны с целью оптимизации, специально, чтобы избежать создания избыточных копий. Если структура «недружелюбна» по отношению к этим модификаторам, у вас есть другие возможности: передать аргумент по значению или сохранить копию в локальной переменной. В этом отношении поля readonly ведут себя иначе: если вы хотите сделать тип неизменяемым, то должны использовать эти поля. Помните: код должен быть ясным и элегантным, и только во вторую очередь — быстрым.
Анализ скрытых копий
Компилятор выполняет много действий, скрытых от пользователя. Как было показано в предыдущей публикации, довольно сложно увидеть, когда происходит создание защитной копии.
Анализатор выявляет следующие скрытые копии:
- Скрытая копия поля readonly.
- Скрытая копия аргумента in.
- Скрытая копия локальной переменной ref readonly.
- Скрытая копия возвращаемого значения ref readonly.
- Скрытая копия при вызове метода расширения, принимающего параметр с модификатором this по значению, для экземпляра структуры.
public struct NonReadOnlyStruct
{
public readonly long PublicField;
public long PublicProperty { get; }
public void PublicMethod() { }
private static readonly NonReadOnlyStruct _ros;
public static void Samples(in NonReadOnlyStruct nrs)
{
// Ok. Public field access causes no hidden copies
var x = nrs.PublicField;
// Ok. No hidden copies.
x = _ros.PublicField;
// Hidden copy: Property access on 'in'-parameter
x = nrs.PublicProperty;
// Hidden copy: Method call on readonly field
_ros.PublicMethod();
ref readonly var local = ref nrs;
// Hidden copy: method call on ref readonly local
local.PublicMethod();
// Hidden copy: method call on ref readonly return
Local().PublicMethod();
ref readonly NonReadOnlyStruct Local() => ref _ros;
}
}
Обратите внимание, что анализаторы выводят диагностические сообщения, только если размер структуры ≥16 байт.
Использование анализаторов в реальных проектах
Передача больших структур по значению и, как результат, создание компилятором защитных копий существенно влияют на производительность. По крайней мере, это показывают результаты тестов производительности. Но как эти явления повлияют на реальные приложения в терминах времени сквозного прохождения?
Чтобы протестировать анализаторы с помощью реального кода, я использовал их для двух проектов: проекта Roslyn и внутреннего проекта, над которым я сейчас работаю в компании Microsoft (проект представляет собой самостоятельное компьютерное приложение с жесткими требованиями к производительности); назовем его для ясности «Проект D».
Вот результаты:
- В проектах с высокими требованиями к производительности, как правило, содержится множество структур, и большинство из них можно сделать readonly. Например, в проекте Roslyn анализатор обнаружил около 400 структур, которые можно сделать readonly, и в проекте D — примерно 300.
- В проектах с высокими требованиями к производительности скрытые копии должны создаваться лишь в исключительных ситуациях. Я обнаружил лишь несколько таких случаев в проекте Roslyn, поскольку большая часть структур имеет поля public вместо свойств public. Это позволяет избежать создания защитных копий в ситуации, когда структуры сохраняются в полях readonly. Скрытых копий в проекте D было больше, поскольку, по крайней мере, половина из них имела свойства get-only (доступ только для чтения).
- Передача даже довольно больших структур с использованием модификатора in, скорее всего, очень слабо (практически незаметно) влияет на время сквозного прохождения программы.
Я изменил все 300 структур в проекте D, сделав их readonly, а затем откорректировал сотни случаев их использования, указав, что они передаются с модификатором in. Затем я измерил сквозное время прохождения для различных сценариев производительности. Различия были статистически незначительными.
Означает ли это, что описанные выше возможности бесполезны? Вовсе нет.
Работа над проектом с высокими требованиями к производительности (например, над Roslyn или «Проектом D») подразумевает, что большое количество людей тратят массу времени на различные виды оптимизации. В самом деле, в ряде случаев структуры в нашем коде передавались с модификатором ref, и некоторые поля были объявлены без модификатора readonly, чтобы исключить порождение защитных копий. Отсутствие роста производительности при передаче структур с модификатором in может означать, что код был хорошо оптимизирован и на критических путях его прохождения отсутствует избыточное копирование структур.
Что я должен делать с этими возможностями?
Я считаю, что вопрос использования модификатора readonly для структур не требует долгих размышлений. Если структура неизменяема, то модификатор readonly просто явно принуждает компилятор к такому проектному решению. И отсутствие защитных копий для подобных структур — просто бонус.
Сегодня мои рекомендации таковы: если структуру можно сделать readonly, то непременно сделайте ее таковой.
Использование других рассмотренных возможностей имеет нюансы.
Предварительная оптимизация против предварительной пессимизации?
Герб Саттер (Herb Sutter) в своей удивительной книге «Стандарты кодирования на C++: 101 правило, рекомендации и передовой опыт» вводит понятие «предварительной пессимизации».
«При прочих равных условиях, сложность кода и его удобочитаемость, некоторые эффективные шаблоны проектирования и идиомы кодирования должны естественным образом стекать с кончиков ваших пальцев. Такой код не сложнее в написании, чем его пессимизированные альтернативы. Вы не занимаетесь предварительной оптимизацией, а избегаете добровольной пессимизации».
С моей точки зрения, параметр с модификатором in — как раз тот самый случай. Если вы знаете, что структура относительно велика (40 байт и более), то можете всегда передавать ее с модификатором in. Цена использования модификатора in сравнительно невелика, поскольку при этом не нужно корректировать вызовы, а выгоду можно получить реальную.
Напротив, для локальных переменных и возвращаемых значений с модификатором readonly ref дело обстоит иначе. Я бы сказал, что эти возможности следует использовать при кодировании библиотек, а в коде приложения от них лучше отказаться (только если профилирование кода не выявит, что операция копирования реально является проблемой). Использование этих возможностей требует от вас дополнительных усилий, а читателю кода становится сложнее его понять.
Заключение
- Используйте модификатор readonly для структур там, где это возможно.
- Рассмотрите возможность использования модификатора in для больших структур.
- Рассмотрите возможность использования локальных переменных и возвращаемых значений с модификатором ref readonly для кодирования библиотек или в тех случаях, когда результаты профилирования кода говорят о том, что это может быть полезно.
- Пользуйтесь ErrorProne.NET для обнаружения проблем с кодом и делитесь результатами.
Автор: Александр Гуреев