Поделиться через


Разработка больших, быстро реагирующих приложений .NET Framework

В этой статье приведены советы по повышению производительности крупных приложений .NET Framework или приложений, обрабатывающих большой объем данных, например файлов или баз данных. Эти советы выработаны во время перевода компиляторов C# и Visual Basic на управляемый код, кроме того, здесь приведено несколько реальных примеров из компилятора C#.

Платформа .NET Framework отлично подходит для создания приложений. Эффективные и безопасные языки и обширная коллекция библиотек делают процесс создания приложений крайне плодотворным. Однако высокая производительность требует высокой ответственности. Вам следует использовать все возможности платформы .NET Framework, однако быть готовым к подстройке производительности кода по мере необходимости.

Как производительность нового компилятора затрагивает ваше приложение

Группа разработчиков платформы компиляторов .NET ("Roslyn") переписала компиляторы для языков C# и Visual Basic в управляемом коде, чтобы предоставить новые API для моделирования и анализа кода, средства разработки, а также реализовать в Visual Studio более обширные возможности взаимодействия с учетом кода. Переработка компиляторов и создание процедур взаимодействия Visual Studio на базе новых компиляторов позволили получить ценные сведения по поводу производительности, распространяющиеся на любое крупное приложение .NET Framework или любое приложение, обрабатывающее большой объем данных. Чтобы с пользой воспользоваться этими ценными сведениями и примерами из компилятора C#, вам не нужно углубляться в изучение компиляторов.

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

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

Дополнительные сведения о компиляторах Roslyn см. в разделе SDK платформы компилятора .NET.

Только факты

Принимайте эти факты во внимание во время подстройки производительности и создания приложений .NET Framework, активно реагирующих на действия пользователя.

Факт 1. Преждевременная оптимизация не всегда стоит спешки

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

Факт 2. Если вы не измеряете, значит, вы угадываете

Профили и измерения не врут. Профили показывают, полностью ли загружен ЦП или выполняется ли блокировка на уровне операций ввода-вывода диска. Профили сообщают вам, какой тип и объем памяти вы выделяете и тратит ли ЦП слишком много времени на сборку мусора.

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

Факт 3. Значение хороших средств трудно переоценить

Хорошие средства позволяют вам быстро выявлять наиболее важные проблемы с производительностью (ЦП, память или диск) и помогают локализовать код, вызывающий такие узкие места. Корпорация Майкрософт поставляет различные средства производительности, такие как Visual Studio Profiler и PerfView.

PerfView — это мощный инструмент, который помогает сосредоточиться на глубоких проблемах, таких как операции ввода-вывода диска, события GC и память. Вы можете перехватывать связанные с производительностью события трассировки событий Windows и просматривать информацию для отдельных приложений, процессов, стеков и потоков. PerfView показывает, какой объем и тип памяти выделяет ваше приложение, а также какие функции или стеки вызовов чаще всего осуществляют выделение памяти. Дополнительные сведения см. в подробных разделах справки, демонстрациях и видео, включенных в инструмент.

Факт 4. Выделения памяти значат больше всего

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

Практически вся работа по созданию процедур взаимодействия IDE с помощью API новых компиляторов связана с предотвращением выделений и управлением стратегиями кэширования. Трассировки PerfView показывают, что производительность новых компиляторов C# и Visual редко привязана к ЦП. Компиляторы могут зависеть от операций ввода-вывода при считывании сотен тысяч или миллионов строк кода, считывании метаданных или выводе сформированного кода. Практически все задержки потока пользовательского интерфейса вызваны сборкой мусора. Процедура сборки мусора в .NET Framework реализована с учетом обеспечения высокой производительности и выполняет основную часть работы параллельно с выполнением кода приложения. Однако отдельное выделение может активировать ресурсоемкую сборку gen2, остановив все потоки.

Распространенные выделения и примеры

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

Упаковка-преобразование

Упаковка-преобразование происходит, когда типы значения, которые обычно находятся в стеке или структурах данных, упаковываются в объект. То есть вы выделяете объект для хранения данных, а затем возвращаете указатель на этот объект. Платформа .NET Framework иногда выполняет упаковку-преобразование значений в связи с сигнатурой метода или типом выделения хранилища. Упаковка типа значения в объект приводит к выделению памяти. Многие операции упаковки-преобразования могут приводить к выделению мегабайт и гигабайт памяти в приложении, что увеличит объем сборки мусора. Платформа .NET Framework и компиляторы языков стараются по возможности не использовать упаковку-преобразование, но иногда оно возникает в самый неожиданный момент.

