[Перевод] Как писать на C# аккуратно: память и производительность

image-loader.svg

К старту курса о разработке на C# делимся переводом статьи о типичных ошибках программирования на C# от Кристофа Насарре — технического рецензента книг Microsoft Press в целом и, конечно, книги CLR via C# в частности. Кроме того, Кристоф Насарре — один из авторов книги Windows via C++.

Разработчики не контролируют работу сборщика мусора (далее — GC — Garbage Collector), но, чем меньше выполняется выделений памяти, тем меньше GC влияет на приложение, поэтому главная цель — избежать выделяющего ненужные объекты кода или кода, который ссылается на них слишком долго. Помогая командам Criteo очистить код, я собрал и задокументировал несколько антипаттернов C#, подобных паттернам из публикации Кевина о коде с неприятным запахом. Вот выдержка о хороших и плохих моделях памяти.

Финализация и IDisposable

Начнём со внедрения «финализатора» — скрытого способа ссылаться на объект. Пишется метод, имя которого — это имя класса с пре́фиксом ~. Компилятор генерирует переопределение виртуального метода Object.Finalize, а экземпляр типа с таким методом обрабатывается GC особым образом:

  • После выделения памяти ссылка сохраняется во внутренней очереди Finalization.

  • Если на Finalization больше не ссылаются, эта ссылка перемещается в другую внутреннюю очередь — fReachable — и рассматривается как корневая, пока выделенный поток не вызовет код её финализатора.

Конрад Кокоса в одном из своих бесплатных видео о GC изнутри подробно рассказывает, что экземпляры типа, где реализован финализатор, остаются в памяти намного дольше необходимого, ожидая следующей сборки мусора поколения, где экземпляр оставила предыдущая сборка мусора, т. е. gen1, если он находился в gen0, или gen2, если в gen1, что ещё хуже. Поэтому первый вопрос часто касается того, действительно ли необходим финализатор. В большинстве случаев ответ должен быть отрицательным. Финализатор очищает только неуправляемые ресурсы, то есть обычно полученное от вызовов нативных функций взаимодействия с COM или P/Invoke «нечто»: дескрипторы, нативную или выделенную через Marshal-хелперы память.

Если в классе есть поля IntPtr, это хороший знак того, что время их жизни заканчивается в финализаторе через хелперы Marshal или очистки после вызова P/Invoke. Ищите наследованный от SafeHandle класс, если вам нужно манипулировать дескрипторами объектов ядра вместо необработанных IntPtr, избегайте финализаторов. Итак, в 99,9% случаев финализатор не нужен.

Второй вопрос: как реализация финализатора связана с реализацией IDisposable? В отличие от финализатора реализация уникального метода Dispose() интерфейса IDisposable для сборщика мусора ничего не значит, то есть время жизни не продлевается никаким побочным эффектом. Dispose позволяет пользователям экземпляров класса не ждать сборку мусора, вместо этого в определённый момент явно очистив память от экземпляра.

Пример: при записи в файл за кулисами .NET вызывает нативные API, работающие с реальным файлом через дескрипторы объектов ядра в Windows с ограниченным одновременным доступом: два процесса не повредят файл одновременной записью разных байтов. Это взгляд на ситуацию с высоты птичьего полёта, однако конкретно в этом обсуждении он допустим.

Другой класс позволяет получить доступ к базам данных через ограниченное количество соединений, освобождать их нужно как можно скорее. Во всех этих ситуациях как пользователь этих классов вы хотите «освобождать» ресурсы за кулисами как можно быстрее. Это переводится в хорошо известный паттерн using:

using (var disposableInstance = new MyDisposable())
{
   DoSomething(disposableInstance);
}; // the instance will be cleanup and its resources released

Компилятор преобразует код выше в такой:

var disposableInstance = new MyDisposable();
try
{
   DoSomething(disposableInstance);
}
finally
{
   disposableInstance?.Dispose();
}

Когда нужен IDisposable? Мой ответ прост: он нужен, когда класс владеет полями реализующих IDisposable классов и если он реализует финализатор (причина объяснялась выше). Не используйте IDisposable.Dispose по другим причинам, например при логировании, как это делалось в деструкторе C++. Предпочтите для этого реализовать другой явный интерфейс.

Что касается реализации, я не понимаю, почему код в документации Microsoft настолько сложный. Чтобы «освободить» неуправляемые или управляемые ресурсы, нужно реализовать метод ниже. Вызываться он должен как финализатором, так и IDisposable.Dispose():

var disposableInstance = new MyDisposable();
try
{
   DoSomething(disposableInstance);
}
finally
{
   disposableInstance?.Dispose();
}

