Публикацию подготовил Стефан Тауб (Stephen Toub), постоянный автор блога Parallel Programming in .NET (Параллельное программирование в .NET) В этот раз он, Visual Studio 2012 и внимание к деталям помогут вам выявить случаи ненужного выделения памяти в ваших приложениях и повысить их производительность.

Visual Studio 2012 содержит огромное количество самых разнообразных функций. Их очень много, но я, как ни странно, периодически слышу просьбы разработчиков, которые уже используют Visual Studio, добавить ту или иную функцию, хотя эта функция уже существует в этой среде, а они ее просто не находят. А иногда разработчики задают вопросы о какой-то функции, думая, что она нужна для одной цели, а на самом деле у нее совсем другое предназначение.

Оба эти случая справедливы для профилировщика выделения памяти .NET memory allocation profiler, который входит в состав Visual Studio. Многие разработчики могли бы эффективно его использовать, но одни не знают о его существовании, а другие — как им пользоваться. А жаль. Ведь функция может быть очень полезна в различных сценариях, и разработчики смогут извлечь большую пользу для себя, узнав о существовании профилировщика и изучив сценарии его использования.

Для чего нужна функция профилирования памяти

Есть две главных причины для использования диагностических инструментов, когда речь заходит о приложениях .NET и анализе памяти:

  1. Выявление утечек памяти. Утечки в средах выполнения со сборкой мусора, таких как CLR, проявляются не так, как утечки в средах без такой функции, например, в коде, написанном на языке C/C++. Утечка в последнем случае обычно возникает из-за того, то разработчик не освобождает вручную выделенную ранее память. Напротив, в среде с функцией очистки мусора ручного высвобождения памяти не требуется: этим занимается сборщик мусора (СМ). Однако СМ может высвобождать только память, которая вероятно больше не используется, т.е. когда нет корневых ссылок на данные в памяти. Утечки в коде .NET возникают тогда, когда некоторое количество памяти, которое нужно было очистить, все еще ошибочно зарезервировано; например, когда появляется ссылка на объект в обработчике события, зарегистрированного статическим событием. Хороший инструмент для анализа памяти поможет выявить такие утечки. С его помощью, например, можно делать моментальные снимки процесса в два разных момента времени, и сравнивать эти снимки. На втором снимке можно увидеть, какие объекты не высвобождены, а главное — понять почему.
  2. Выявление ненужных выделений. На первый взгляд, в платформе .NET излишнее выделение памяти не слишком влияет на производительность. Однако это только на первый взгляд, поскольку позже, когда СМ выполняет сборку мусора, нагрузка на систему растет. Чем больше памяти выделяется, тем чаще будет запускаться СМ, и обычно чем больше объектов остается после сбора мусора, тем больше работы должен сделать СМ, когда определяет, какие объекты больше недоступны. Поэтому чем больше памяти выделяет программа, тем сильнее СМ нагружает систему. Часто нагрузками, вызываемыми СМ, можно пренебречь. Но в некоторых видах приложений, особенно на стороне сервера, где требуется повышенная производительность, работа СМ может оказать заметное влияние на производительность приложений. Мощный инструмент для анализа поможет понять, какие участки программы выполняют выделение памяти, и избежать потенциально неэффективного выделения.

Профилировщик памяти .NET, входящий в пакет Visual Studio 2012 (выпуски Professional и выше), был разработан в первую очередь для выявления ненужных выделений памяти, и он достаточно эффективно делает эту работу (об этом мы расскажем далее). Инструмент не адаптирован для поиска и устранения утечек первого типа, хотя группа диагностики Visual Studio и планирует развивать эту функцию в будущем (например, подобная функция для JavaScript была добавлена в Visual Studio в рамках обновления VS2012.1). Несмотря на то, что в этом инструменте есть расширенная опция по отслеживанию времени сбора объектов advanced option to track when objects are collected, она не помогает понять, почему мусор не был собран, или почему объекты оставались в памяти дольше, чем ожидалось.

Есть также и другие полезные инструменты для управления выделением памяти. Инструмент PerfView, доступный для загрузки, не имеет удобного интерфейса, такого как профилировщик памяти .NET в Visual Studio 2012. Однако это очень мощный инструмент, который выполняет две задачи: осуществляет поиск утечек памяти и выявляет ненужные выделения. Он также поддерживает профилирование приложений Магазина Windows. Профилировщик памяти .NET в Visual Studio 2012 на момент написания этой заметки такой функции не поддерживал.

Пример оптимизации

Чтобы лучше понять роль профилировщика выделения памяти и его возможности, давайте рассмотрим следующий пример. Начнем с основного метода, который используется для оптимизации (в реальной работе анализируется сначала все приложение, а затем поиск сужается до определенных его областей. Но в данном примере мы не будем заходить так далеко).

public static async Task<T> WithCancellation1<T>(this Task<T> task, CancellationToken cancellationToken)
{
    var tcs = new TaskCompletionSource<bool>();
    using (cancellationToken.Register(() => tcs.TrySetResult(true)))
    if (task != await Task.WhenAny(task, tcs.Task))
        throw new OperationCanceledException(cancellationToken);
    return await task;
}

