web-dev-qa-db-fra.com

Pourquoi ConcurrentBag <T> est-il si lent dans .Net (4.0)? Suis-je en train de mal faire?

Avant de commencer un projet, j'ai écrit un test simple pour comparer les performances de ConcurrentBag de (System.Collections.Concurrent) par rapport au verrouillage et aux listes. Je suis extrêmement surpris que ConcurrentBag soit 10 fois plus lent que le verrouillage avec une simple liste. D'après ce que je comprends, le ConcurrentBag fonctionne mieux lorsque le lecteur et l'écrivain sont le même thread. Cependant, je n'avais pas pensé que ses performances seraient bien pires que les serrures traditionnelles.

J'ai exécuté un test avec deux parallèles pour les boucles écrivant et lisant à partir d'une liste/sac. Cependant, l'écriture en elle-même montre une énorme différence:

private static void ConcurrentBagTest()
   {
        int collSize = 10000000;
        Stopwatch stopWatch = new Stopwatch();
        ConcurrentBag<int> bag1 = new ConcurrentBag<int>();

        stopWatch.Start();


        Parallel.For(0, collSize, delegate(int i)
        {
            bag1.Add(i);
        });


        stopWatch.Stop();
        Console.WriteLine("Elapsed Time = {0}", 
                          stopWatch.Elapsed.TotalSeconds);
 }

Sur ma boîte, cela prend entre 3 et 4 secondes pour s'exécuter, contre 0,5 à 0,9 secondes pour ce code:

       private static void LockCollTest()
       {
        int collSize = 10000000;
        object list1_lock=new object();
        List<int> lst1 = new List<int>(collSize);

        Stopwatch stopWatch = new Stopwatch();
        stopWatch.Start();


        Parallel.For(0, collSize, delegate(int i)
            {
                lock(list1_lock)
                {
                    lst1.Add(i);
                }
            });

        stopWatch.Stop();
        Console.WriteLine("Elapsed = {0}", 
                          stopWatch.Elapsed.TotalSeconds);
       }

Comme je l'ai mentionné, faire des lectures et des écritures simultanées n'aide pas le test de sac simultané. Suis-je en train de faire quelque chose de mal ou cette structure de données est-elle vraiment très lente?

[MODIFIER] - J'ai supprimé les tâches parce que je n'en ai pas besoin ici (le code complet a une autre tâche à lire)

[EDIT] Merci beaucoup pour les réponses. J'ai du mal à choisir "la bonne réponse" car il semble que ce soit un mélange de quelques réponses.

Comme l'a souligné Michael Goldshteyn, la vitesse dépend vraiment des données. Darin a souligné qu'il devrait y avoir plus de conflits pour que ConcurrentBag soit plus rapide, et Parallel.For ne démarre pas nécessairement le même nombre de threads. Un point à retenir est de ne rien faire que vous ne fassiez pas devoir à l'intérieur d'une serrure. Dans le cas ci-dessus, je ne me vois pas faire quoi que ce soit à l'intérieur du verrou, sauf peut-être assigner la valeur à une variable temporaire.

De plus, sixlettervariables a souligné que le nombre de threads en cours d'exécution peut également affecter les résultats, bien que j'aie essayé d'exécuter le test d'origine dans l'ordre inverse et que ConcurrentBag soit toujours plus lent.

J'ai effectué quelques tests avec le démarrage de 15 tâches et les résultats dépendaient entre autres de la taille de la collection. Cependant, ConcurrentBag s'est comporté presque aussi bien ou mieux que le verrouillage d'une liste, jusqu'à 1 million d'insertions. Au-dessus de 1 million, le verrouillage semblait parfois beaucoup plus rapide, mais je n'aurai probablement jamais une plus grande infrastructure de données pour mon projet. Voici le code que j'ai exécuté:

        int collSize = 1000000;
        object list1_lock=new object();
        List<int> lst1 = new List<int>();
        ConcurrentBag<int> concBag = new ConcurrentBag<int>();
        int numTasks = 15;

        int i = 0;

        Stopwatch sWatch = new Stopwatch();
        sWatch.Start();
         //First, try locks
        Task.WaitAll(Enumerable.Range(1, numTasks)
           .Select(x => Task.Factory.StartNew(() =>
            {
                for (i = 0; i < collSize / numTasks; i++)
                {
                    lock (list1_lock)
                    {
                        lst1.Add(x);
                    }
                }
            })).ToArray());

        sWatch.Stop();
        Console.WriteLine("lock test. Elapsed = {0}", 
            sWatch.Elapsed.TotalSeconds);

        // now try concurrentBag
        sWatch.Restart();
        Task.WaitAll(Enumerable.Range(1, numTasks).
                Select(x => Task.Factory.StartNew(() =>
            {
                for (i = 0; i < collSize / numTasks; i++)
                {
                    concBag.Add(x);
                }
            })).ToArray());

        sWatch.Stop();
        Console.WriteLine("Conc Bag test. Elapsed = {0}",
               sWatch.Elapsed.TotalSeconds);
