Meilleures pratiques pour améliorer les performances en C #

Bonjour à tous. Nous avons préparé une traduction d'un autre matériel utile à la veille du début du cours "Développeur C #" . Bonne lecture.




Depuis que j'ai récemment fait une liste des meilleures pratiques en C # pour Criteo, j'ai pensé que ce serait bien de la partager publiquement. Le but de cet article est de fournir une liste incomplète de modèles de code à éviter, soit parce qu'ils sont discutables, soit parce qu'ils fonctionnent mal. La liste peut sembler un peu aléatoire, car elle est légèrement hors contexte, mais tous ses éléments ont été trouvés dans notre code à un moment donné et ont causé des problèmes de production. J'espère que cela servira de bonne prévention et évitera vos erreurs à l'avenir.

Notez également que les services Web Criteo reposent sur du code haute performance, d'où la nécessité d'éviter un code inefficace. Dans la plupart des applications, il n'y aura pas de différence tangible notable par rapport au remplacement de certains de ces modèles.

Enfin et surtout, certains points (par exemple ConfigureAwait) ont déjà été abordés dans de nombreux articles, je ne m'étendrai donc pas sur eux en détail. L'objectif est de former une liste compacte de points auxquels vous devez prêter attention, et non de donner un calcul technique détaillé pour chacun d'eux.

Attente synchrone de code asynchrone


Ne vous attendez jamais à des tâches inachevées de manière synchrone. Cela vaut, mais ne se limite pas à: Task.Wait, Task.Result, Task.GetAwaiter().GetResult(), Task.WaitAny, Task.WaitAll.

En général: toute relation synchrone entre deux threads de pool peut entraîner l'épuisement du pool. Les causes de ce phénomène sont décrites dans cet article .

ConfigureAwait


Si votre code peut être appelé à partir d'un contexte de synchronisation, utilisez-le ConfigureAwait(false)pour chacun de vos appels en attente.

Veuillez noter que cela n'est ConfigureAwait utile que lorsque vous utilisez un mot clé await.

Par exemple, le code suivant n'a pas de sens:

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

async void


Ne jamais utiliserasync void . L'exception levée dans la async voidméthode se propage au contexte de synchronisation et provoque généralement le blocage de l'application entière.

Si vous ne pouvez pas renvoyer la tâche dans votre méthode (par exemple, parce que vous implémentez l'interface), déplacez le code asynchrone vers une autre méthode et appelez-la:

interface IInterface
{
    void DoSomething();
}

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

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

Évitez l'async autant que possible


Par habitude ou à cause de la mémoire musculaire, vous pouvez écrire quelque chose comme:

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

Bien que le code soit sémantiquement correct, l'utilisation d'un mot clé asyncn'est pas nécessaire ici et peut entraîner une surcharge importante dans un environnement très chargé. Essayez de l'éviter autant que possible:

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

Cependant, gardez à l'esprit que vous ne pouvez pas recourir à cette optimisation lorsque votre code est encapsulé dans des blocs (par exemple, 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();
    }
}

Dans la mauvaise version ( Incorrect()), le client peut être supprimé avant la fin de l' GetAsyncappel, car la tâche à l'intérieur du bloc using n'est pas attendue par wait.

Comparaisons régionales


Si vous n'avez aucune raison d'utiliser des comparaisons régionales, utilisez toujours des comparaisons ordinales . Bien qu'en raison des optimisations internes, cela n'a pas beaucoup d'importance pour les formulaires de présentation des données en-US, la comparaison est un ordre de grandeur plus lent pour les formulaires de présentation des autres régions (et jusqu'à deux ordres de grandeur sur Linux!). Étant donné que la comparaison de chaînes est une opération fréquente dans la plupart des applications, la surcharge augmente considérablement.

ConcurrentBag <T>


Ne jamais utiliser ConcurrentBag<T>sans analyse comparative . Cette collection a été conçue pour des cas d'utilisation très spécifiques (lorsque la plupart du temps l'élément est exclu de la file d'attente par le thread qui l'a mis en file d'attente) et souffre de graves problèmes de performances s'il est utilisé à d'autres fins. Si vous avez besoin d' une collection thread-safe, préfèrent ConcurrentQueue <T> .

ReaderWriterLock / ReaderWriterLockSlim <T >
Ne jamais utiliser sans analyse comparative. ReaderWriterLock<T>/ReaderWriterLockSlim<T>Bien que l'utilisation de ce type de primitive de synchronisation spécialisée lorsque vous travaillez avec des lecteurs et des écrivains puisse être tentante, son coût est beaucoup plus élevé que le simple Monitor(utilisé avec le mot-clé lock). Si le nombre de lecteurs exécutant la section critique en même temps n'est pas très élevé, la simultanéité ne sera pas suffisante pour absorber la surcharge accrue et le code fonctionnera moins bien.

Préférez les fonctions lambda au lieu des groupes de méthodes


Considérez le code suivant:

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

Resharper suggère de réécrire du code sans fonction lambda, ce qui pourrait sembler un peu plus propre:

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

Malheureusement, cela conduit à l'allocation de mémoire dynamique pour chaque appel. En fait, l'appel se compile comme:

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

Cela peut avoir un impact significatif sur les performances si le code est appelé dans une section fortement chargée.

L'utilisation des fonctions lambda démarre l'optimisation du compilateur, qui met en cache le délégué dans un champ statique, en évitant l'allocation. Cela ne fonctionne que si Filterstatique. Sinon, vous pouvez mettre en cache le délégué vous-même:

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 les énumérations en chaînes


