
В мире современного программирования эффективное использование ресурсов, включая память, является ключевым аспектом разработки приложений. Сегодня мы поговорим о том, как можно оптимизировать доступные ресурсы в процессе разработки.
Язык программирования C#, несмотря на то, что обеспечивает автоматическое управление памятью с помощью механизма сборки мусора (GC), требует от разработчиков специальных знаний и навыков для оптимизации работы с памятью.

Итак, давайте рассмотрим различные стратегии и методы оптимизации памяти в C#, которые помогают создавать эффективные и быстрые приложения.
Прежде чем начать, я хотел бы отметить, что эта статья не является панацеей и может рассматриваться лишь как вспомогательное средство для ваших дальнейших исследований.
Работа с управляемой и неуправляемой памятью
Прежде чем углубляться в детали оптимизации памяти в C#, важно понимать различие между управляемой и неуправляемой памятью.
Управляемая память
Это память, управление которой полностью лежит ��а плечах CLR (Common Language Runtime). В C# все объекты создаются в управляемой куче и автоматически уничтожаются сборщиком мусора, когда они больше не нужны.

Неуправляемая память
Это память, управляемая разработчиком. В C# вы можете работать с неуправляемой памятью, взаимодействуя с низкоуровневыми API или используя ключевые слова unsafeand и and fixed. Неуправляемая память может использоваться для оптимизации производительности в критически важных участках кода, но требует тщательного обращения во избежание утечек памяти или ошибок.
В Unity практически нет неуправляемой памяти, а сборщик мусора работает несколько иначе, чем в чистом .Net, поэтому вам следует полагаться на себя и понимать, как работает управляемая память на базовом уровне, чтобы знать, при каких условиях она будет очищена, а при каких — нет.
Разумное использование структур данных
Выбор подходящей структуры данных — ключевой аспект оптимизации памяти. Вместо использования сложных объектов и коллекций , которые могут потреблять больше памяти из-за дополнительных метаданных и информации управления, следует отдавать предпочтение простым структурам данных, таким как массивы, списки и структуры.
Массивы и списки
Рассмотрим пример:
// Использует больше памяти
List<string> names = new List<string>();
names.Add("John");
names.Add("Doe");
// Использует меньше памяти
string[] names = new string[2];
names[0] = "John";
names[1] = "Doe";
В этом примере string[]массив требует меньше памяти по сравнению с исходным, List<string>поскольку не имеет дополнительной структуры данных для управления динамическим изменением размера.
Однако это не означает, что всегда следует использовать массивы вместо списков. Следует понимать, что если вам часто приходится добавлять новые элементы и перестраивать массив, или выполнять сложные поиски, которые уже предусмотрены в списке, лучше выбрать второй вариант.
Структуры против классов
Насколько мы знаем, классы и структуры довольно похожи друг на друга, хотя и с некоторыми различиями (только это не тема данной статьи), но всё же у них есть существенные различия в способе организации в памяти нашего приложения. Понимание этого может значительно сэкономить время выполнения и оперативную память, особенно при работе с большими объёмами данных. Давайте рассмотрим несколько примеров.

