Лучшие практики повышения производительности в C#

в 9:48, , рубрики: best practices, C#, csharp, development, dotnet, performance, software, Блог компании OTUS. Онлайн-образование, Программирование, Софт

Всем привет. Мы подготовили перевод еще одного полезного материала в преддверии старта курса «Разработчик С#». Приятного прочтения.


Поскольку недавно мне довелось составлять список лучших практик в C# для Criteo, я подумал, что было бы неплохо поделиться им публично. Цель этой статьи — предоставить неполный список шаблонов кода, которых следует избегать, либо потому что они сомнительны, либо потому что просто плохо работают. Список может показаться немного рандомным, потому что он слегка выдернут из контекста, но все его элементы в какой-то момент были обнаружены в нашем коде и вызывали проблемы в продакшене. Надеюсь, это послужит хорошей профилактикой и предотвратит ваши ошибки в будущем.

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

И последнее, но не менее важное: некоторые пункты (например, ConfigureAwait) уже обсуждались во многих статьях, поэтому я не буду подробно останавливаться на них. Цель заключается в том, чтобы сформировать компактный список моментов, на которые нужно обращать внимание, а не давать подробную техническую выкладку по каждому из них.

Синхронное ожидание асинхронного кода

Никогда не ожидайте синхронно незавершенные задачи. Это касается, но не ограничивается: Task.Wait, Task.Result, Task.GetAwaiter().GetResult(), Task.WaitAny, Task.WaitAll.

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

ConfigureAwait

Если ваш код может быть вызван из контекста синхронизации, используйте ConfigureAwait(false) для каждого из ваших await вызовов.

Обратите внимание, что ConfigureAwait целесообразен только при использовании ключевого слова await.

Например, следующий код лишен какого либо смысла:

// Использование ConfigureAwait никоим магическим образом не делает этот вызов безопаснее
var result = ProcessAsync().ConfigureAwait(false).GetAwaiter().GetResult();

async void

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

Если вы не можете возвращать задачу в своем методе (например, потому что вы реализуете интерфейс), переместите асинхронный код в другой метод и вызывайте его:

interface IInterface
{
    void DoSomething();
}

class Implementation : IInterface
{
    public void DoSomething()
    {
        // Этот метод не может вернуть задачу,
        // делегируйте асинхронный код в другой метод
        _ = DoSomethingAsync();
    }

    private async Task DoSomethingAsync()
    {
        await Task.Delay(100);
    }
}

По возможности избегайте async

По привычке или из-за мышечной памяти вы можете написать что-то вроде:

public async Task CallAsync()
{
    var client = new Client();
    return await client.GetAsync();
}

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

public Task CallAsync()
{
    var client = new Client();
    return _client.GetAsync();
}

Однако имейте в виду, что вы не можете прибегнуть к этой оптимизации, когда ваш код обернут в блоки (например, try/catch или using):

public async Task Correct()
{
    using (var client = new Client())
    {
        return await client.GetAsync();
    }
}

public Task Incorrect()
{
    using (var client = new Client())
    {
        return client.GetAsync();
    }
}

В неправильной версии (Incorrect()), клиент может быть удален до завершения GetAsync вызова, поскольку задача внутри using блока не ожидается посредством await.

Сравнения с учетом региональных особенностей

Если у вас нет причин использовать сравнения с учетом региональных особенностей, всегда используйте порядковые сравнения. Хотя из-за внутренних оптимизаций это и не имеет большого значения для форм представления данных en-US, сравнение происходит на порядок медленнее для форм представления других регионов (и до двух порядков в Linux!). Поскольку сравнение строк является частой операцией в большинстве приложений, накладные расходы возрастают ощутимо.

ConcurrentBag<T>

Никогда не используйте ConcurrentBag<T> без бенчмаркинга. Эта коллекция была разработана для очень специфических случаев использования (когда большую часть времени элемент исключается из очереди потоком, поставившим его в очередь) и страдает от серьезных проблем с производительностью, если используется не по назначению. Если вам нужна потокобезопасная коллекция, предпочитайте ConcurrentQueue<T>.