Цель этого короткого метода — сделать так, чтобы код ожидал появления задачи с возможностью отмены. Это означает, что независимо от того, завершилась ли задача, разработчик хочет не ожидать ее завершения. Вместо того, чтобы писать такой код:

T result = await someTask;

разработчик может написать:

T result = await someTask.WithCancellation1(token);

и если до завершения задачи по соответствующему CancellationToken отправляется запрос на отмену, выдается исключение OperationCanceledException. Эта команда архивируется с помощью WithCancellation1 путем упаковки первоначальной задачи в асинхронный метод. Данный метод создает вторую задачу, которая завершается при поступлении запроса на отмену (путем регистрации вызова с помощью TrySetResult в CancellationToken), а затем использует Task.WhenAny, чтобы подождать завершения первоначальной задачи или ее отмены. После того как произошло одно из двух этих событий, асинхронный метод завершается либо исключением отмены (если вначале завершается задача отмены), либо распространением результата выполнения первоначального задания после его получения. (Подробнее об этом см. публикацию «HowdoIcancelnon-cancelableasyncoperations?» («Как отменить не отменяемые асинхронные операции?»))

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

 

using System;
using System.Threading;
using System.Threading.Tasks;
 
class Harness
{
    static void Main() 
     { 
         Console.ReadLine(); // wait until profiler attaches
         TestAsync().Wait(); 
     }
    static async Task TestAsync()
    {
        var token = CancellationToken.None;
        for(int i=0; i<100000; i++)
            await Task.FromResult(42).WithCancellation1(token);
    }
}
 

static class Extensions
{
    public static async Task<T> WithCancellation1<T>(
    this Task<T> task, CancellationToken cancellationToken)
    {
        var tcs = new TaskCompletionSource<bool>();
        using (cancellationToken.Register(() => tcs.TrySetResult(true)))
            if (task != await Task.WhenAny(task, tcs.Task))
                throw new OperationCanceledException(cancellationToken);
        return await task;
    }
}

Метод TestAsync повторяется 100 000 раз. Каждый раз он создает новую задачу, запускает в ней вызов метода WithCancellation1 и ждет его результата. Это ожидание завершится синхронно, поскольку задача, созданная Task.FromResult, возвращается в уже завершенное состояние, и сам по себе метод WithCancellation1 не имеет никакой дополнительной асинхронности, так что задача, которую он возвращает, также завершается синхронно. 


Запуск профилировщика памяти .NET

Чтобы запустить профилировщик памяти, перейдите в Visual Studio к меню Analyze (Анализировать) и выберите Launch Performance Wizard... (Запустить мастер производительности...). Открывается следующее диалоговое окно:

image