Итак, предположим, у нас есть класс с массивами и структура с массивами . В первом случае массивы будут храниться в оперативной памяти нашего приложения, а во втором — в кэше процессора (с учетом некоторых особенностей сборки мусора, которые мы обсудим ниже). Если мы будем хранить данные в кэше CPU , мы ускорим доступ к необходимым данным, в некоторых случаях от 10 до 100 раз (конечно, все зависит от особенностей CPU и RAM, а в наши дни процессоры стали гораздо умнее взаимодействовать с компиляторами, обеспечивая более эффективный подход к управлению памятью).
Таким образом, со временем, по мере заполнения или организации нашего класса, данные перестанут располагаться вместе в памяти из-за особенностей обработки кучи, поскольку наш класс является ссылочным типом и располагается в ячейках памяти более хаотично. Со временем фрагментация памяти затрудняет перемещение данных в кэш для процессора, что создает проблемы �� производительностью и скоростью доступа к этим данным.
// Class Array Data
internal class ClassArrayData
{
public int value;
}
// Struct Array Data
internal struct StructArrayData
{
public int value;
}
Рассмотрим варианты того, когда следует использовать классы, а когда — структуры.
Когда не следует заменять классы структурами:
Вы работаете с небольшими массивами. Для того чтобы результат можно было измерить, вам нужен достаточно большой массив.
У вас слишком большие объемы данных. Процессор не может кэшировать достаточное количество данных, и в итоге они оказываются в оперативной памяти.
В структурах есть ссылочные типы, такие как String. Они могут указывать на оперативную память так же, как и классы.
Вы недостаточно используете массив. Для корректной работы нам необходима фрагментация.
Вы используете сложную коллекцию, например, List. Нам необходимо фиксированное выделение памяти.
Вы не обращаетесь к массиву напрямую. Если вы хотите передавать данные функциям, используйте класс.
Вам по-прежнему нужна функциональность классов. Не стоит писать неоптимальный код, если вам нужна как функциональность классов, так и производительность структур.
Когда еще стоит заменить класс структурой:
Например, моделирование воды, в котором используется большой массив векторов скорости.
Градостроительная игра с множеством игровых объектов, обладающих одинаковым поведением. Например, автомобили.
Система частиц, работающая в режиме реального времени.
Рендеринг на CPU с использованием большого массива пикселей.
Увеличение производительности на 90% — это много, поэтому, если вас это заинтересовало, я настоятельно рекомендую провести собственные тесты.
Также хочу привести пример тестов производительности со смешанными элементами массивов, основанных на классах и структурах (выполнено на процессоре Intel Core i5-11260H 2,6 Гц, итеративно на 100 миллионах операций с 5 попытками ):
Структура (мс) |
Класс (мс) |
Хаотичность значений |
|---|---|---|
115 мс |
155 мс |
Без перемешивания |
105 мс |
620 мс |
10% перемешанных значений |
120 мс |
840 мс |
25% перемешанных значений |
125 мс |
1050 мс |
50% перемешанных значений |
140 мс |
1300 мс |
100% перемешанных значений |
Да, речь идёт об огромных объёмах данных, но я хотел подчеркнуть, что компилятор не может угадать, как вы хотите использовать эти данные, в отличие от вас самих, — и только вы сами решаете, как хотите получить к ним доступ.
Избегайте утечек памяти
Утечки памяти могут возникать из-за небрежного обращения с объектами и ссылками на них. В C# сборщик мусора автоматически освобождает память, когда объект больше не используется, но если в памяти остаются ссылки на объекты, они не будут удалены.