Чтобы без проблем вызывать IDisposable.Dispose() несколько раз, необходимо поле _disposed. Если _disposed равно true, чтобы поймать использование утилизированных объектов, не забудьте во всех методах и свойствах класса бросить исключение ObjectDisposedException.

Спросите нескольких разработчиков, когда disposing должно быть true или false: не считая сомневающихся, половина скажет, что при вызове из финализатора, а другая половина — из Dispose. Зачем давать аналогичное имя методу, который уже есть в IDisposable? Почему параметр называется disposing? Я думаю, избыток Dispose убивает паттерн и проблема решается проще. Вот моя версия:

class DisposableMe : IDisposable
{
    private bool _disposed = false;

    // 1. field that implements IDisposable
    // 2. field that stores "native resource" (ex: IntPtr)

    ~DisposableMe()
    {
        Cleanup("called from GC" != null);
    }           // = true

    public void Dispose()
    {
        Cleanup("not from GC" == null);
    }           // = false
    
    ...
}

Я также переименовал Dispose(bool disposing) в Cleanup(bool fromGC):

 private void Cleanup(bool fromGC)
 {
     if (_disposed)
         return;

     try
     {
         // always clean up the NATIVE resources
         if (fromGC)
             return;

         // clean up managed resources ONLY if not called from GC
     }
     finally
     {
         _disposed = true;

         if (!fromGC)
             GC.SuppressFinalize(this);
     }
 }

Правила просты. Запомните их:

  • Нативные ресурсы, т. е. поля IntPtr, должны очищаться всегда.

  • Управляемые ресурсы, т. е. поля IDisposable, должны утилизироваться при вызове из Dispose, но не из GC.

Логическое поле _disposed используется для очистки ресурсов только один раз. Здесь оно установлено в true, даже когда брошено исключение, поскольку я предполагаю, что, если исключение произошло, оно произойдёт и позднее. И последняя важная деталь: вызов GC.SuppressFinalize(this) просто сообщает GC, что ему нужно удалить утилизируемый объект из Finalization:

  • Вызывать его имеет смысл исключительно из Dispose (не из GC), чтобы не продлевать время жизни объекта.

  • Это означает, что финализатор не вызывается, иначе он вызовет Cleanup, который немедленно перейдёт к возврату, поскольку _disposed истинно.

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

Увеличивайте вместимость списка, когда это возможно

Создавая List или экземпляр коллекции, дайте ему запас вместимости. Реализация классов списков и коллекций в .NET обычно хранит значения в массиве, размер которого необходимо изменять при добавлении новых элементов; это означает, что:

  1. Выделяется новый массив.

  2. Прежние значения копируются в него.

  3. На прежний массив больше не ссылаются.

var resultList = new List<...>();
foreach (var item in otherList)
{ 
   resultList.Add(...);
}

StringBuilder лучше +/+=

Создание вре́менных объектов увеличит количество сборок мусора и снизит производительность. Класс string иммутабелен, поэтому каждый раз, когда нужна новая версия строки символов, .NET Framework создаёт новую строку.

При конкатенации строк избегайте Concat, + и +=. Особенно важно это для циклов и часто вызываемых методов. В коде ниже эффективнее StringBuilder:

var productIds = string.Empty;
while (match.Success)
{
   productIds += match.Groups[2].Value + "\n";
   match = match.NextMatch();
}

В циклах старайтесь не создавать временных строк. Ниже не изменяется SearchValue.ToUpper():

if (SelectedColumn == Resources.Journaux.All && !String.IsNullOrEmpty(SearchValue))
    source = model.DataSource.Where(x => x.ItemId.Contains(SearchValue)
        || x.ItemName.ToUpper().Contains(SearchValue.ToUpper())
        || x.ItemGroupName.ToUpper().Contains(SearchValue.ToUpper())
        || x.CountingGroupName.ToUpper().Contains(SearchValue.ToUpper()));
 
 
if (SelectedColumn == Resources.Journaux.ItemNumber)
    source = model.DataSource.Where(x => x.ItemId.ToUpper().Contains(SearchValue.ToUpper()));
 
 
if (SelectedColumn == Resources.Journaux.ItemName)
    source = model.DataSource.Where(x => x.ItemName.ToUpper().Contains(SearchValue.ToUpper()));
 
 
if (SelectedColumn == Resources.Journaux.ItemGroup)
    source = model.DataSource.Where(x => x.ItemGroupName.ToUpper().Contains(SearchValue.ToUpper()));
 
if (SelectedColumn == Resources.Journaux.CountingGroup)
    source = model.DataSource.Where(x => x.CountingGroupName.ToUpper().Contains(SearchValue.ToUpper()));