42
Tachy

Permettez-moi de vous demander ceci: dans quelle mesure est-il réaliste que vous ayez une application qui ajoute constamment à une collection et ne la lit jamais ? Quelle est l'utilité d'une telle collection? (Ce n'est pas une question purement rhétorique. Je pourrais imaginer qu'il y ait des utilisations où, par exemple, vous ne lisez de la collection qu'à l'arrêt (pour la journalisation) ou à la demande de l'utilisateur. Je pense cependant que ces scénarios sont assez rares.)

C'est ce que votre code simule. Appeler List<T>.Add va être ultra-rapide dans tous les cas, sauf dans les cas occasionnels où la liste doit redimensionner son tableau interne; mais cela est lissé par tous les autres ajouts qui se produisent assez rapidement. Il est donc peu probable que vous voyiez un nombre important de conflits dans ce contexte, en particulier des tests sur un PC personnel avec, par exemple, même 8 cœurs (comme vous l'avez déclaré dans un commenter quelque part). Peut-être vous pourriez voir plus de conflits sur quelque chose comme une machine à 24 cœurs, où de nombreux cœurs peuvent essayer d'ajouter à la liste littéralement en même temps.

La contention est beaucoup plus susceptible de se glisser là où vous lisez de votre collection, en particulier. dans les boucles foreach (ou les requêtes LINQ qui équivalent à des boucles foreach sous le capot) qui nécessitent de verrouiller toute l'opération afin de ne pas modifier votre collection pendant son itération.

Si vous pouvez reproduire ce scénario de manière réaliste, je pense que vous verrez ConcurrentBag<T> l'échelle est bien meilleure que ce que montre votre test actuel.


Mise à jour : ici est un programme que j'ai écrit pour comparer ces collections dans le scénario que j'ai décrit ci-dessus (plusieurs écrivains, plusieurs lecteurs) . En exécutant 25 essais avec une taille de collection de 10000 et 8 threads de lecture, j'ai obtenu les résultats suivants:

 Il a fallu 529,0095 ms pour ajouter 10000 éléments à une liste <double> avec 8 threads de lecture. 
 Il a fallu 39,5237 ms pour ajouter 10000 éléments à un ConcurrentBag <double> avec 8 threads de lecture. 
 Il a fallu 309,4475 ms pour ajouter 10000 éléments à une liste <double> avec 8 threads de lecture. 
 Il a fallu 81,1967 ms pour ajouter 10000 éléments à un ConcurrentBag <double> avec 8 threads de lecture. 
 A pris 228.7669 ms pour ajouter 10000 éléments à une liste <double> avec 8 threads de lecture. 
 Il a fallu 164,8376 ms pour ajouter 10000 éléments à un ConcurrentBag <double> avec 8 threads de lecture. 
 [...] 
Temps moyen de liste: 176,072456 ms.Durée moyenne du sac: 59,603656 ms.

Cela dépend donc clairement de ce que vous faites avec ces collections.

43
Dan Tao

Il semble y avoir un bogue dans le .NET Framework 4 que Microsoft a corrigé dans 4.5, il semble qu'ils ne s'attendaient pas à ce que ConcurrentBag soit beaucoup utilisé.

Voir le post Ayende suivant pour plus d'informations

http://ayende.com/blog/156097/the-high-cost-of-concurrentbag-in-net-4-

15
Paleta

L'examen du programme à l'aide du visualiseur de contention de MS montre que ConcurrentBag<T> a un coût beaucoup plus élevé associé à l'insertion parallèle que le simple verrouillage sur un List<T>. Une chose que j'ai remarquée est qu'il semble y avoir un coût associé à la rotation des 6 threads (utilisés sur ma machine) pour commencer le premier ConcurrentBag<T> run (exécution à froid). 5 ou 6 fils sont ensuite utilisés avec le List<T> code, qui est plus rapide (exécution à chaud). Ajout d'un autre ConcurrentBag<T> exécuter après que la liste montre que cela prend moins de temps que le premier (exécution à chaud).

D'après ce que je vois dans l'affirmation, beaucoup de temps est passé dans le ConcurrentBag<T> implémentation d'allocation de mémoire. Suppression de l'allocation explicite de taille du List<T> le code le ralentit, mais pas suffisamment pour faire la différence.

EDIT: il semble que le ConcurrentBag<T> conserve en interne une liste par Thread.CurrentThread, se verrouille 2 à 4 fois selon qu'il s'exécute sur un nouveau thread et effectue au moins une Interlocked.Exchange. Comme indiqué dans MSDN: "optimisé pour les scénarios où le même thread produira et consommera des données stockées dans le sac". Il s'agit de l'explication la plus probable de votre baisse de performances par rapport à une liste brute.

9
user7116