ReaderWriterLock / ReaderWriterLockSlim<T>
Никогда не используйте ReaderWriterLock<T>/ReaderWriterLockSlim<T> без бенчмаркинга. Хотя использовать этот вид специализированного примитива синхронизации при работе с читателями и писателями может быть заманчиво, его стоимость намного выше простого Monitor (используемого с ключевым словом lock). Если количество читателей, выполняющих критическую секцию одновременно, не очень велико, параллелизма будет недостаточно для амортизации возросших накладных расходов, и код будет работать хуже.

Предпочитайте лямбда-функции вместо группы методов

Рассмотрим следующий код:

public IEnumerable<int> GetItems()
{
    return _list.Where(i => Filter(i));
}
private static bool Filter(int element)
{
    return i % 2 == 0;
}

Resharper предлагает переписать код без лямбда-функции, что может выглядеть немного чище:

public IEnumerable<int> GetItems()
{
    return _list.Where(Filter);
}
private static bool Filter(int element)
{
    return i % 2 == 0;
}

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

public IEnumerable<int> GetItems()
{
    return _list.Where(new Predicate<int>(Filter));
}
private static bool Filter(int element)
{
    return i % 2 == 0;
}

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

Использование лямбда-функций запускает оптимизацию компилятора, которая кэширует делегат в статическое поле, избегая аллокации. Это работает только если Filter статичный. Если нет, вы можете кэшировать делегат самостоятельно:

private Predicate<int> _filter;

public Constructor()
{
    _filter = new Predicate<int>(Filter);
}

public IEnumerable<int> GetItems()
{
    return _list.Where(_filter);
}

private bool Filter(int element)
{
    return i % 2 == 0;
}

Преобразование перечислений в строки

Вызов Enum.ToString в .net является достаточно дорогостоящим, поскольку для преобразования внутри используется рефлексия, а вызов виртуального метода для структуры провоцирует упаковку. Этого следует избегать, насколько это возможно.

Часто перечисления могут быть заменены константными строками:

// В обоих случаях вы можете использовать Numbers.One, Numbers.Two, ...
public enum Numbers
{
    One,
    Two,
    Three
}

public static class Numbers
{
    public const string One = "One";
    public const string Two = "Two";
    public const string Three = "Three";
}

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

Сравнение перечислений

Примечание: это больше не актуально в .net core, начиная с версии 2.1, оптимизация выполняется JIT автоматически.

При использовании перечислений в качестве флагов может возникнуть соблазн использовать метод Enum.HasFlag:

[Flags]
public enum Options
{
    Option1 = 1,
    Option2 = 2,
    Option3 = 4
}

private Options _option;

public bool IsOption2Enabled()
{
    return _option.HasFlag(Options.Option2);
}

Этот код провоцирует две упаковки с аллокацией: одна для преобразования Options.Option2 в Enum, а другая для виртуального вызова HasFlag для структуры. Это делает этот код непропорционально дорогостоящим. Вместо этого вам следует пожертвовать читаемостью и использовать бинарные операторы:

public bool IsOption2Enabled()
{
    return (_option & Options.Option2) == Options.Option2;
}

Реализация методов сравнения для структур

При использовании структуры в сравнениях (например, при использовании в качестве ключа для словаря) вам необходимо переопределять методы Equals/GetHashCode. Реализация по умолчанию использует рефлексию и очень медленная. Реализация, сгенерированная Resharper, обычно достаточно хороша.

Узнать об этом больше можете здесь: devblogs.microsoft.com/premier-developer/performance-implications-of-default-struct-equality-in-c

Избегайте нецелесообразной упаковки при использовании структур с интерфейсами

Рассмотрим следующий код:

public class IntValue : IValue
{
}

public void DoStuff()
{
    var value = new IntValue();

    LogValue(value);
    SendValue(value);
}

public void SendValue(IValue value)
{
    // ...
}

public void LogValue(IValue value)
{
    // ...
}

Сделать IntValue структурой может быть соблазнительно, чтобы избежать выделения динамической памяти. Но поскольку AddValue и SendValue ожидают интерфейс, а интерфейсы имеют эталонную семантику, значение будет упаковываться при каждом вызове, сводя на нет преимущества этой «оптимизации». На самом деле, выделений памяти будет даже больше, чем если бы IntValue был классом, поскольку значение будет упаковано независимо для каждого вызова.

