Práticas recomendadas para melhorar o desempenho em C #

Olá a todos. Preparamos uma tradução de outro material útil na véspera do início do curso "Desenvolvedor C #" . Gostar de ler.




Como fiz recentemente uma lista de práticas recomendadas em C # para o Criteo, achei que seria bom compartilhá-lo publicamente. O objetivo deste artigo é fornecer uma lista incompleta de modelos de código que devem ser evitados, porque são questionáveis ​​ou porque funcionam mal. A lista pode parecer um pouco aleatória porque está levemente fora de contexto, mas todos os seus elementos foram encontrados em algum momento do nosso código e causaram problemas na produção. Espero que isso sirva como uma boa prevenção e evite seus erros no futuro.

Observe também que os serviços web Criteo dependem de código de alto desempenho, daí a necessidade de evitar códigos ineficientes. Na maioria dos aplicativos, não haverá diferença tangível perceptível na substituição de alguns desses modelos.

E por último, mas não menos importante, alguns pontos (por exemplo ConfigureAwait) já foram discutidos em muitos artigos, por isso não vou me aprofundar neles em detalhes. O objetivo é formar uma lista compacta de pontos aos quais você precisa prestar atenção, e não fornecer um cálculo técnico detalhado para cada um deles.

Esperando de forma síncrona por código assíncrono


Nunca espere tarefas inacabadas de forma síncrona. Isto aplica-se, mas não se limita a: Task.Wait, Task.Result, Task.GetAwaiter().GetResult(), Task.WaitAny, Task.WaitAll.

Como uma generalização: qualquer relacionamento síncrono entre dois encadeamentos de conjunto pode causar esgotamento do conjunto. As causas desse fenômeno são descritas neste artigo .

ConfigureAwait


Se seu código puder ser chamado de um contexto de sincronização, use ConfigureAwait(false)para cada uma das chamadas em espera.

Observe que isso ConfigureAwait await é útil ao usar uma palavra-chave .

Por exemplo, o código a seguir não faz sentido:

//  ConfigureAwait        
var result = ProcessAsync().ConfigureAwait(false).GetAwaiter().GetResult();

vazio assíncrono


Nunca useasync void . A exceção lançada no async voidmétodo se propaga para o contexto de sincronização e geralmente causa o travamento de todo o aplicativo.

Se você não puder retornar a tarefa no seu método (por exemplo, porque está implementando a interface), mova o código assíncrono para outro método e chame-o:

interface IInterface
{
    void DoSomething();
}

class Implementation : IInterface
{
    public void DoSomething()
    {
        //      ,
        //      
        _ = DoSomethingAsync();
    }

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

Evite async sempre que possível


Por hábito ou por causa da memória muscular, você pode escrever algo como:

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

Embora o código esteja semanticamente correto, o uso de uma palavra async- chave não é necessário aqui e pode resultar em sobrecarga significativa em um ambiente altamente carregado. Tente evitá-lo sempre que possível:

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

No entanto, lembre-se de que você não pode recorrer a essa otimização quando seu código estiver agrupado em blocos (por exemplo, try/catchou 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();
    }
}

Na versão errada ( Incorrect()), o cliente pode ser excluído antes que a GetAsyncchamada seja concluída , pois a tarefa dentro do bloco de uso não é esperada por aguardar.

Comparações regionais


Se você não tiver motivos para usar comparações regionais, sempre use comparações ordinais . Embora, devido às otimizações internas, isso realmente não importe para as formas de apresentação de dados nos EUA, a comparação é uma ordem de magnitude mais lenta para as formas de apresentação de outras regiões (e até duas ordens de magnitude no Linux!). Como a comparação de cadeias é uma operação frequente na maioria dos aplicativos, a sobrecarga aumenta significativamente.

ConcurrentBag <T>


Nunca use ConcurrentBag<T>sem benchmarking . Essa coleção foi projetada para casos de uso muito específicos (quando na maioria das vezes o item é excluído da fila pelo encadeamento que o enfileirou) e sofre de sérios problemas de desempenho se for usado para outros fins. Se você precisa de uma coleção thread-safe, preferem ConcurrentQueue <T> .

ReaderWriterLock / ReaderWriterLockSlim <T >
Nunca use sem benchmarking. ReaderWriterLock<T>/ReaderWriterLockSlim<T>Embora o uso desse tipo de primitiva de sincronização especializada ao trabalhar com leitores e gravadores possa ser tentador, seu custo é muito maior que o simples Monitor(usado com uma palavra-chave lock). Se o número de leitores que executam a seção crítica ao mesmo tempo não for muito grande, a simultaneidade não será suficiente para absorver o aumento da sobrecarga e o código funcionará pior.

Preferir funções lambda em vez de grupos de métodos


Considere o seguinte código:

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

O Re-compartilhador sugere reescrever o código sem uma função lambda, que pode parecer um pouco mais limpa:

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

Infelizmente, isso leva à alocação de memória dinâmica para cada chamada. De fato, a chamada é compilada como:

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

Isso pode ter um impacto significativo no desempenho se o código for chamado em uma seção muito carregada.

O uso das funções lambda inicia a otimização do compilador, que armazena em cache o delegado em um campo estático, evitando a alocação. Isso funciona apenas se Filterestático. Caso contrário, você pode armazenar em cache o delegado:

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;
}

