Mejores prácticas para mejorar el rendimiento en C #

Hola a todos. Hemos preparado una traducción de otro material útil en la víspera del inicio del curso "C # Developer" . Disfruta leyendo.




Como recientemente hice una lista de las mejores prácticas en C # para Criteo, pensé que sería bueno compartirla públicamente. El propósito de este artículo es proporcionar una lista incompleta de plantillas de código que deben evitarse, ya sea porque son cuestionables o porque simplemente funcionan mal. La lista puede parecer un poco aleatoria, porque está ligeramente fuera de contexto, pero todos sus elementos se encontraron en nuestro código en algún momento y causaron problemas de producción. Espero que esto sirva como una buena prevención y evite sus errores en el futuro.

También tenga en cuenta que los servicios web de Criteo se basan en código de alto rendimiento, de ahí la necesidad de evitar un código ineficiente. En la mayoría de las aplicaciones, no habrá una diferencia tangible notable al reemplazar algunas de estas plantillas.

Y por último, pero no menos importante, algunos puntos (por ejemplo ConfigureAwait) ya se han discutido en muchos artículos, por lo que no me detendré en ellos en detalle. El objetivo es formar una lista compacta de puntos a los que debe prestar atención, y no proporcionar un cálculo técnico detallado para cada uno de ellos.

Sincrónicamente esperando código asincrónico


Nunca espere sincrónicamente tareas inacabadas. Esto se aplica a, pero no se limita a: Task.Wait, Task.Result, Task.GetAwaiter().GetResult(), Task.WaitAny, Task.WaitAll.

Como generalización: cualquier relación síncrona entre dos subprocesos de grupo puede causar el agotamiento del grupo. Las causas de este fenómeno se describen en este artículo .

ConfigureAwait


Si se puede invocar su código desde un contexto de sincronización, utilícelo ConfigureAwait(false)para cada una de sus llamadas en espera.

Tenga en cuenta que esto ConfigureAwait soloawait es útil cuando se utiliza una palabra clave .

Por ejemplo, el siguiente código no tiene sentido:

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

vacío asíncrono


Nunca lo useasync void . La excepción lanzada en el async voidmétodo se propaga al contexto de sincronización y generalmente hace que toda la aplicación se bloquee.

Si no puede devolver la tarea en su método (por ejemplo, porque está implementando la interfaz), mueva el código asincrónico a otro método y llámelo:

interface IInterface
{
    void DoSomething();
}

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

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

Evite asíncrono siempre que sea posible


Por costumbre o por memoria muscular, puedes escribir algo como:

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

Aunque el código es semánticamente correcto, asyncno se requiere el uso de una palabra clave aquí y puede generar una sobrecarga significativa en un entorno altamente cargado. Intenta evitarlo siempre que sea posible:

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

Sin embargo, tenga en cuenta que no puede recurrir a esta optimización cuando su código está envuelto en bloques (por ejemplo, try/catcho 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();
    }
}

En la versión incorrecta ( Incorrect()), el cliente puede eliminarse antes de que GetAsyncse complete la llamada, ya que la tarea dentro del bloque de uso no se espera en espera.

Comparaciones regionales


Si no tiene ninguna razón para usar comparaciones regionales, use siempre comparaciones ordinales . Aunque debido a las optimizaciones internas, esto no importa mucho para los formularios de presentación de datos en EE. UU., La comparación es un orden de magnitud más lento para los formularios de presentación de otras regiones (¡y hasta dos órdenes de magnitud en Linux!) Dado que la comparación de cadenas es una operación frecuente en la mayoría de las aplicaciones, la sobrecarga aumenta significativamente.

ConcurrentBag <T>


Nunca lo use ConcurrentBag<T>sin benchmarking . Esta colección fue diseñada para casos de uso muy específicos (cuando la mayoría de las veces el elemento está excluido de la cola por el hilo que lo puso en cola) y sufre serios problemas de rendimiento si se usa para otros fines. Si necesita una colección flujos seguros, prefieren ConcurrentQueue <T> .

ReaderWriterLock / ReaderWriterLockSlim <T >
Nunca lo use sin benchmarking. ReaderWriterLock<T>/ReaderWriterLockSlim<T>Aunque el uso de este tipo de primitiva de sincronización especializada cuando se trabaja con lectores y escritores puede ser tentador, su costo es mucho mayor que el simple Monitor(utilizado con la palabra clave lock). Si el número de lectores que realizan la sección crítica al mismo tiempo no es muy grande, la concurrencia no será suficiente para absorber el aumento de la sobrecarga y el código funcionará peor.

Prefiere funciones lambda en lugar de grupos de métodos


Considere el siguiente código:

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

Resharper sugiere reescribir el código sin una función lambda, que podría verse un poco más limpia:

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

Desafortunadamente, esto lleva a la asignación de memoria dinámica para cada llamada. De hecho, la llamada se compila como:

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

Esto puede tener un impacto significativo en el rendimiento si el código se llama en una sección muy cargada.