Эффект усугубляется Where(), где строка в верхнем регистре создаётся для каждого элемента последовательности! Это верно и для типов, предоставляющих доступ на основе строки:

if (!uriBuilder.ToString().EndsWith(".", true, invCulture))

ToString() не нужен, доступ к последнему элементу можно получить напрямую:

if (uriBuilder[uriBuilder.Length - 1] != '.')

Интернирование и кэширование строк

Лучше статически кешировать объекты только для чтения, а не создавать их при каждом вызове:

var allCampaignStatuses = 
   ((CampaignActivityStatus[])Enum.GetValues(typeof(CampaignActivityStatus)))
   .ToList();

Элементы перечисления не изменятся, поэтому замените перечисление статическим списком.

И последнее, но не менее важное: применяя строковые ключи с несколькими разными значениями, вы можете попросить CLR кешировать значение и всегда возвращать одну и ту же ссылку. Это называют интернированием. Подробности читайте в Microsoft Docs.

Не создавайте объекты повторно

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

var allCampaignStatuses = 
   ((CampaignActivityStatus[])Enum.GetValues(typeof(CampaignActivityStatus)))
   .ToList();
   // use allCampaignStatuses in the rest of the method

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

В работе с циклом избегайте повторных вызовов и сохраняйте значения в локальных переменных; об этом особенно легко забыть, имея дело с методами строк ToLower() и ToUpper(). Новая временная строка создаётся в key.ToLower() каждой проверкой:

var found = elements.Any(
// ToLower() is called in each test
k => string.Compare(
       k.ToLower(), 
       key.ToLower(), 
       StringComparison.OrdinalIgnoreCase
       ) == 0);

Чтобы избежать вызова ToLower() или ToUpper() только ради сравнения строк, как ниже, работайте с методом String.Compare (...,... StringComparison.OrdinalIgnoreCase). Этот код:

if (transactionIdAsString != null && transactionIdAsString.ToLowerInvariant() == "undefined")

преображается в такой:

if (transactionIdAsString != null && string.Compare(transactionIdAsString, "undefined", StringComparison.OrdinalIgnoreCase) == 0)

Лучшие практики LINQ

LINQ работает везде, снижающие общую производительность детали встречаются очень часто.

Предпочитайте IEnumerable IList

Большинство методов итерируют представленные IEnumerable последовательности через foreach() или благодаря методам расширения System.Linq.Enumerable. С IList работайте, когда последовательность нужно модифицировать:

image-loader.svg

Если элементы не добавляются и не удаляются, как параметр метода вместо IList воспользуйтесь IEnumerable: тогда перед вызовом в клиентском коде не нужен ToList(). В большинстве случаев последовательность просто итерируется foreach, поэтому они также должны возвращать тип IEnumerable, но не IList.

FirstOrDefault и Any могут не понадобиться

Нет необходимости перед foreach вызывать Any или, ещё хуже, ToList().Count > 0, как ниже:

if (sequence != null && sequence.Any())
{
   foreach (var item in sequence)
   ...
}

Избегайте избыточных ToList () и ToArray ()

Пока соответствующая последовательность не будет итерирована , к примеру, через foreach, запросы LINQ должны откладывать выполнение. То же верно, когда на таком запросе вызываются ToList() или ToArray():

var resourceNames = resourceAssembly
.GetManifestResourceNames()
.Where(r => r.StartsWith($"{resourcePath}.i18n"))
.ToArray();

foreach (var resourceName in resourceNames)
{
   ...
}

Метод ToList() создаёт экземпляр List<> с элементами заданной последовательности. Из-за реализации добавления элементов в List<> стоимость создания списка из большой последовательности объектов может оказаться высокой в смысле памяти и производительности, поэтому используйте ToList() с осторожностью. Вот рекомендуемые варианты его применения:

  1. Оптимизация, чтобы избегать многократного выполнения основного запроса, если он дорог.

  2. Удаление и добавление элементов последовательности.

  3. Хранение результата запроса в поле класса.

Часто при итерации по IEnumerable вызывать ToList() не нужно: его вызовом из-за ненужного ListenAddress вы навредите выполнению с точки зрения потребления памяти, но это временно; также последовательность будет итерироваться дважды, что снизит производительность.

Основа LINQ to Object — интерфейс IEnumerable для итерации по последовательности объектов. Все методы расширения LINQ в качестве параметра принимают экземпляры IEnumerable в дополнение к конструкциям foreach. Также ToList() не нужен, когда ожидается IEnumerable. В этом заключается достойная причина предпочесть IEnumerable IList/List/[] в сигнатурах методов.

Некоторые методы вызывают ToList() перед применением Where к IEnumerable. Эффективнее располагать Where цепочкой и вызывать ToList() в конце.

