[Перевод] Как писать на C# аккуратно: память и производительность
К старту курса о разработке на 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 обычно хранит значения в массиве, размер которого необходимо изменять при добавлении новых элементов; это означает, что:
Выделяется новый массив.
Прежние значения копируются в него.
На прежний массив больше не ссылаются.
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
работайте, когда последовательность нужно модифицировать:
Если элементы не добавляются и не удаляются, как параметр метода вместо 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()
с осторожностью. Вот рекомендуемые варианты его применения:
Оптимизация, чтобы избегать многократного выполнения основного запроса, если он дорог.
Удаление и добавление элементов последовательности.
Хранение результата запроса в поле класса.
Часто при итерации по 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;
}
}
Изучив реализацию в .NET Framework с моим коллегой Джином-Филипе, мы увидели, что дополнительные затраты, похоже, имеют отношение к базовому IEnumerator
, связанному с первым Where
, [но] никогда не предполагайте, вместо этого всегда измеряйте.
И приходите на наш курс по разработке на С#, где вы сможете выйти на новый уровень владения этим языком или изучить его с нуля и поработать с HR, чтобы стать Junior-разработчиком. Также вы можете узнать, как изменить карьеру в других направлениях:
Data Science и Machine Learning
Python, веб-разработка
Мобильная разработка
Java и C#
От основ — в глубину
А также:
»