Примеры кода, демонстрирующие утечку памяти
При работе с управляемыми ресурсами, такими как файлы, сетевые подключения или базы данных, убедитесь, что они надлежащим образом освобождаются после использования. В противном случае это может привести к утечкам памяти или исчерпанию системных ресурсов.
Итак, давайте рассмотрим пример кода, демонстрирующего утечку памяти в C#:
public class MemoryLeakSample
{
public static void Main()
{
while (true)
{
Thread thread = new Thread(new ThreadStart(StartThread));
thread.Start();
}
}
public static void StartThread()
{
Thread.CurrentThread.Join();
}
}
А вот код, вызывающий утечку памяти в Unity:
int frameNumber = 0;
WebCamTexture wct;
Texture2D frame;
void Start()
{
frameNumber = 0;
wct = new WebCamTexture(WebCamTexture.devices[0].name, 1280, 720, 30);
Renderer renderer = GetComponent<Renderer>();
renderer.material.mainTexture = wct;
wct.Play();
frame = new Texture2D(wct.width, wct.height);
}
void Update()
{
if (wct.didUpdateThisFrame == false)
return;
++frameNumber;
if (frame.width != wct.width || frame.height != wct.height)
{
frame.Resize(wct.width, wct.height);
}
frame.SetPixels(wct.GetPixels());
frame.Apply();
}
Уже догадались почему в этих двух примерах происходят утечки?
В C# существует множество способов избежать утечек памяти. Мы можем предотвратить утечки памяти при работе с неуправляемыми ресурсами с помощью оператора using, который внутренне вызывает метод Dispose(). Синтаксис оператора using выглядит следующим образом:
using(var ourObject = new OurDisposableClass)
{
}
При использовании управляемых ресурсов, таких как базы данных или сетевые соединения, также рекомендуется использовать пулы соединений для снижения накладных расходов на создание и удаление ресурсов.
Оптимизация работы с большими объемами данных
При работе с большими объемами данных важно избегать ненужного копирования и использовать эффективные структуры данных. Например, если вам нужно обрабатывать большие текстовые строки, используйте StringBuilderвместо обычных строк, чтобы избежать ненужного выделения памяти.
// Плохой вариант
string result = "";
for (int i = 0; i < 10000; i++) {
result += i.ToString();
}
// Хороший вариант
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
sb.Append(i);
}
string result = sb.ToString();
Также следует избегать ненужного выделения памяти при работе с коллекциями. Например, если вы используете LINQ для фильтрации списка, вы можете преобразовать результат в массив с помощью соответствующего ToArray()метода, чтобы избежать создания лишнего списка.
// Плохой пример
List<int> numbers = Enumerable.Range(1, 10000).ToList();
List<int> evenNumbers = numbers.Where(n => n % 2 == 0).ToList();
// Пример получше
int[] numbers = Enumerable.Range(1, 10000).ToArray();
int[] evenNumbers = numbers.Where(n => n % 2 == 0).ToArray();
Профилирование и оптимизация кода
Профилирование кода позволяет выявлять узкие места и оптимизировать их для повышения производительности и эффективности использования памяти. Существует множество инструментов профилирования для C#, таких как dotTrace, ANTS Performance Profiler и Visual Studio Profiler.
В Unity есть собственный профилировщик памяти. Подробнее о нём можно прочитать здесь.
Профилирование позволяет вам:
Определить участки кода, которые потребляют больше всего памяти.
Выявлять утечки памяти и ненужные выделения памяти.
Оптимизировать алгоритмы и структуры данных для снижения потребления памяти.
Оптимизация приложений для конкретных сценариев.
В зависимости от конкретных сценариев использования вашего приложения, некоторые стратегии оптимизации могут быть более или менее подходящими. Например, если ваше приложение работает в реальном времени (как игры), вы можете столкнуться с проблемами производительности из-за сборки мусора, и вам может потребоваться использовать специализированные структуры данных или алгоритмы для решения этой проблемы (например, Unity DOTS и Burst Compiler).
Оптимизация с использованием управляемой памяти (небезопасный код)
Хотя использование unsafeпамяти в C# должно быть осторожным и ограниченным, существуют сценарии, когда использование unsafeкода может значительно повысить производительность. Это может быть особенно полезно при работе с большими объемами данных или при написании низкоуровневых алгоритмов, где накладные расходы на сборку мусора становятся значительными.
unsafe
{
int x = 10;
int* ptr;
ptr = &x;
Console.WriteLine("Inside the unsafe code block");
Console.WriteLine("The value of x is " + *ptr);
}
Console.WriteLine("Outside the unsafe code block");
Однако использование unsafeкода требует серьезного понимания внутренних механизмов работы с памятью и многопоточностью в .NET, а также дополнительных мер предосторожности, таких как проверка границ массивов и бережное обращение с указателями.
Заключение
Оптимизация памяти в C# — критически важный аспект разработки эффективных и быстрых приложений. Понимание основных принципов управления памятью, выбор правильных структур данных и алгоритмов, а также использование инструментов профилирования помогут вам создать эффективное приложение, которое рационально использует системные ресурсы и обеспечивает высокую производительность.
Однако не забывайте, что помимо оптимизации кода, следует также оптимизировать ресурсы приложения (например, это особенно актуально для игр, где необходимо работать со сжатием текстур, оптимизацией рендеринга, динамической загрузкой и выгрузкой ресурсов с помощью пакетов и т. д.).
И, конечно же, спасибо за прочтение статьи, я с удовольствием обсужу с вами различные аспекты оптимизации и кода.
VasiliyKudryavtsev
Некачественный перевод не особенно качественной статьи без указания, что это перевод((
Memory Optimization in C#: Effective Practices and Strategies - DEV Community
poznohub Автор
Привет. Это моя же статья на dev.to, забыл указать что это перевод