En guise de réponse générale:

  • Les collections simultanées qui utilisent le verrouillage peuvent être très rapides s'il y a peu ou pas de conflit pour leurs données (c'est-à-dire les verrous). Cela est dû au fait que de telles classes de collection sont souvent construites à l'aide de primitives de verrouillage très peu coûteuses, en particulier lorsqu'elles sont incontrôlées.
  • Les collections sans verrouillage peuvent être plus lentes, en raison des astuces utilisées pour éviter les verrous et en raison d'autres goulots d'étranglement tels que le faux partage, la complexité requise pour implémenter leur nature sans verrouillage conduisant à des échecs de cache, etc.

Pour résumer, la décision de la voie la plus rapide dépend fortement des structures de données utilisées et du nombre de conflits pour les verrous, entre autres problèmes (par exemple, nombre de lecteurs contre écrivains dans un arrangement de type partagé/exclusif).

Votre exemple particulier a un très haut degré de discorde, donc je dois dire que je suis surpris par le comportement. D'un autre côté, la quantité de travail effectuée pendant que le verrou est maintenu est très petite, donc peut-être qu'il y a peu de conflits pour le verrou lui-même, après tout. Il pourrait également y avoir des lacunes dans la mise en œuvre de la gestion des accès concurrents de ConcurrentBag, ce qui fait de votre exemple particulier (avec des insertions fréquentes et aucune lecture) un mauvais cas d'utilisation.

9
Michael Goldshteyn

Cela est déjà résolu dans .NET 4.5. Le problème sous-jacent était que ThreadLocal, que ConcurrentBag utilise, ne s'attendait pas à avoir beaucoup d'instances. Cela a été corrigé et peut maintenant fonctionner assez rapidement.

source - Le coût ÉLEVÉ de ConcurrentBag dans .NET 4.

5
Rohit

Comme l'a dit @ Darin-Dimitrov, je soupçonne que votre Parallel.For ne génère pas en fait le même nombre de threads dans chacun des deux résultats. Essayez de créer manuellement N threads pour vous assurer que vous voyez réellement des conflits de threads dans les deux cas.

3
Chris Shain

Vous avez essentiellement très peu d'écritures simultanées et aucune contention (Parallel.For ne signifie pas nécessairement plusieurs threads). Essayez de paralléliser les écritures et vous observerez différents résultats:

class Program
{
    private static object list1_lock = new object();
    private const int collSize = 1000;

    static void Main()
    {
        ConcurrentBagTest();
        LockCollTest();
    }

    private static void ConcurrentBagTest()
    {
        var bag1 = new ConcurrentBag<int>();
        var stopWatch = Stopwatch.StartNew();
        Task.WaitAll(Enumerable.Range(1, collSize).Select(x => Task.Factory.StartNew(() =>
        {
            Thread.Sleep(5);
            bag1.Add(x);
        })).ToArray());
        stopWatch.Stop();
        Console.WriteLine("Elapsed Time = {0}", stopWatch.Elapsed.TotalSeconds);
    }

    private static void LockCollTest()
    {
        var lst1 = new List<int>(collSize);
        var stopWatch = Stopwatch.StartNew();
        Task.WaitAll(Enumerable.Range(1, collSize).Select(x => Task.Factory.StartNew(() =>
        {
            lock (list1_lock)
            {
                Thread.Sleep(5);
                lst1.Add(x);
            }
        })).ToArray());
        stopWatch.Stop();
        Console.WriteLine("Elapsed = {0}", stopWatch.Elapsed.TotalSeconds);
    }
}
1
Darin Dimitrov

Je suppose que les serrures ne connaissent pas beaucoup de conflits. Je recommanderais de lire l'article suivant: Théorie et pratique Java: Anatomie d'un microbenchmark défectueux . L'article discute d'un microbenchmark de verrouillage. Comme indiqué dans l'article, il y a beaucoup de choses à prendre en considération dans ce genre de situations.

1
Edin Dazdarevic

Le corps de la boucle étant petit, vous pouvez essayer d'utiliser la méthode Create de la classe Partitioner ...

qui vous permet de fournir une boucle séquentielle pour le corps du délégué, de sorte que le délégué ne soit invoqué qu'une seule fois par partition, au lieu d'une fois par itération

Comment: accélérer les petits corps de boucle

0
Craig

Il serait intéressant de voir une mise à l'échelle entre les deux.

Deux questions

1) Quelle est la vitesse du sac par rapport à la liste pour la lecture, n'oubliez pas de verrouiller la liste

2) À quelle vitesse le sac vs la liste sont-ils lus pendant qu'un autre fil écrit

0
Bengie

Il semble que ConcurrentBag est juste plus lent que les autres collections simultanées.

Je pense que c'est un problème d'implémentation - ANTS Profiler montre qu'il est enlisé à quelques endroits - y compris une copie de tableau.

L'utilisation d'un dictionnaire simultané est des milliers de fois plus rapide.

0
Jason Hernandez