Введение: почему производительность C# имеет значение
C# и платформа .NET — мощные инструменты для создания высоконагруженных веб-сервисов, десктопных приложений и игр на Unity. Однако даже самый элегантный код может работать медленно, если не учитывать особенности работы CLR (Common Language Runtime), сборки мусора (GC) и механизмов JIT-компиляции.
В этой статье мы разберем ключевые методы оптимизации производительности C# приложений, которые помогут вам сократить время отклика, уменьшить потребление памяти и повысить пропускную способность системы. Все советы сопровождаются практическими примерами кода.
1. Управление памятью и сборка мусора (GC)
Одна из главных особенностей .NET — автоматическое управление памятью. Но неправильное использование объектов приводит к частым сборкам мусора, что вызывает "замирания" (pause) приложения.
1.1. Избегайте аллокаций в горячих путях
Старайтесь не создавать новые объекты внутри циклов или часто вызываемых методов. Вместо этого используйте пулы объектов или переиспользуйте экземпляры.
// Плохо: создается новый StringBuilder на каждой итерации
for (int i = 0; i < 1000; i++)
{
var sb = new StringBuilder();
sb.Append(i);
}
// Хорошо: используем пул или переиспользуем
var sbPool = new StringBuilder();
for (int i = 0; i < 1000; i++)
{
sbPool.Clear();
sbPool.Append(i);
}
1.2. Используйте структуры вместо классов для маленьких данных
Структуры (struct) хранятся на стеке и не создают нагрузки на GC. Но будьте осторожны: большие структуры (более 16 байт) могут копироваться медленно.
public struct Point
{
public int X;
public int Y;
}
// Теперь вызов метода не создает объект в куче
void ProcessPoint(Point p) { /* ... */ }
1.3. Включайте режим Server GC для серверных приложений
По умолчанию в консольных приложениях используется Workstation GC. Для веб-сервисов (ASP.NET Core) переключитесь на Server GC в файле csproj:
<PropertyGroup>
<ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>
2. Оптимизация работы с LINQ и коллекциями
LINQ — удобный, но потенциально медленный инструмент, если не знать его "подводных камней".
2.1. Избегайте множественных перечислений
Каждый вызов .Count(), .ToList() или .Any() может повторно выполнять запрос. Сохраняйте результат в переменную.
// Плохо: два прохода по коллекции
if (items.Where(x => x.IsActive).Count() > 0) { /* ... */ }
var activeList = items.Where(x => x.IsActive).ToList();
// Хорошо: один проход
var activeItems = items.Where(x => x.IsActive).ToList();
if (activeItems.Count > 0) { /* ... */ }
2.2. Используйте специализированные коллекции
Для частого поиска по ключу используйте Dictionary<TKey, TValue>, а не List<T>. Для операций "первым вошел — первым вышел" — Queue<T>.
// Быстрый поиск: O(1) вместо O(n)
var dict = new Dictionary<int, string>();
dict.Add(1, "one");
if (dict.ContainsKey(1)) { /* ... */ }
2.3. Заменяйте LINQ на циклы в критических секциях
Для простых операций (сумма, фильтрация) цикл for или foreach может быть в 2-3 раза быстрее LINQ.
// LINQ (медленно)
int sum = numbers.Sum();
// Цикл (быстро)
int sum = 0;
for (int i = 0; i < numbers.Length; i++)
{
sum += numbers[i];
}
3. Асинхронное программирование и снижение блокировок
Правильное использование async/await позволяет не блокировать потоки, что критично для масштабируемости.
3.1. Используйте ValueTask для частых синхронных результатов
Если метод часто возвращает результат синхронно (например, из кэша), ValueTask<T> уменьшает аллокации по сравнению с Task<T>.
public ValueTask<int> GetCachedValueAsync(int key)
{
if (cache.TryGetValue(key, out int value))
return new ValueTask<int>(value); // синхронно
return new ValueTask<int>(LoadFromDbAsync(key)); // асинхронно
}
3.2. Избегайте блокирующих вызовов в асинхронном коде
Никогда не используйте .Result или .Wait() — это приводит к deadlock'ам и перегрузке пула потоков.
// Плохо
var data = GetDataAsync().Result;
// Хорошо
var data = await GetDataAsync();
4. Профилирование и продвинутые техники
Прежде чем оптимизировать, всегда измеряйте. Используйте профилировщики (dotMemory, PerfView, BenchmarkDotNet) и сниппеты для замера времени.
4.1. Используйте Span<T> для работы с памятью без копирования
Span<T> позволяет работать с фрагментами массивов, строк и неуправляемой памяти без выделения новых буферов.
// Обработка части массива без копирования
int[] array = { 1, 2, 3, 4, 5 };
Span<int> slice = array.AsSpan(1, 3); // {2, 3, 4}
slice[0] = 10; // меняет исходный массив
4.2. Используйте BenchmarkDotNet для точных замеров
Эта библиотека учитывает прогревы JIT, шумы и выдает статистически достоверные результаты.
[MemoryDiagnoser]
public class MyBenchmark
{
[Benchmark]
public void TestMethod() { /* ваш код */ }
}