Эти распространенные привычки могут стоить вам много производительности и памяти в вашем .NET-приложении!

В .NET нам не нужно беспокоиться об использовании памяти и сборке мусора, верно? Ну, если вас не волнует производительность, это может быть правдой. Однако есть некоторые распространенные привычки разработчиков C#, которые довольно неэффективны с точки зрения производительности и использования памяти. Эти привычки далеко не преднамеренны и возникают просто при кодировании, как вы его изучили, или как подсказывает документация C# или некоторых распространенных библиотек.

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

Введение

Одной из основных причин, почему некоторый оптимизированный код выполняется быстрее, чем неоптимизированный, является лучшее распределение памяти в куче и стеке.

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

Heap можно рассматривать как немного более грязный контейнер, куда временно помещается все более сложное, требующее много памяти или долгоживущее. Более сложные объекты будут стоить дороже для записи и чтения. Когда на объект больше не ссылаются, он в конечном итоге будет удален из Heap сборщиком мусора. Вот тут-то и становится интересно: в то время как Stack в значительной степени отлично очищает себя, Heap должен очищаться отдельно от Garbage Collector, что требует времени и снижает производительность.

Во время сборки мусора ваше приложение полностью перестает выполняться!

Если вы не будете внимательны, сборка мусора может легко занять более 2% времени выполнения вашего приложения, просто собирая объекты, на которые нет ссылок, а это очень много.

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

  1. Ссылочные типы всегда создаются в куче.
  2. Типы значений и Указатели создаются в scope объявляющего родителя. Это означает, что когда вы создаете экземпляр типа значения внутри ссылочного типа, он также попадает в кучу.

1. Распределение кучи, когда возможен стек

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

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

Для некоторых экземпляров с низкой рабочей нагрузкой это обычно не проблема. Однако, когда вы масштабируете и инициализируете несколько тысяч классов, где может быть достаточно простого типа значения, вы обязательно должны его использовать! (Для подробного примера см. 2.)

2. Классы против структур

Также сильно коррелирует с распределением кучи и стека решение, используете ли вы class или struct для своих объектов передачи данных. Разница в том, что structs являются типами значений, что позволяет размещать их в Stack. Однако имейте в виду, что типы значений также считаются неизменяемыми. Это означает, что каждый раз, когда вы вносите в него изменения или передаете его в другую область, он будет скопирован в память.

В 1. я упомянул, что вы должны размещать переменные в стеке, чтобы повысить производительность. Однако есть одна загвоздка. Работа только со стеком тоже может тормозить, если не обращать внимание на клонирование объектов в памяти. Давайте быстро сравним структуры и классы с бенчмарком. Я использую BenchmarkDotNet.

Выполнение этих тестов привело к следующим результатам на моей машине:

Итак, что это говорит нам? Инициализация структуры кажется намного быстрее, чем инициализация класса, а также в куче не выделяется память. Кажется, что структура идеально подходит для использования везде, верно? Давайте создадим еще один тест, а именно тест, что произойдет, если мы передадим struct-objects или class-objects другим функциям:

Результаты:

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

Итак, что мы можем узнать из этих тестов? Что ж, в некоторых случаях лучше работают структуры, а в других — классы. Создание экземпляров классов будет выделять память в куче, но передача их через другие функции более эффективна, чем их клонирование каждый раз. Чтобы лучше различать, что использовать в каком случае, в будущем я создам более подробный анализ на эту тему. Так что следите за обновлениями!

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

3. IEnumerable и LINQ

IEnumearbles, вероятно, являются наиболее распространенными убийцами производительности, поскольку их можно очень быстро использовать неправильно. Одним из ключевых факторов, влияющих на производительность, является перечисление IEnumerables чаще, чем необходимо.

Давайте посмотрим на простой тест:

Здесь я ожидаю, что второй подход будет медленнее, так как он имеет дополнительное перечисление с ToArray(). Итак, давайте посмотрим на результаты:

Результат немного сбивает с толку. Время выполнения обоих методов примерно одинаково для 1000 элементов, второй подход даже немного быстрее. Разве мы только что не предположили, что перечислять между двумя вызовами LINQ — это плохо? Плохая часть этого, по-видимому, заключается в использовании памяти. Как видите, второй метод занимает больше Heap выделенной памяти, что будет снижать производительность при сборке мусора позже во время выполнения. Но все ли это? Взгляните на следующий метод:

Здесь у нас есть дополнительное перечисление, а именно Count(). Отличие от метода EnumerateTwice() в том, что Count() не возвращает уже пронумерованный массив, над которым мы можем выполнить еще какую-то логику. Вместо этого мы действительно дважды перечисляем IEnumerable half! В данном случае это перечисление стоит нам дополнительно 60% времени выполнения.

Имейте в виду, что любое дополнительное перечисление может замедлить работу. Однако может быть более эффективно перечислять ваши IEnumerables между ними, так как источник IEnumerable может быть очень ресурсоемким, например вызовы базы данных или операции ввода-вывода. Здесь вы даже должны перечислить его, прежде чем передавать его в другие функции, так как вы не можете легко убедиться, что он не будет перечислен или даже изменен в других функциях.