Converter enumerações em seqüências de caracteres


Chamando Enum.ToStringem .neté muito caro, porque a reflexão é usado para converter dentro, e chamar o método virtual na provoca estrutura da embalagem. Isso deve ser evitado o máximo possível.

As enumerações geralmente podem ser substituídas por seqüências constantes:

//       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";
}

Se você realmente precisar usar uma enumeração, considere armazenar em cache o valor convertido em um dicionário para amortizar a sobrecarga.

Comparação de enumeração


Nota: isso não é mais relevante no núcleo .net, desde a versão 2.1, a otimização é executada pelo JIT automaticamente.

Ao usar enumerações como sinalizadores, pode ser tentador usar o método Enum.HasFlag:

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

private Options _option;

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

Este código provoca dois pacotes com alocação: um para a conversão Options.Option2para Enume outra para uma chamada virtual HasFlagpara a estrutura. Isso torna esse código desproporcionalmente caro. Em vez disso, você deve sacrificar a legibilidade e usar operadores binários:

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

Implementação de métodos de comparação para estruturas


Ao usar uma estrutura em comparações (por exemplo, quando usada como uma chave para um dicionário), você precisa substituir os métodos Equals/GetHashCode. A implementação padrão usa reflexão e é muito lenta. A implementação gerada pelo Resharper geralmente é muito boa.

Você pode saber mais sobre isso aqui: devblogs.microsoft.com/premier-developer/performance-implications-of-default-struct-equality-in-c

Evite embalagens inadequadas ao usar estruturas com interfaces


Considere o seguinte código:

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)
{
    // ...
}

Tornar IntValueestruturado pode ser tentador para evitar a alocação de memória dinâmica. Mas desde AddValuee SendValuesão esperados interface e interfaces têm semântica de referência, o valor será embalado com cada chamada, negando os benefícios deste "otimização". De fato, haverá ainda mais alocações de memória do que se IntValuefosse uma classe, pois o valor será empacotado independentemente para cada chamada.

Se você está escrevendo uma API e espera que alguns valores sejam estruturas, tente usar métodos genéricos:

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
{
    // ...
}

Embora a conversão desses métodos para universal pareça inútil à primeira vista, na verdade ela permite evitar o empacotamento com alocação no caso em que IntValueé uma estrutura.

CancellationToken Subscriptions Always Inline


Quando você cancela CancellationTokenSource, todas as assinaturas serão executadas dentro do segmento atual. Isso pode levar a pausas não planejadas ou até a conflitos implícitos.

var cts = new CancellationTokenSource();
cts.Token.Register(() => Thread.Sleep(5000));
cts.Cancel(); //     5 

Você não pode escapar desse comportamento. Portanto, ao cancelar CancellationTokenSource, pergunte a si mesmo se você pode permitir que seu segmento atual seja capturado com segurança. Se a resposta for não, passe a chamada para Canceldentro Task.Runpara executá-la no conjunto de encadeamentos.

Continuações de origem frequentemente alinhadas


Assim como as assinaturas CancellationToken, as continuações TaskCompletionSourcegeralmente são inline. Essa é uma boa otimização, mas pode causar erros implícitos. Por exemplo, considere o seguinte programa:

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");
    }
}

A chamada tcs.SetResultfaz com que a continuação await ProcessAsync()seja executada no encadeamento atual. Portanto, a instrução _mutex.Wait()é executada pelo mesmo encadeamento que deve chamar _mutex.Set(), o que leva a um impasse. Isso pode ser evitado passando o parâmetro TaskCreationsOptions.RunContinuationsAsynchronouslyc TaskCompletionSource.

Se você não tiver um bom motivo para negligenciá-lo, sempre use a opção TaskCreationsOptions.RunContinuationsAsynchronouslyao criar TaskCompletionSource.

Tenha cuidado: o código também irá compilar se você usa TaskContinuationOptions.RunContinuationsAsynchronouslyvez TaskCreationOptions.RunContinuationsAsynchronously, mas os parâmetros serão ignorados, e as continuações ainda será embutido. Esse é um erro surpreendentemente comum porque TaskContinuationOptionsprecede o TaskCreationOptionspreenchimento automático.

Task.Run / Task.Factory.StartNew


Se você não tiver motivos para usar Task.Factory.StartNew, sempre opte Task.Runpor executar uma tarefa em segundo plano. Task.Runusa valores padrão mais seguros e, mais importante, descompacta automaticamente a tarefa retornada, o que pode evitar erros não óbvios com métodos assíncronos. Considere o seguinte programa:

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();
    }
}

Apesar de sua aparência, o fim do programa será exibido antes do processamento concluído. Isso ocorre porque ele Task.Factory.StartNewretornará Task<Task>e o código espera apenas uma tarefa externa. O código correto pode ser await Task.Factory.StartNew(ProcessAsync).Unwrap(), ou await Task.Run(ProcessAsync).

Existem apenas três casos de uso válidos Task.Factory.StartNew:

  • Executando uma tarefa em outro planejador.
  • Executando uma tarefa em um encadeamento dedicado (usando TaskCreationOptions.LongRunning).
  • ( TaskCreationOptions.PreferFairness).



.



All Articles