L' appel Enum.ToStringen .netest assez cher, parce que la réflexion est utilisé pour convertir l' intérieur, et appelant la méthode virtuelle sur la structure provoque emballage. Cela devrait être évité autant que possible.

Les énumérations peuvent souvent être remplacées par des chaînes 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 vous devez vraiment utiliser une énumération, pensez à mettre en cache la valeur convertie dans un dictionnaire pour amortir les frais généraux.

Comparaison d'énumération


Remarque: cela n'est plus pertinent dans le noyau .net, depuis la version 2.1, l'optimisation est effectuée automatiquement par JIT.

Lorsque vous utilisez des énumérations comme indicateurs, il peut être tentant d'utiliser la méthode Enum.HasFlag:

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

private Options _option;

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

Ce code provoque deux packages avec allocation: un pour la conversion Options.Option2en Enum, et l'autre pour un appel virtuel HasFlagpour la structure. Cela rend ce code excessivement cher. Au lieu de cela, vous devez sacrifier la lisibilité et utiliser des opérateurs binaires:

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

Mise en place de méthodes de comparaison des structures


Lorsque vous utilisez une structure dans des comparaisons (par exemple, lorsqu'elle est utilisée comme clé pour un dictionnaire), vous devez remplacer les méthodes Equals/GetHashCode. L'implémentation par défaut utilise la réflexion et est très lente. L'implémentation générée par Resharper est généralement assez bonne.

Vous pouvez en savoir plus à ce sujet ici: devblogs.microsoft.com/premier-developer/performance-implications-of-default-struct-equality-in-c

Évitez les emballages inappropriés lorsque vous utilisez des structures avec des interfaces


Considérez le code suivant:

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

Rendre IntValuestructuré peut être tentant d'éviter d'allouer de la mémoire dynamique. Mais puisque l' interface est AddValueet SendValuesont attendues, et que les interfaces ont une sémantique de référence, la valeur sera emballée à chaque appel, annulant les avantages de cette "optimisation". En fait, il y aura encore plus d'allocations de mémoire que s'il IntValues'agissait d'une classe, car la valeur sera mise en package indépendamment pour chaque appel.

Si vous écrivez une API et que certaines valeurs sont des structures, essayez d'utiliser des méthodes génériques:

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

Bien que la conversion de ces méthodes en universelles semble inutile à première vue, elle permet en fait d'éviter le packaging avec allocation dans le cas où il IntValues'agit d'une structure.

Abonnements CancellationToken toujours en ligne


Lorsque vous annulez CancellationTokenSource, tous les abonnements seront exécutés dans le thread actuel. Cela peut entraîner des pauses imprévues ou même des blocages implicites.

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

Vous ne pouvez pas échapper à ce comportement. Par conséquent, lors de l'annulation CancellationTokenSource, demandez-vous si vous pouvez autoriser la capture de votre thread actuel en toute sécurité. Si la réponse est non, encapsulez l'appel à l' Cancelintérieur Task.Runpour l'exécuter dans le pool de threads.

Les continuations de TaskCompletionSource sont souvent en ligne


Comme les abonnements CancellationToken, les continuations TaskCompletionSourcesont souvent en ligne. Il s'agit d'une bonne optimisation, mais elle peut provoquer des erreurs implicites. Par exemple, considérez le programme suivant:

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

L'appel tcs.SetResultprovoque l' await ProcessAsync()exécution de la continuation dans le thread actuel. Par conséquent, l'instruction _mutex.Wait()est exécutée par le même thread qu'elle doit appeler _mutex.Set(), ce qui conduit à un blocage. Cela peut être évité en passant le paramètre TaskCreationsOptions.RunContinuationsAsynchronouslyc TaskCompletionSource.

Si vous n'avez aucune bonne raison de le négliger, utilisez toujours l'option TaskCreationsOptions.RunContinuationsAsynchronouslylors de la création TaskCompletionSource.

Attention: le code sera également compilé si vous l'utilisez à la TaskContinuationOptions.RunContinuationsAsynchronouslyplace TaskCreationOptions.RunContinuationsAsynchronously, mais les paramètres seront ignorés et les suites seront toujours en ligne. Il s'agit d'une erreur étonnamment courante car elle TaskContinuationOptionsprécède la TaskCreationOptionssaisie semi-automatique.

Task.Run / Task.Factory.StartNew


Si vous n'avez aucune raison de l'utiliser Task.Factory.StartNew, choisissez toujours Task.Rund'exécuter une tâche en arrière-plan. Task.Runutilise des valeurs par défaut plus sûres, et plus important encore, il décompresse automatiquement la tâche renvoyée, ce qui peut éviter les erreurs non évidentes avec les méthodes asynchrones. Considérez le programme suivant:

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

Malgré son apparence, la fin du programme sera affichée plus tôt que le traitement terminé. En effet, il Task.Factory.StartNewreviendra Task<Task>et le code n'attend qu'une tâche externe. Le code correct pourrait être soit await Task.Factory.StartNew(ProcessAsync).Unwrap(), soit await Task.Run(ProcessAsync).

Il n'y a que trois cas d'utilisation valides Task.Factory.StartNew:

  • Exécution d'une tâche dans un autre planificateur.
  • Exécution d'une tâche dans un thread dédié (à l'aide de TaskCreationOptions.LongRunning).
  • ( TaskCreationOptions.PreferFairness).



.



All Articles