El uso de funciones lambda inicia la optimización del compilador, que almacena en caché al delegado en un campo estático, evitando la asignación. Esto solo funciona si es Filterestático. De lo contrario, puede almacenar en caché el delegado usted mismo:

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

Convertir enumeraciones en cadenas


Llamando Enum.ToStringen .netes bastante caro, ya que la reflexión se utiliza para convertir el interior, y una llamada al método virtual en los provoca estructura de empaquetado. Esto debe evitarse tanto como sea posible.

Las enumeraciones a menudo se pueden reemplazar con cadenas 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";
}

Si realmente necesita usar una enumeración, considere almacenar en caché el valor convertido en un diccionario para amortizar los gastos generales.

Comparación de enumeración


Nota: esto ya no es relevante en .net core, desde la versión 2.1, JIT realiza la optimización automáticamente.

Al usar enumeraciones como banderas, puede ser tentador usar el 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 dos paquetes con la asignación: uno para la conversión Options.Option2a Enum, y el otro para una llamada virtual HasFlagpara la estructura. Esto hace que este código sea desproporcionadamente caro. En cambio, debe sacrificar la legibilidad y utilizar operadores binarios:

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

Implementación de métodos de comparación de estructuras.


Al usar una estructura en las comparaciones (por ejemplo, cuando se usa como clave para un diccionario), debe anular los métodos Equals/GetHashCode. La implementación predeterminada usa reflexión y es muy lenta. La implementación generada por Resharper suele ser bastante buena.

Puede obtener más información sobre esto aquí: devblogs.microsoft.com/premier-developer/performance-implications-of-default-struct-equality-in-c

Evite empaques inapropiados cuando use estructuras con interfaces


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

Hacer IntValueestructurado puede ser tentador para evitar asignar memoria dinámica. Pero desde AddValuey SendValuese espera interfaz y las interfaces tienen una semántica de referencia, el valor será embalado con cada llamada, negando los beneficios de esta "optimización". De hecho, habrá incluso más asignaciones de memoria que si IntValuefuera una clase, ya que el valor se empaquetará de forma independiente para cada llamada.

Si está escribiendo una API y espera que algunos valores sean estructuras, intente utilizar 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
{
    // ...
}

Aunque la conversión de estos métodos a universal parece inútil a primera vista, en realidad le permite evitar el empaquetado con asignación en el caso de que IntValuesea ​​una estructura.

Cancelación Suscripciones aceptadas siempre en línea


Cuando cancela CancellationTokenSource, todas las suscripciones se ejecutarán dentro del hilo actual. Esto puede conducir a pausas no planificadas o incluso a puntos muertos implícitos.

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

No puedes escapar de este comportamiento. Por lo tanto, al cancelar CancellationTokenSource, pregúntese si puede permitir que su hilo actual sea capturado de manera segura. Si la respuesta es no, envuelva la llamada Canceldentro Task.Runpara ejecutarla en el grupo de subprocesos.

TaskCompletionSource continuaciones a menudo en línea


Al igual que las suscripciones CancellationToken, las continuaciones TaskCompletionSourceson a menudo en línea. Esta es una buena optimización, pero puede causar errores implícitos. Por ejemplo, considere el siguiente 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");
    }
}

La llamada tcs.SetResulthace que la continuación se await ProcessAsync()ejecute en el hilo actual. Por lo tanto, la declaración _mutex.Wait()se ejecuta mediante el mismo subproceso que debería llamar _mutex.Set(), lo que conduce a un punto muerto. Esto se puede evitar pasando el parámetro TaskCreationsOptions.RunContinuationsAsynchronouslyc TaskCompletionSource.

Si no tiene una buena razón para descuidarlo, use siempre la opción TaskCreationsOptions.RunContinuationsAsynchronouslyal crear TaskCompletionSource.

Tenga cuidado: el código también se compilará si se utiliza TaskContinuationOptions.RunContinuationsAsynchronouslyen su lugar TaskCreationOptions.RunContinuationsAsynchronously, pero se ignora los parámetros y las continuaciones aún estarán en línea. Este es un error sorprendentemente común porque TaskContinuationOptionsprecede al TaskCreationOptionsautocompletado.

Task.Run / Task.Factory.StartNew


Si no tiene ninguna razón para usar Task.Factory.StartNew, elija siempre Task.Runejecutar una tarea en segundo plano. Task.Runutiliza valores predeterminados más seguros y, lo que es más importante, desempaqueta automáticamente la tarea devuelta, lo que puede evitar errores no obvios con métodos asincrónicos. Considere el siguiente 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();
    }
}

A pesar de su apariencia, el Fin del programa se mostrará antes que el Procesamiento realizado. Esto se debe a que Task.Factory.StartNewvolverá Task<Task>y el código solo espera una tarea externa. El código correcto podría ser await Task.Factory.StartNew(ProcessAsync).Unwrap(), o await Task.Run(ProcessAsync).

Solo hay tres casos de uso válidos Task.Factory.StartNew:

  • Ejecutar una tarea en otro planificador.
  • Realizar una tarea en un hilo dedicado (usando TaskCreationOptions.LongRunning).
  • ( TaskCreationOptions.PreferFairness).



.



All Articles