Если вы пишете API и ожидаете, что некоторые значения будут структурами, попробуйте использовать универсальные методы:

public struct IntValue : IValue
{
}

public void DoStuff()
{
    var value = new IntValue();

    LogValue(value);
    SendValue(value);
}

public void SendValue<T>(T value) where T : IValue
{
    // ...
}

public void LogValue<T>(T value) where T : IValue
{
    // ...
}

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

Подписки CancellationToken всегда инлайнятся

Когда вы отменяете CancellationTokenSource, все подписки будут выполняться внутри текущего потока. Это может привести к незапланированным паузам или даже неявным взаимным блокировкам.

var cts = new CancellationTokenSource();
cts.Token.Register(() => Thread.Sleep(5000));
cts.Cancel(); // Это вызовет блокировку на 5 секунд

Вы не можете избежать этого поведения. Поэтому, при отмене CancellationTokenSource, спросите себя, можете ли вы безопасно позволить своему текущему потоку быть захваченным. Если ответ отрицательный, оберните вызов Cancel внутри Task.Run, чтобы выполнить его в пуле потоков.

Континуации TaskCompletionSource зачастую инлайнятся

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

class Program
{
    private static ManualResetEventSlim _mutex = new ManualResetEventSlim();
    
    public static async Task Deadlock()
    {
        await ProcessAsync();
        _mutex.Wait();
    }
    
    private static Task ProcessAsync()
    {
        var tcs = new TaskCompletionSource<bool>();
        
        Task.Run(() =>
        {
            Thread.Sleep(2000); // Симулируем какую-нибудь работу
            tcs.SetResult(true);
            _mutex.Set();
        });
        
        return tcs.Task;
    }
    
    static void Main(string[] args)
    {
        Deadlock().Wait();
        Console.WriteLine("Will never get there");
    }
}

Вызов tcs.SetResult заставляет продолжение await ProcessAsync() выполниться в текущем потоке. Следовательно, оператор _mutex.Wait() выполняется тем же потоком, который должен вызывать _mutex.Set(), что приводит к взаимной блокировке. Этого можно избежать, передав параметр TaskCreationsOptions.RunContinuationsAsynchronously в TaskCompletionSource.

Если у вас нет веских причин для пренебрежения им, всегда используйте параметр TaskCreationsOptions.RunContinuationsAsynchronously при создании TaskCompletionSource.

Будьте осторожны: код также будет компилироваться, если вы используете TaskContinuationOptions.RunContinuationsAsynchronously вместо TaskCreationOptions.RunContinuationsAsynchronously, но параметры будут игнорироваться, а континуации будут все так же инлайниться. Это удивительно распространенная ошибка, потому что TaskContinuationOptions предшествует TaskCreationOptions в автозаполнении.

Task.Run / Task.Factory.StartNew

Если у вас нет причин использовать Task.Factory.StartNew, всегда выбирайте Task.Run для запуска фоновой задачи. Task.Run использует более безопасные значения по умолчанию, и что более важно, он автоматически распаковывает возвращаемую задачу, что может предотвратить неочевидные ошибки с асинхронными методами. Рассмотрим следующую программу:

class Program
{
    public static async Task ProcessAsync()
    {
        await Task.Delay(2000);
        Console.WriteLine("Processing done");
    }
    
    static async Task Main(string[] args)
    {
        await Task.Factory.StartNew(ProcessAsync);
        Console.WriteLine("End of program");
        Console.ReadLine();
    }
}

Несмотря на внешний вид, «End of program» будет отображено раньше, чем «Processing done». Это потому, что Task.Factory.StartNew будет возвращать Task<Task>, а код ожидает только внешнюю задачу. Корректным кодом могло бы быть либо await Task.Factory.StartNew(ProcessAsync).Unwrap(), либо await Task.Run(ProcessAsync).

Существует только три допустимых варианта использования Task.Factory.StartNew:

  • Запуск задачи в другом планировщике.
  • Выполнение задачи в выделенном потоке (с помощью TaskCreationOptions.LongRunning).
  • Размещение задачи в глобальной очереди пула потоков (с помощь TaskCreationOptions.PreferFairness).


Узнать подробнее о курсе.


Автор: Дмитрий

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js