4. Повторение больших коллекций

Часто рука об руку с IEnumerables идет перебор этих коллекций и выполнение какой-то логики. Самыми распространенными способами будут, конечно, циклы For и ForEach, а также методы Linq, такие как Select(). Но каков самый быстрый способ перебора списков?

Смотря как. Для небольших списков цикла For, безусловно, достаточно, но как насчет действительно больших списков и, возможно, сложной побочной логики для каждого элемента? Давайте создадим несколько тестов с помощью BenchmarkDotNet.
(Благодарим [1] за эти подходы)

Выполнение этих тестов приводит к следующим результатам на моей машине:

Как видите, я использовал обычные итерации и параллельные итерации, а также очень необычный подход с использованием Span<T>. Исключая Span, мы ясно видим, что чем выше количество итераций, тем лучше работают параллельные методы по сравнению с последовательными. Конечно, будет точка, в которой параллельные циклы будут быстрее, чем последовательные, и если побочная логика будет более сложной, точка будет достигнута намного раньше, чем в этом примере. Также примечательно, что выделение памяти для параллельных циклов не зависит от количества итераций. На самом деле, он остается примерно одинаковым для каждого подсчета итераций.

Теперь давайте посмотрим на подход Span. Он примерно в два раза быстрее, чем цикл for, и остается на вершине рейтинга по всем отображаемым счетчикам итераций. Почему это так? Span — это особый вид структуры ref, которая размещается на Stack и представляет собой непрерывную область произвольной памяти. Это означает, что он будет сохранен в одном непрерывном диапазоне адресов памяти, что упрощает итерацию, поскольку итератор знает, что следующая запись находится только в следующем слоте памяти.

Но как насчет сложных задач?

Здесь я извлек наиболее эффективных кандидатов из приведенных выше и адаптировал их для выполнения асинхронной операции в 10 мс. Обратите внимание, как я не могу использовать подход async/await внутри итерации Span. Это не разрешено, так как Span не может обрабатывать разные потоки, поэтому мне нужно обрабатывать его синхронно.

Вот результаты:

Здесь мы видим, что даже для 100 элементов (при 10 мс на операцию) параллельный подход явно лидирует.

Итак, что мы можем извлечь из этого:

  • При повторении коллекций с менее сложной побочной логикой подход Span является самым быстрым.
  • При повторении И выполнении более сложной побочной логики используйте параллельный подход.

5. Исключения

Теперь перейдем к другому важному моменту производительности: исключениям.

Одна вещь, которую я всегда задавал себе, заключается в том, влияет ли размещение ненужного блока TryCatch вокруг метода на производительность. Для этого я создал следующие тесты:

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

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



6. Асинхронный/ожидание

Теперь последний пункт этой статьи: использование async/await.

Может быть, вы спрашивали себя, лучше ли использовать await Оператор, когда вы можете просто вернуть основную Задачу? Давайте выясним с другим набором тестов:

Как видите, нет никакой разницы во времени выполнения и небольшая разница в выделении кучи. Конечно, вы немного потеряете в производительности при использовании await, предположительно из-за создания внутреннего StateMachine, однако вы потеряете важное преимущество оператора await: обработку исключений. Если вы не используете оператор await в цепочке вызовов, а задача выдает ошибку, исключение не будет передано обработчику ошибок, и вы можете никогда не узнать об этой ошибке.

В этом примере я ожидаю его на верхнем уровне, но я также мог бы просто использовать Task.Run(), и ошибка пропала бы.

Еще один важный момент для async/await: если вы можете синхронно что-то вычислить, как это повлияет на производительность, если вы сделаете это асинхронно?

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

Если бы я запускал эту задачу только один раз вместо 1000 раз, влияние на производительность уже не было бы таким высоким.

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

Отдельное спасибо Nick Chapsas за подробную информацию на его канале!

[1]: https://www.youtube.com/watch?v=jUZ3VKFyB-A

Если вы заинтересованы в тестах, вы можете проверить мой репозиторий тестов здесь:



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

Если вы заинтересованы в том, чтобы быть в курсе последних тенденций, советов и приемов для чистой архитектуры, чистого кодирования и новейших технологических стеков, особенно в контексте C #, .NET и Angular, я был бы признателен. если вы решили следовать за мной.

Удачного дня!

Если вы еще не используете Medium для ежедневного расширения своих знаний, сейчас самое время начать! С Medium вы можете легко получить больше знаний по высокопрофессиональным темам, публиковать качественный контент и охватить более широкую аудиторию. Чтобы начать, просто создайте учетную запись Medium, используя эту ссылку:

Присоединиться к Medium сейчас

Таким образом, вы получите доступ к мощной платформе, которая поможет вам общаться с новыми писателями и читателями и каждый день узнавать что-то новое.