Выберите .NET memory allocation (sampling) (Выделение памяти .NET (выборка), дважды щелкните Next (Дальше), а затем Finish (Завершить) (если вы впервые используете профилировщик памяти после входа в Windows, то для запуска профилировщика нужно принять запрос на повышение прав). В этот момент запускается приложение, и профилировщик начинает мониторинг выделения памяти (кроме того, указанный выше код окружения требует нажатия клавиши «Enter» («Ввести»). Таким образом гарантируется, что профилировщик уже подключился к тому моменту, когда программа запускается в режиме реального тестирования). При завершении программы или при прекращении профилирования вручную, профилировщик загружает символы и начинает анализировать трассировку. Теперь можно выпить чашку кофе или пообедать в зависимости от того, сколько было выделений: инструменту потребуется определенное время на анализ.

По завершении анализа выводится отчет о выделении. В отчете выделяются функции, на которые выделено больше всего памяти, типы, требующие больше всего памяти, и типы, для которых выделено больше всего экземпляров.

image

Мы можем получить более детальную информацию, ознакомившись со списком выделений (выберите Allocation (Выделение) из выпадающего меню Current View (Текущее представление).

image

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

image

Выбрав представление «Functions» («Функции»), мы можем получить другую сводную таблицу по этим данным. В ней указаны функции, которым выделены большая часть объектов и памяти.

image

Интерпретация результатов профилирования и выполнение необходимых действий

Благодаря этой функции мы можем проанализировать результаты нашего примера профилирования. Во-первых, здесь можно видеть большое количество выделений, и это несколько неожиданно. В конце концов, в нашем примере мы используем WithCancellation1 с задачей, которая уже была завершена. Это означает, что работы по очистке должно было быть немного (ведь если задача завершена, отменять уже нечего). И в то же время, из данных трассировки мы видим, что каждая итерация нашего примера приводит к следующему.

  • Три выделения по Задаче 1 (мы можем выполнить окружение 100 тысяч раз и увидеть, что существовало около 300 тысяч выделений)
  • Два выделения для Задачи[]
  • Одно выделение каждой задачи TaskCompletionSource`1Action, — сгенерированное компилятором <>c_DisplayClass2`1 и типом под названием CompleteOnInvokePromise

Это уже девять выделений для данного примера, где, как могло показаться, должно быть одно (выделение по задаче, которое мы явным образом указали в окружении, вызвав Task.FromResult) с помощью метода WithCancellation1, осуществляющего восемь выделений.

Чтобы вспомогательное приложение могло работать с задачами, действия часто выполняются с уже завершенными задачами: реализация такова, что асинхронные операции фактически выполняются синхронно (например, одна операция чтения в сетевом потоке буферизирует в память достаточно дополнительных данных, чтобы выполнить последующую операцию чтения). А значит оптимизация уже завершенной задачи может положительно влиять на производительность. Давайте попробуем. Вот вторая попытка с WithCancellation, оптимизированным для нескольких «уже завершенных» задач.

    public static Task<T> WithCancellation2<T>(this Task<T> task, 
CancellationToken cancellationToken) { if (task.IsCompleted || !cancellationToken.CanBeCanceled) return task; else if (cancellationToken.IsCancellationRequested) return new Task<T>(() => default(T), cancellationToken); else return task.WithCancellation1(cancellationToken); }

Эта реализация выполняет следующие проверки.

  • Во-первых, завершена ли задача или маркер CancellationToken нельзя отменить; в обоих случаях не требуется выполнять никакой дополнительной работы, и отмену выполнить невозможно. А значит, мы можем мгновенно вызвать первоначальную задачу, а не тратить память на создание новых.
  • Был ли уже отправлен запрос на отмену; если да, то можно выделить память под повторный вызов уже отмененной задачи, а не делать восемь выделений, сделанных раннее для вызова первоначальной реализации.
  • Наконец, если ни один из этих способов неприменим, то мы можем использовать первоначальную реализацию.

Перепрофилирование нашего небольшого теста с использованием WithCancellation2 вместо WithCancellation1 дает гораздо лучший результат (вы, возможно, заметите, что анализ выполняется гораздо быстрее, чем раньше. Это само по себе свидетельствует, что выделение памяти существенно уменьшилось). Теперь у нас есть одно основное выделение, как мы и ожидали — из Task.FromResult, вызванное нашим методом TestAsync в окружении.

image

Итак, мы успешно оптимизировали варианты, когда задача уже завершена, когда отмену нельзя запросить, или когда отмена уже запрошена. Но как быть со случаями, когда нужно реализовать более сложную логику? Можно ли здесь что-нибудь улучшить?

Давайте изменим наши тесты так, чтобы можно было использовать задачу, еще не завершенную ко времени вызова WithCancellation2, а также применим маркер, который может вызвать отмену. Это гарантирует, что мы можем использовать «медленный» способ.

    static async Task TestAsync()
    {
        var token = new CancellationTokenSource().Token;
        for (int i = 0; i < 100000; i++)
        {
            var tcs = new TaskCompletionSource<int>();
            var t = tcs.Task.WithCancellation2(token);
            tcs.SetResult(42);
            await t;
        }
    }

Профилирование вновь дает больше информации.

image

В этом медленном методе теперь используется по 14 выделений на каждую итерацию, включая два выделения из окружения TestAsync (TaskCompletionSource<int>, которое мы создаем явным образом, и Task<int>, которую создает это выделение). На данном этапе мы можем использовать всю информацию, предоставленную в результатах профилирования, чтобы понять источник оставшихся 12 выделений, а затем оптимизировать их насколько это возможно. Рассмотрим в качестве примера два конкретных выделения: экземпляр <>c__DisplayClass2`1 и один из двух экземпляров Action. Эти два выделения будут понятны всем, кто ознакомился с заметкой «how the C# compiler handles closures» («Как компилятор C# обрабатывает закрытия»). Почему у нас есть закрытие? Потому что используется эта строка:

using(cancellationToken.Register(() => tcs.TrySetResult(true)))

Вызов Реестра закрывается переменной ‘tcs’. Но это не всегда обязательно: метод Register имеет еще одну перегрузку, которая вместо действия Action использует Action<object> и передаваемое состояние объекта. Если мы заменим эту строку так, чтобы использовать перегрузку на основе состояний вместе с кэшируемым вручную делегатом, то можем избежать закрытия и не использовать два этих выделения.

private static readonly Action<object> s_cancellationRegistration =
    s => ((TaskCompletionSource<bool>)s).TrySetResult(true);
…
using(cancellationToken.Register(s_cancellationRegistration, tcs))
  

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

Начните профилирование сегодня!

Этот цикл профилирования, поиска и устранения проблемных мест можно повторять несколько раз. Такой подход позволяет улучшить производительность кода при использовании профилировщиков центрального процессора или памяти. Поэтому если вы работаете со сценарием, в котором важно свести к минимуму выделения для повышения производительности кода, стоит попробовать профилировщик выделения памяти в Visual Studio 2012. Загрузите пример проекта, использованный в этой публикации: download the sample project used in this blog post.

Больше о профилировании можно прочитать в блоге группы разработчиков Visual Studio Diagnostics team. А на форуме Visual Studio Diagnostics  можно задать интересующие вас вопросы.