Представьте себе: ваше приложение на .NET Core работает медленнее ленивца в воскресенье, пользователи покидают его быстрее крыс с «Титаника», а сервер потребляет память, как чёрная дыра — свет. Звучит знакомо? Не волнуйтесь, мы все бывали в такой ситуации! Сегодня мы превратим ваше медлительное приложение в эффективную и производительную машину.

Оптимизация производительности — это не только ускорение работы, но и обеспечение устойчивости, масштабируемости и удобства использования вашего приложения. Это как если бы вы дали своему коду персонального тренера и диетолога в одном лице.

Детектив производительности: понимание того, что не так

Прежде чем начать оптимизировать как сумасшедшие учёные, нам нужно понять, что на самом деле происходит под капотом. Вы же не будете пытаться починить машину, не выяснив сначала, что сломано, верно? Тот же принцип применим и здесь.

Настройка вашего арсенала профилирования

Первым делом давайте заполучим подходящие инструменты профилирования. Вот основные игроки в мире профилирования .NET Core:

Встроенные инструменты:

  • dotnet-counters — метрики производительности в реальном времени
  • dotnet-trace — трассировка событий
  • dotnet-dump — анализ дампов памяти
  • dotnet-gcdump — анализ сборки мусора

Сторонние герои:

  • JetBrains dotMemory
  • PerfView (бесплатный инструмент от Microsoft)
  • Application Insights
  • MiniProfiler

Начнём с основ. Установите диагностические инструменты глобально:

dotnet tool install --global dotnet-counters
dotnet tool install --global dotnet-trace
dotnet tool install --global dotnet-dump
dotnet tool install --global dotnet-gcdump

Пошаговая инструкция: ваше первое исследование производительности

Вот как провести надлежащее исследование производительности, не потеряв рассудка:

Шаг 1: Установите базовый уровень

Прежде чем что-либо менять, измерьте всё! Создайте простую настройку мониторинга производительности:

public class PerformanceMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<PerformanceMiddleware> _logger;
    public PerformanceMiddleware(RequestDelegate next, ILogger<PerformanceMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }
    public async Task InvokeAsync(HttpContext context)
    {
        var stopwatch = Stopwatch.StartNew();
        var initialMemory = GC.GetTotalMemory(false);
        await _next(context);
        stopwatch.Stop();
        var finalMemory = GC.GetTotalMemory(false);
        var memoryUsed = finalMemory - initialMemory;
        _logger.LogInformation(
            "Запрос {Method} {Path} занял {ElapsedMilliseconds} мс и использовал {MemoryUsed} байт",
            context.Request.Method,
            context.Request.Path,
            stopwatch.ElapsedMilliseconds,
            memoryUsed);
    }
}

Шаг 2: Мониторинг метрик в реальном времени

Используйте dotnet-counters, чтобы получить актуальные данные о производительности:

dotnet-counters monitor --process-id <ваш-идентификатор-процесса> \
    --counters System.Runtime,Microsoft.AspNetCore.Hosting

Это покажет вам метрики в реальном времени, такие как активность сборки мусора, использование памяти и пропускная способность запросов.

Шаг 3: Захват детальных трассировок

Когда вы заметите что-то подозрительное, захватите трассировку для более глубокого анализа:

dotnet-trace collect --process-id <ваш-идентификатор-процесса> \
    --duration 00:00:30 --format speedscope

Управление памятью: искусство не быть расточительным

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

Танго со сборкой мусора

Понимание сборки мусора в .NET Core похоже на понимание ритма танца. Иногда он плавный, иногда наступает на пятки, но с правильными движениями можно добиться красивой работы.

graph TD A[Создан объект] --> B[Сбор в Gen 0] B --> C{Объект выжил?} C -->|Да| D[Продвижение в Gen 1] C -->|Нет| E[Объект уничтожен] D --> F[Сбор в Gen 1] F --> G{Объект выжил?} G -->|Да| H[Продвижение в Gen 2] G -->|Нет| I[Объект уничтожен] H --> J[Сбор в Gen 2 — дорогой!] J --> K[Долгоживущий объект]

Span и Memory: ваши новые лучшие друзья

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

Вот как использовать Span<T> для обработки данных без ненужных выделений:

// Вместо этого расточительного подхода
public string ProcessDataOldWay(byte[] data)
{
    var substring = new byte[data.Length / 2];
    Array.Copy(data, 0, substring, 0, substring.Length);
    return Convert.ToBase64String(substring);
}
// Используйте этот подход без выделения памяти
public string ProcessDataNewWay(ReadOnlySpan<byte> data)
{
    var slice = data.Slice(0, data.Length / 2);
    return Convert.ToBase64String(slice);
}

Совет профессионала: Span<T> работает только со стеком, что означает, что его нельзя использовать в асинхронных методах. Для асинхронных сценариев используйте Memory<T>.

public async ValueTask<string> ProcessDataAsync(Memory<byte> data)
{
    await Task.Delay(100); // Имитация асинхронной работы
    var slice = data.Span.Slice(0, data.Length / 2);
    return Convert.ToBase64String(slice);
}

Избегание ловушки Large Object Heap (LOH)

Large Object Heap — это как тот друг, который никогда не убирает за собой — объекты попадают туда, но не выходят легко. Объекты размером более 85 000 байт попадают сюда и собираются только во время сборки мусора поколения 2, что дорого.

Вот как избежать выделений LOH:

// Плохо: создаёт большой массив, который идёт в LOH
public byte[] CreateLargeBuffer()
{
    return new byte[100_000]; // Это идёт в LOH!
}
// Хорошо: используйте ArrayPool для повторного использования больших объектов
private static readonly ArrayPool<byte> _arrayPool = ArrayPool<byte>.Shared;
public void ProcessLargeData()
{
    var buffer = _arrayPool.Rent(100_000);
    try
    {
        // Используйте буфер
        ProcessBuffer(buffer.AsSpan(0, 100_000));
    }
    finally
    {
        _arrayPool.Return(buffer);
    }
}

Оптимизация строк: потому что строки хитрые

Строки в .NET неизменяемы, что означает, что каждый раз, когда вы их объединяете, вы создаёте новый объект. Это как делать новый бутерброд каждый раз, когда вы хотите добавить огурец — расточительно и беспорядочно!

// Это создаёт несколько объектов строк — кошмар с памятью!
public string BuildQueryOldWay(string[] parameters)
{
    string query = "SELECT * FROM Users WHERE ";
    for (int i = 0; i < parameters.Length; i++)
    {
        query += $"param{i} = '{parameters[i]}'";
        if (i < parameters.Length - 1)
            query += " AND ";
    }
    return query;
}
// Это использует один StringBuilder — намного лучше!
public string BuildQueryNewWay(string[] parameters)
{
    var query = new StringBuilder("SELECT * FROM Users WHERE ");
    for (int i = 0; i < parameters.Length; i++)
    {
        query.Append($"param{i} = '{parameters[i]}'");
        if (i < parameters.Length - 1)
            query.Append(" AND ");
    }
    return query.ToString();
}
// Ещё лучше: используйте диапазоны для форматирования
public string BuildQueryBestWay(ReadOnlySpan<string> parameters)
{
    var totalLength = EstimateLength(parameters);
    return string.Create(totalLength, parameters, (span, state) =>
    {
        // Пользовательская логика построения строки с использованием диапазонов
        BuildQueryInSpan(span, state);
    });
}

Асинхронное программирование: умножитель производительности

Асинхронное программирование в