И последнее, но только по порядку: не нужно вызывать ToList(), чтобы выяснить длину последовательности, то есть писать так:

productInfos
  .Select(p => p.Split(DisplayProductInfoSeparator)[0])
  .Distinct()
  .ToList()
  .Count;

Эффективнее такой код:

productInfos
  .Select(p => p.Split(DisplayProductInfoSeparator)[0])
  .Distinct()
  .Count();

IEnumerable<>.Any лучше List<>.Exists

При работе с IEnumerable вместо ToList().Exists() рекомендуется Any:

if (sequence.ToList().Exists(…))

превращается в такой код:

if (sequence.Any(...))

В проверке того, что список пуст, Any лучше Count

Методы расширения Any следует предпочесть вычислениям на IEnumerable, потому что итерация на последовательности останавливается, как только условие (если оно есть) выполнено, без выделения временного списка. Этот код:

var nonArchivedCampaigns = 
   campaigns
   .Where(c => c.Status != CampaignActivityStatus.Archived)
   .ToList();
if (nonArchivedCampaigns.Count == 0)

преображается в такой:

if (!campaigns.Where(c => c.Status != CampaignActivityStatus.Archived).Any())

Обратите внимание: допустимо написать if (!campaigns.Any (filter)).

Порядок в методах расширения может иметь значение

Порядок применяемых к последовательностям IEnumerable операторов может повлиять на производительность. Одно из важных правил — всегда сначала фильтровать, чтобы перед итерацией последовательности становились всё меньше и меньше. Именно поэтому запрос LINQ рекомендуется начинать с фильтров Where. В LINQ код определения запроса в смысле выполнения может быть обманчивым. Например, в чём разница между:

var filteredElements = sequence
  .Where(first filter)
  .Where(second filter)
  ;

и:

var filteredElements = sequence
  .Where(first filter && second filter)
  ;

Это зависит от исполнителя запроса. В случае LINQ for Objects, похоже, после фильтрации в строках нет никакой разницы: оба фильтра выполняются одинаковое число раз:

 var integers = Enumerable.Range(1, 6);
 var set1 = integers
 .Where(i => IsEven(i))
 .Where(i => IsMultipleOf3(i));

 foreach (var current in set1)
 {
     Console.WriteLine($"--> {current}");
 }

 Console.WriteLine("--------------------------------");

 var set2 = integers
 .Where(i => IsEven(i) && IsMultipleOf3(i))
 ;

 foreach (var current in set1)
 {
     Console.WriteLine($"--> {current}");
 }

Запустив код, в консоли вы увидите одинаковые строки:

IsEven(1)
IsEven(2)
   IsMultipleOf3(2)
IsEven(3)
IsEven(4)
   IsMultipleOf3(4)
IsEven(5)
IsEven(6)
   IsMultipleOf3(6)
--> 6
--------------------------------
IsEven(1)
IsEven(2)
   IsMultipleOf3(2)
IsEven(3)
IsEven(4)
   IsMultipleOf3(4)
IsEven(5)
IsEven(6)
   IsMultipleOf3(6)
--> 6

Однако под Benchmark.NET у «объединённого» выражения Where результаты значительно лучше:

 private int[] _myArray;

 [Params(10, 1000, 10000)]
 public int Size { get; set; }

 [GlobalSetup]
 public void Setup()
 {
     _myArray = new int[Size];

     for (var i = 0; i < Size; i++)
         _myArray[i] = i;
 }

 [Benchmark(Baseline = true)]
 public void Original()
 {
     var set = _myArray
         .Where(i => IsEven(i))
         .Where(i => IsMultipleOf3(i))
         ;

     int i;
     foreach (var current in set)
     {
         i = current;
     }
 }

 [Benchmark]
 public void Merged()
 {
     var set = _myArray
         .Where(i => IsEven(i) && IsMultipleOf3(i))
         ;

     int i;
     foreach (var current in set)
     {
         i = current;
     }
 }

image-loader.svg

Изучив реализацию в .NET Framework с моим коллегой Джином-Филипе, мы увидели, что дополнительные затраты, похоже, имеют отношение к базовому IEnumerator, связанному с первым Where, [но] никогда не предполагайте, вместо этого всегда измеряйте.

И приходите на наш курс по разработке на С#, где вы сможете выйти на новый уровень владения этим языком или изучить его с нуля и поработать с HR, чтобы стать Junior-разработчиком. Также вы можете узнать, как изменить карьеру в других направлениях:

image-loader.svg

Data Science и Machine Learning

Python, веб-разработка

Мобильная разработка

Java и C#

От основ — в глубину

А также:

»

© Habrahabr.ru