Чтобы просмотреть упаковку-преобразование в PerfView, откройте трассировку и просмотрите "GC Heap Alloc Stacks" (Стеки выделения кучи сборки мусора) под именем процесса вашего приложения (помните, что PerfView включает в отчет все процессы). Если в выделениях видны такие типы, как System.Int32 и System.Char, значит, вы выполняете упаковку-преобразование типов значения. При выборе одного из этих типов отображаются стеки и функции, в которые выполнено упаковка-преобразование.

Пример 1: строковые методы и аргументы типов значения

Этот пример кода показывает ненужное и излишнее упаковка-преобразование:

public class Logger
{
    public static void WriteLine(string s) { /*...*/ }
}

public class BoxingExample
{
    public void Log(int id, int size)
    {
        var s = string.Format("{0}:{1}", id, size);
        Logger.WriteLine(s);
    }
}

Данный код предоставляет функциональность ведения журналов, поэтому приложение может вызывать функцию Log очень часть, возможно, несколько миллионов раз. Проблема заключается в том, что вызов string.Format разрешается в перегрузку Format(String, Object, Object).

Эта перегрузка требует, чтобы платформа .NET Framework выполнила упаковку-преобразование значений int в объекты, чтобы передать их в этот вызов метода. Частичное исправление заключается в вызове id.ToString() и size.ToString() и передаче всех строк (которые являются объектами) в вызов string.Format. Вызов ToString()не выделяет строку, однако такое выделение все равно произойдет внутри string.Format.

Вы можете предположить, что этот базовый вызов string.Format представляет собой просто объединение строк, и написать следующий код:

var s = id.ToString() + ':' + size.ToString();

Однако эта строка кода вызывает выделение памяти с упаковкой-преобразованием, так как она компилируется в Concat(Object, Object, Object). Платформа .NET Framework должна выполнить упаковку-преобразование символьного литерала для вызова Concat.

Исправление для примера 1

Полное исправление довольно простое. Просто замените символьный литерал на строковый, который не требует упаковку-преобразование, так как строки уже являются объектами:

var s = id.ToString() + ":" + size.ToString();

Пример 2: упаковка-преобразование перечисления

Этот пример вызывает выделение большого объема памяти в новых компиляторах C# и Visual Basic из-за частого использования типов перечисления, особенно в операциях поиска в словаре.

public enum Color
{
    Red, Green, Blue
}

public class BoxingExample
{
    private string name;
    private Color color;
    public override int GetHashCode()
    {
        return name.GetHashCode() ^ color.GetHashCode();
    }
}

Эту проблему очень нелегко выявить. PerfView сообщает о ней как об упаковке-преобразовании GetHashCode(), так как для реализации данный метод выполняет упаковку-преобразование базового представления типа перечисления. Если тщательнее просмотреть данные в PerfView, можно заметить два выделения памяти с упаковкой-преобразованием для каждого вызова GetHashCode(). Один из них вставляет компилятор, другой — платформа .NET Framework.

Исправление для примера 2

вы можете легко избежать обоих выделений, выполнив перед вызовом GetHashCode() приведение к базовому представлению:

((int)color).GetHashCode()

Другой распространенной причиной упаковки-преобразования типов перечисления является метод Enum.HasFlag(Enum). Передаваемый в HasFlag(Enum) аргумент подлежит упаковке-преобразованию. В большинстве случаев замена вызовов Enum.HasFlag(Enum) на побитовое тестирование упрощает код и не требует выделения памяти.

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

Строки

Операции со строками являются самым большим источником выделений памяти и часто входят в число первых пяти выделений в PerfView. Программы используют строки для сериализации, JSON и REST API. Вы не можете использовать строки в качестве программных констант для взаимодействия с системами в тех случаях, когда нельзя использовать типы перечисления. Когда профилирование указывает на то, что строки оказывают сильное негативное влияние на производительность, выполните поиск вызовов методов String, таких как Format, Concat, Split, Join, Substring и т. п. Использование StringBuilder для предотвращения создания одной строки из нескольких частей может помочь, однако даже выделение объекта StringBuilder может стать узким местом, требующим вашего внимания.

Пример 3: строковые операции

Компилятор C# содержал этот код, который записывает текст форматированного комментария XML-документа:

public void WriteFormattedDocComment(string text)
{
    string[] lines = text.Split(new[] { "\r\n", "\r", "\n" },
                                StringSplitOptions.None);
    int numLines = lines.Length;
    bool skipSpace = true;
    if (lines[0].TrimStart().StartsWith("///"))
    {
        for (int i = 0; i < numLines; i++)
        {
            string trimmed = lines[i].TrimStart();
            if (trimmed.Length < 4 || !char.IsWhiteSpace(trimmed[3]))
            {
                skipSpace = false;
                break;
            }
        }
        int substringStart = skipSpace ? 4 : 3;
        for (int i = 0; i < numLines; i++)
            WriteLine(lines[i].TrimStart().Substring(substringStart));
    }
    else { /* ... */ }

Видно, что этот код выполняет множество операций со строками. Код использует методы библиотеки для разделения строчек кода на отдельные строки, усечения пробела, проверки того, является ли аргумент text комментарием XML-документа, и извлечения подстрок из строчек кода.

На первой строчке внутри WriteFormattedDocComment вызов text.Split при каждом выполнении выделяет новый массив с тремя элементами в качестве аргумента. Компилятору требуется выводить код для каждого выделения этого массива. Это вызвано тем, что компилятор не знает, хранит ли Split массив в таком месте, где он может быть изменен другим кодом, что повлияло бы на дальнейшие вызовы WriteFormattedDocComment. Вызов Split также выделяет строку для каждой строчки кода в text и выделяет другой объем памяти для выполнения этой операции.

WriteFormattedDocComment содержит три вызова метода TrimStart. Два находятся во внутренних циклах, которые дублируют работу и выделения. Ситуацию усугубляет то, что вызов метода TrimStart без аргументов в дополнение к строковому результату выделяет пустой массив (для параметра params).

Наконец, имеется вызов метода Substring, который обычно выделяет новую строку.

Исправление для примера 3

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

Команда по обеспечению производительности компиляторов решила проблему всех этих выделений с помощью следующего кода:

private int IndexOfFirstNonWhiteSpaceChar(string text, int start) {
    while (start < text.Length && char.IsWhiteSpace(text[start])) start++;
    return start;
}

private bool TrimmedStringStartsWith(string text, int start, string prefix) {
    start = IndexOfFirstNonWhiteSpaceChar(text, start);
    int len = text.Length - start;
    if (len < prefix.Length) return false;
    for (int i = 0; i < len; i++)
    {
        if (prefix[i] != text[start + i]) return false;
    }
    return true;
}

// etc...

Первая версия WriteFormattedDocComment() выделяла массив, несколько подстрок и усеченную подстроку, а также пустой массив params. Он также установлен для параметра "///". Переработанный код использует только индексирование и ничего не выделяет. Он находит первый символ, который не является пробелом, а затем проверяет символ по символам, чтобы увидеть, начинается ли строка с "///". Новый код используется IndexOfFirstNonWhiteSpaceChar вместо TrimStart возврата первого индекса (после указанного начального индекса), где возникает символ, отличный от пробела. Такое исправление нельзя назвать окончательным, но из него можно понять, как применять аналогичные исправления для всего решения. Используя такой подход для всего кода, можно удалить все выделения в WriteFormattedDocComment().

Пример 4: StringBuilder

В этом примере используется объект StringBuilder. Следующая функция создает полное имя типа для универсальных типов:

public class Example
{
    // Constructs a name like "SomeType<T1, T2, T3>"
    public string GenerateFullTypeName(string name, int arity)
    {
        StringBuilder sb = new StringBuilder();

        sb.Append(name);
        if (arity != 0)
        {
            sb.Append("<");
            for (int i = 1; i < arity; i++)
            {
                sb.Append("T"); sb.Append(i.ToString()); sb.Append(", ");
            }
            sb.Append("T"); sb.Append(i.ToString()); sb.Append(">");
        }

        return sb.ToString();
    }
}

Обратите внимание на строку, создающую новый экземпляр StringBuilder. Этот код вызывает выделение для sb.ToString() и внутренние выделения в реализации StringBuilder, однако вы не можете управлять этими выделениями, если хотите получить строковый результат.

Исправление для примера 4

Чтобы исправить выделение объекта StringBuilder, кэшируйте его. Даже кэширование отдельного экземпляра, который может быть отброшен, может существенно повысить производительность. Это новая реализация функции, в которой опущен весь код, за исключением новой первой и последней строк:

// Constructs a name like "MyType<T1, T2, T3>"
public string GenerateFullTypeName(string name, int arity)
{
    StringBuilder sb = AcquireBuilder();
    /* Use sb as before */
    return GetStringAndReleaseBuilder(sb);
}

Основными компонентами являются новые функции AcquireBuilder() и GetStringAndReleaseBuilder():

[ThreadStatic]
private static StringBuilder cachedStringBuilder;

private static StringBuilder AcquireBuilder()
{
    StringBuilder result = cachedStringBuilder;
    if (result == null)
    {
        return new StringBuilder();
    }
    result.Clear();
    cachedStringBuilder = null;
    return result;
}

private static string GetStringAndReleaseBuilder(StringBuilder sb)
{
    string result = sb.ToString();
    cachedStringBuilder = sb;
    return result;
}

Поскольку новые компиляторы использую потоки, эти реализации используют потокостатическое поле (атрибут ThreadStaticAttribute) для кэширования StringBuilder, и вы с большой долей вероятности сможете отказаться от объявления ThreadStatic. Потокостатическое поле содержит уникальное значение для каждого потока, выполняющего данный код.

AcquireBuilder() возвращает кэшированный экземпляр StringBuilder, если он имеется, после его очистки и установки значения NULL для поля или кэша. В противном случае AcquireBuilder() создает новый экземпляр и возвращает его, оставив для поля или кэша значение NULL.

После завершения работы с StringBuilder вы вызываете GetStringAndReleaseBuilder(), чтобы получить строковый результат, сохраняете экземпляр StringBuilder в поле или кэше, а затем возвращаете результат. Процесс выполнения может повторно входить в этот код и создать несколько объектов StringBuilder (хотя это происходит довольно редко). Код сохраняет только последний освобожденный экземпляр StringBuilder для дальнейшего использования. Такая простая стратегия кэширования значительно уменьшает число выделений в новых компиляторах. В частях .NET Framework и MSBuild ("MSBuild") для повышения производительности используется та же методика.

Эта простая стратегия кэширования придерживается разумного подхода к кэшированию из-за наличия ограничения размера. Однако теперь объем кода увеличился, что требует больше затрат на обслуживание. Вам следует внедрять стратегию кэширования только в том случае, если обнаружена проблема с производительностью, а PerfView показал, что одной из основных причин являются выделения StringBuilder.

LINQ и лямбда-выражения

Языковой интегрированный запрос (LINQ) в сочетании с лямбда-выражениями является примером функции повышения производительности. Однако его использование может оказать значительное влияние на производительность с течением времени, и может потребоваться перезаписать код.

Пример 5. Лямбда-коды, список<T> и IEnumerable<T>

В этом примере LINQ и код в функциональном стиле применяются для поиска символа в модели компилятора при заданной строке имени:

class Symbol {
    public string Name { get; private set; }
    /*...*/
}

class Compiler {
    private List<Symbol> symbols;
    public Symbol FindMatchingSymbol(string name)
    {
        return symbols.FirstOrDefault(s => s.Name == name);
    }
}

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

Func<Symbol, bool> predicate = s => s.Name == name;
     return symbols.FirstOrDefault(predicate);

В первой строке лямбда-выражение s => s.Name == name закрывается по локальной переменной.name Это означает, что в дополнение к выделению объекта для делегата, содержащегося в predicate, код выделяет статический класс для среды, которая перехватывает значение name. Компилятор формирует код, аналогичный следующему:

// Compiler-generated class to hold environment state for lambda
private class Lambda1Environment
{
    public string capturedName;
    public bool Evaluate(Symbol s)
    {
        return s.Name == this.capturedName;
    }
}

// Expanded Func<Symbol, bool> predicate = s => s.Name == name;
Lambda1Environment l = new Lambda1Environment() { capturedName = name };
var predicate = new Func<Symbol, bool>(l.Evaluate);

Два выделения new (одно для класса среды и одно для делегата) теперь являются явными.

Теперь рассмотрим вызов FirstOrDefault. Этот метод расширения в типе System.Collections.Generic.IEnumerable<T> также вызывает выделение. Поскольку FirstOrDefault принимает объект IEnumerable<T> в качестве своего первого аргумента, вы можете развернуть вызов в следующий код (он немного упрощен для облегчения восприятия):

// Expanded return symbols.FirstOrDefault(predicate) ...
     IEnumerable<Symbol> enumerable = symbols;
     IEnumerator<Symbol> enumerator = enumerable.GetEnumerator();
     while(enumerator.MoveNext())
     {
         if (predicate(enumerator.Current))
             return enumerator.Current;
     }
     return default(Symbol);

Переменная symbols имеет тип List<T>. Тип коллекции List<T> реализует IEnumerable<T> и продуманно определяет перечислитель (интерфейс IEnumerator<T>), который List<T> реализует с помощью struct. В большинстве случаев использование структуры вместо класса позволяет избежать выделений кучи, что может повлиять на производительность сборки мусора. Перечислители обычно используются в цикле foreach языка, который использует структуру перечислителя, когда она возвращается в стек вызовов. Увеличение указателя стека вызовов для освобождения места для объекта не затрагивает сборку мусора так сильно, как это делает выделение кучи.

В случае развернутого вызова FirstOrDefault коду требуется вызвать GetEnumerator() в IEnumerable<T>. При назначении значения symbols переменной enumerable, имеющей тип IEnumerable<Symbol>, приводит к потере информации о том, что фактический объект является List<T>. Это означает, что при получении перечислителя кодом с помощью enumerable.GetEnumerator(), платформа .NET Framework вынуждена выполнить упаковку-преобразование возвращенной структуры, чтобы назначить ее переменной enumerator.

Исправление для примера 5

Для исправления ситуации нужно переписать FindMatchingSymbol следующим образом, заменив одну строчку кода шестью строчками, которые сохраняют краткость, удобны для восприятия и обслуживания:

public Symbol FindMatchingSymbol(string name)
    {
        foreach (Symbol s in symbols)
        {
            if (s.Name == name)
                return s;
        }
        return null;
    }

Этот код не использует методы расширения LINQ, лямбда-выражения или перечислители, а также не вызывает выделений памяти. Выделения отсутствуют, так как компилятор может определить, что коллекция symbols является List<T>, и может привязать итоговый перечислитель (структуру) к локальной переменной с помощью подходящего типа, чтобы предотвратить упаковку-преобразование. Исходная версия данной функции была отличным примером выразительных возможностей языка C# и производительности платформы .NET Framework. Эта новая и более эффективная версия сохраняет данные качества и не имеет сложного в обслуживании кода.

Кэширование асинхронных методов

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

Пример 6: кэширование в асинхронных методах

Компоненты IDE Visual Studio, созданные на базе новых компиляторов C# и Visual Basic, часто получают деревья синтаксиса, в этом случае компиляторы используют асинхронный режим для обеспечения быстрого реагирования Visual Studio. Вот первый вариант кода, который вы могли бы написать для получения дерева синтаксиса:

class SyntaxTree { /*...*/ }

class Parser { /*...*/
    public SyntaxTree Syntax { get; }
    public Task ParseSourceCode() { /*...*/ }
}

class Compilation { /*...*/
    public async Task<SyntaxTree> GetSyntaxTreeAsync()
    {
        var parser = new Parser(); // allocation
        await parser.ParseSourceCode(); // expensive
        return parser.Syntax;
    }
}

Видно, что вызов GetSyntaxTreeAsync() создает экземпляр Parser, анализирует код и возвращает объект TaskTask<SyntaxTree>. Ресурсоемким является выделение экземпляра Parser и синтаксический анализ кода. Функция возвращает Task, чтобы вызывающие объекты могли дождаться анализа и освободить поток пользовательского интерфейса, обеспечив возможность реагирования на действия пользователя.

Несколько компонентов Visual Studio могут попытаться получить то же самое дерево синтаксиса, поэтому вы можете написать следующий код для кэширования результата анализа, чтобы сэкономить время и сократить число выделений памяти. Однако этот код осуществляет выделение:

class Compilation { /*...*/

    private SyntaxTree cachedResult;

    public async Task<SyntaxTree> GetSyntaxTreeAsync()
    {
        if (this.cachedResult == null)
        {
            var parser = new Parser(); // allocation
            await parser.ParseSourceCode(); // expensive
            this.cachedResult = parser.Syntax;
        }
        return this.cachedResult;
    }
}

Видно, что новый код с кэшированием имеет поле SyntaxTree с именем cachedResult. Когда значение этого поля равно NULL, GetSyntaxTreeAsync() выполняет работу и сохраняет результат в кэш. GetSyntaxTreeAsync() возвращает объект SyntaxTree. Проблема заключается в том, что если при наличии функции async типа Task<SyntaxTree> вы возвращаете значение типа SyntaxTree, компилятор выдает код для выделения задачи под хранение результата (с помощью Task<SyntaxTree>.FromResult()). Эта задача помечается как завершенная, и результат становится доступен сразу же. В коде для новых компиляторов объекты Task, которые уже были завершены, встречались так часто, что исправление таких выделений памяти позволило заметно повысить скорость реагирования.

Исправление для примера 6

Чтобы удалить завершенное Task выделение, можно кэшировать объект Task с полным результатом:

class Compilation { /*...*/

    private Task<SyntaxTree> cachedResult;

    public Task<SyntaxTree> GetSyntaxTreeAsync()
    {
        return this.cachedResult ??
               (this.cachedResult = GetSyntaxTreeUncachedAsync());
    }

    private async Task<SyntaxTree> GetSyntaxTreeUncachedAsync()
    {
        var parser = new Parser(); // allocation
        await parser.ParseSourceCode(); // expensive
        return parser.Syntax;
    }
}

Этот код изменяет тип cachedResult на Task<SyntaxTree> и применяет вспомогательную функцию async, которая содержит исходный код GetSyntaxTreeAsync(). GetSyntaxTreeAsync() теперь использует оператор объединения со значением NULL для возврата cachedResult, если он имеет отличное от NULL значение. Если cachedResult имеет значение NULL, то GetSyntaxTreeAsync() вызывает GetSyntaxTreeUncachedAsync() и кэширует результат. Обратите внимание на то, что GetSyntaxTreeAsync() не дожидается вызова GetSyntaxTreeUncachedAsync(), как это делает обычный код. Это означает, что когда GetSyntaxTreeUncachedAsync() возвращает свой объект Task, GetSyntaxTreeAsync() сразу же возвращает Task. Теперь кэшированный результат является Task, поэтому выделения памяти для возврата кэшированного результата отсутствуют.

Дополнительные рекомендации

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

Словари

Словари повсеместно применяется во многих программах, так как они крайне удобны и эффективны. Однако часто они используются нецелесообразно. Анализ в Visual Studio и новых компиляторах показывает, что многие словари содержали всего один элемент или были пустыми. Пустой Dictionary<TKey,TValue> содержит десять полей и занимает 48 байт в куче на компьютере с архитектурой x86. Словари отлично подходят для случаев, когда требуется сопоставление или ассоциативная структура данных с постоянным по времени поиском. Однако при наличии всего нескольких элементов использование словаря приведет лишь к потере места. Вместо этого можно, например, с той же самой скоростью осуществить итерационный просмотр List<KeyValuePair\<K,V>>. Если вы используете словарь только для загрузки в него данных и последующего их считывания (весьма распространенный случай), применение отсортированного массива с подстановкой N(log(N)) может обеспечивать близкое быстродействие (зависит от числа используемых элементов).

Классы и структуры

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

Кэши

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

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

  • Не проводите преждевременную оптимизацию — сохраните производительность своего труда и производите подстройку приложения при обнаружении проблем.

  • Профили не лгут — если вы не измеряете, значит, вы угадываете.

  • Значение хороших средств трудно переоценить — загрузите PerfView и оцените его в работе.

  • Выделения памяти значат больше всего — именно на них команда разработчиков платформы компиляторов потратила больше всего времени, стремясь повысить производительность новых компиляторов.

См. также