it-swarm.com.de

Warum ist Parallel.ForEach viel schneller als AsParallel (). ForAll (), obwohl MSDN etwas anderes vorschlägt?

Ich habe einige Nachforschungen angestellt, um zu sehen, wie wir eine Multithread-Anwendung erstellen können, die durch einen Baum läuft.

Um herauszufinden, wie dies am besten implementiert werden kann, habe ich eine Testanwendung erstellt, die über mein Laufwerk C:\ausgeführt wird und alle Verzeichnisse öffnet.

class Program
{
    static void Main(string[] args)
    {
        //var startDirectory = @"C:\The folder\RecursiveFolder";
        var startDirectory = @"C:\";

        var w = Stopwatch.StartNew();

        ThisIsARecursiveFunction(startDirectory);

        Console.WriteLine("Elapsed seconds: " + w.Elapsed.TotalSeconds);

        Console.ReadKey();
    }

    public static void ThisIsARecursiveFunction(String currentDirectory)
    {
        var lastBit = Path.GetFileName(currentDirectory);
        var depth = currentDirectory.Count(t => t == '\\');
        //Console.WriteLine(depth + ": " + currentDirectory);

        try
        {
            var children = Directory.GetDirectories(currentDirectory);

            //Edit this mode to switch what way of parallelization it should use
            int mode = 3;

            switch (mode)
            {
                case 1:
                    foreach (var child in children)
                    {
                        ThisIsARecursiveFunction(child);
                    }
                    break;
                case 2:
                    children.AsParallel().ForAll(t =>
                    {
                        ThisIsARecursiveFunction(t);
                    });
                    break;
                case 3:
                    Parallel.ForEach(children, t =>
                    {
                        ThisIsARecursiveFunction(t);
                    });
                    break;
                default:
                    break;
            }

        }
        catch (Exception eee)
        {
            //Exception might occur for directories that can't be accessed.
        }
    }
}

Was mir jedoch aufgefallen ist, ist, dass der Code bei Ausführung in Modus 3 (Parallel.ForEach) in etwa 2,5 Sekunden abgeschlossen ist (ja, ich habe eine SSD;)). Das Ausführen des Codes ohne Parallelisierung ist in etwa 8 Sekunden abgeschlossen. Wenn Sie den Code in Modus 2 (AsParalle.ForAll ()) ausführen, dauert dies nahezu unendlich lange.

Beim Einchecken im Prozess-Explorer stoßen Sie auch auf einige merkwürdige Fakten:

Mode1 (No Parallelization):
Cpu:     ~25%
Threads: 3
Time to complete: ~8 seconds

Mode2 (AsParallel().ForAll()):
Cpu:     ~0%
Threads: Increasing by one per second (I find this strange since it seems to be waiting on the other threads to complete or a second timeout.)
Time to complete: 1 second per node so about 3 days???

Mode3 (Parallel.ForEach()):
Cpu:     100%
Threads: At most 29-30
Time to complete: ~2.5 seconds

Was ich besonders merkwürdig finde, ist, dass Parallel.ForEach alle übergeordneten Threads/Aufgaben, die noch ausgeführt werden, während AsParallel () ignoriert. ForAll () scheint darauf zu warten, dass entweder die vorherige Aufgabe abgeschlossen wird (was nicht bald alle übergeordneten Aufgaben ist) warten immer noch auf ihre untergeordneten Aufgaben.

Was ich auch auf MSDN gelesen habe, war: "ForAll lieber als ForEach, wenn es möglich ist"

Quelle: http://msdn.Microsoft.com/de-de/library/dd997403(v=vs.110).aspx

Hat jemand eine Ahnung, warum das sein könnte?

Edit 1:

Wie von Matthew Watson gefordert, habe ich zuerst den Baum in den Speicher geladen, bevor ich ihn durchlaufen habe. Das Laden des Baums erfolgt nun nacheinander.

Die Ergebnisse sind jedoch die gleichen. Unparallelized und Parallel.ForEach vervollständigen nun den gesamten Baum in etwa 0,05 Sekunden, während AsParallel () ForAll noch immer nur um 1 Schritt pro Sekunde geht.

Code:

class Program
{
    private static DirWithSubDirs RootDir;

    static void Main(string[] args)
    {
        //var startDirectory = @"C:\The folder\RecursiveFolder";
        var startDirectory = @"C:\";

        Console.WriteLine("Loading file system into memory...");
        RootDir = new DirWithSubDirs(startDirectory);
        Console.WriteLine("Done");


        var w = Stopwatch.StartNew();

        ThisIsARecursiveFunctionInMemory(RootDir);

        Console.WriteLine("Elapsed seconds: " + w.Elapsed.TotalSeconds);

        Console.ReadKey();
    }        

    public static void ThisIsARecursiveFunctionInMemory(DirWithSubDirs currentDirectory)
    {
        var depth = currentDirectory.Path.Count(t => t == '\\');
        Console.WriteLine(depth + ": " + currentDirectory.Path);

        var children = currentDirectory.SubDirs;

        //Edit this mode to switch what way of parallelization it should use
        int mode = 2;

        switch (mode)
        {
            case 1:
                foreach (var child in children)
                {
                    ThisIsARecursiveFunctionInMemory(child);
                }
                break;
            case 2:
                children.AsParallel().ForAll(t =>
                {
                    ThisIsARecursiveFunctionInMemory(t);
                });
                break;
            case 3:
                Parallel.ForEach(children, t =>
                {
                    ThisIsARecursiveFunctionInMemory(t);
                });
                break;
            default:
                break;
        }
    }
}

class DirWithSubDirs
{
    public List<DirWithSubDirs> SubDirs = new List<DirWithSubDirs>();
    public String Path { get; private set; }

    public DirWithSubDirs(String path)
    {
        this.Path = path;
        try
        {
            SubDirs = Directory.GetDirectories(path).Select(t => new DirWithSubDirs(t)).ToList();
        }
        catch (Exception eee)
        {
            //Ignore directories that can't be accessed
        }
    }
}

Edit 2:

Nachdem ich das Update zu Matthews Kommentar gelesen hatte, habe ich versucht, dem Programm den folgenden Code hinzuzufügen:

ThreadPool.SetMinThreads(4000, 16);
ThreadPool.SetMaxThreads(4000, 16);

Dies ändert jedoch nicht die Art und Weise, wie die AsParallel-Funktionen ausgeführt werden. Die ersten 8 Schritte werden sofort ausgeführt, bevor sie auf 1 Schritt/Sekunde verlangsamt werden.

(Zusätzlicher Hinweis: Ich ignoriere derzeit die Ausnahmen, die auftreten, wenn ich über den Try Catch-Block im Verzeichnis Directory.GetDirectories () nicht auf ein Verzeichnis zugreifen kann.

Bearbeiten Sie 3:

Was mich hauptsächlich interessiert, ist der Unterschied zwischen Parallel.ForEach und AsParallel.ForAll, weil es für mich einfach seltsam ist, dass der zweite aus irgendeinem Grund einen Thread für jede Rekursion erstellt, während der erste alles in rund 30 Threads erledigt max. (Und auch, warum MSDN vorschlägt, AsParallel zu verwenden, obwohl es so viele Threads mit einem Timeout von ~ 1 Sekunde erstellt.)

Edit 4:

Eine andere merkwürdige Sache, die ich herausgefunden habe: Wenn ich versuche, die MinThreads im Thread-Pool oberhalb von 1023 zu setzen, wird der Wert anscheinend ignoriert und auf etwa 8 oder 16 zurückskaliert.

Wenn ich jedoch 1023 verwende, werden die ersten 1023 Elemente sehr schnell ausgeführt, gefolgt von einem langsamen Tempo, das ich schon immer erlebt habe.

Hinweis: Außerdem werden jetzt buchstäblich mehr als 1000 Threads erstellt (im Vergleich zu 30 für den gesamten Parallel.ForEach-Thread).

Bedeutet das, dass Parallel.ForEach bei der Bearbeitung von Aufgaben einfach klüger ist?

Weitere Informationen, dieser Code wird zweimal 8 - 8 gedruckt, wenn Sie den Wert über 1023 einstellen: (Wenn Sie die Werte auf 1023 oder niedriger einstellen, wird der korrekte Wert gedruckt.)

        int threadsMin;
        int completionMin;
        ThreadPool.GetMinThreads(out threadsMin, out completionMin);
        Console.WriteLine("Cur min threads: " + threadsMin + " and the other thing: " + completionMin);

        ThreadPool.SetMinThreads(1023, 16);
        ThreadPool.SetMaxThreads(1023, 16);

        ThreadPool.GetMinThreads(out threadsMin, out completionMin);
        Console.WriteLine("Now min threads: " + threadsMin + " and the other thing: " + completionMin);

Edit 5:

Auf Wunsch von Dean habe ich einen weiteren Fall erstellt, um Aufgaben manuell zu erstellen:

case 4:
    var taskList = new List<Task>();
    foreach (var todo in children)
    {
        var itemTodo = todo;
        taskList.Add(Task.Run(() => ThisIsARecursiveFunctionInMemory(itemTodo)));
    }
    Task.WaitAll(taskList.ToArray());
    break;

Dies ist auch so schnell wie die Parallel.ForEach () - Schleife. Daher haben wir immer noch keine Antwort auf die Frage, warum AsParallel (). ForAll () so viel langsamer ist.

30
Devedse

Dieses Problem ist ziemlich debugierbar, ein ungewöhnlicher Luxus, wenn Sie Probleme mit Threads haben. Ihr grundlegendes Werkzeug hier ist das Debugger-Fenster Debug> Windows> Threads. Zeigt Ihnen die aktiven Threads und gibt Ihnen einen Einblick in deren Stack-Trace. Sie werden leicht feststellen, dass, sobald es langsam wird, Dutzende aktive Threads vorhanden sind, die alle festgefahren sind. Ihre Stack-Trace sehen alle gleich aus:

    mscorlib.dll!System.Threading.Monitor.Wait(object obj, int millisecondsTimeout, bool exitContext) + 0x16 bytes  
    mscorlib.dll!System.Threading.Monitor.Wait(object obj, int millisecondsTimeout) + 0x7 bytes 
    mscorlib.dll!System.Threading.ManualResetEventSlim.Wait(int millisecondsTimeout, System.Threading.CancellationToken cancellationToken) + 0x182 bytes    
    mscorlib.dll!System.Threading.Tasks.Task.SpinThenBlockingWait(int millisecondsTimeout, System.Threading.CancellationToken cancellationToken) + 0x93 bytes   
    mscorlib.dll!System.Threading.Tasks.Task.InternalRunSynchronously(System.Threading.Tasks.TaskScheduler scheduler, bool waitForCompletion) + 0xba bytes  
    mscorlib.dll!System.Threading.Tasks.Task.RunSynchronously(System.Threading.Tasks.TaskScheduler scheduler) + 0x13 bytes  
    System.Core.dll!System.Linq.Parallel.SpoolingTask.SpoolForAll<ConsoleApplication1.DirWithSubDirs,int>(System.Linq.Parallel.QueryTaskGroupState groupState, System.Linq.Parallel.PartitionedStream<ConsoleApplication1.DirWithSubDirs,int> partitions, System.Threading.Tasks.TaskScheduler taskScheduler) Line 172  C#
// etc..

Wann immer Sie so etwas sehen, sollten Sie sofort an Feuerlöschschlauchproblem denken. Wahrscheinlich der dritthäufigste Fehler bei Threads, nach Rennen und Deadlocks.

Da Sie nun die Ursache kennen, ist das Problem des Codes, dass jeder Thread, der abgeschlossen wird, N weitere Threads hinzufügt. Dabei ist N die durchschnittliche Anzahl von Unterverzeichnissen in einem Verzeichnis. In der Tat wächst die Anzahl der Threads exponentiell, das ist immer schlecht. Es bleibt nur dann in der Kontrolle, wenn N = 1 ist, was auf einer typischen Platte natürlich niemals vorkommt.

Beachten Sie, dass dieses Fehlverhalten, wie bei fast jedem Threading-Problem, dazu neigt, sich schlecht zu wiederholen. Die SSD in Ihrem Computer neigt dazu, sie zu verstecken. Wenn Sie also RAM in Ihrem Computer verwenden, wird das Programm beim zweiten Ausführen möglicherweise schnell und problemlos abgeschlossen. Da Sie jetzt aus dem Dateisystemcache anstelle der Festplatte lesen, sehr schnell. Wenn Sie mit ThreadPool.SetMinThreads () basteln, wird dies ebenfalls ausgeblendet, kann jedoch nicht behoben werden. Es behebt nie ein Problem, es versteckt sie nur. Denn egal was passiert, die exponentielle Zahl wird immer die festgelegte Mindestanzahl von Threads überfordern. Sie können nur hoffen, dass das Iterieren des Laufwerks abgeschlossen ist, bevor dies geschieht. Idle Hoffnung für einen Benutzer mit großem Antrieb.

Der Unterschied zwischen ParallelEnumerable.ForAll () und Parallel.ForEach () ist jetzt vielleicht auch leicht zu erklären. Sie können der Stack-Ablaufverfolgung entnehmen, dass ForAll () etwas Unartiges tut, die Methode RunSynchronously () blockiert, bis alle Threads abgeschlossen sind. Blockieren ist etwas, was Threadpool-Threads nicht tun sollten. Es fummelt den Thread-Pool zusammen und erlaubt nicht, den Prozessor für einen anderen Job einzuplanen. Und der Effekt, den Sie beobachtet haben, ist der Thread-Pool schnell mit Threads überfordert, die auf die N anderen Threads warten, um sie abzuschließen. Was nicht passiert, sie warten im Pool und werden nicht geplant, da bereits so viele von ihnen aktiv sind.

Dies ist ein sehr häufiges Deadlock-Szenario, für den Threadpool-Manager gibt es jedoch eine Problemumgehung. Er überwacht die aktiven Threadpool-Threads und -schritte, wenn sie nicht rechtzeitig abgeschlossen werden. Dann kann ein extra -Thread gestartet werden, einer mehr als das von SetMinThreads () festgelegte Minimum. Aber nicht mehr als das von SetMaxThreads () festgelegte Maximum, zu viele aktive tp-Threads sind riskant und lösen wahrscheinlich OOM aus. Dadurch wird der Deadlock gelöst, der Aufruf von ForAll () wird abgeschlossen. Dies geschieht jedoch sehr langsam, der Threadpool tut dies nur zweimal pro Sekunde. Sie haben keine Geduld mehr, bevor sie aufholt.

Parallel.ForEach () hat dieses Problem nicht, es blockiert nicht, sodass der Pool nicht aufgebraucht wird. 

Scheint die Lösung zu sein, bedenken Sie jedoch, dass Ihr Programm immer noch den Arbeitsspeicher Ihres Computers in Brand setzt und immer mehr wartende Threads zum Pool hinzufügt. Dies kann Ihr Programm ebenfalls zum Absturz bringen. Es ist jedoch nicht so wahrscheinlich, dass Sie über viel Speicher verfügen und der Threadpool nicht viel davon verwendet, um eine Anfrage zu verfolgen. Einige Programmierer jedoch schaffen dies auch .

Die Lösung ist sehr einfach, verwenden Sie kein Threading. Es ist schaden, es gibt keine Parallelität, wenn Sie nur eine Festplatte haben. Und es ist nicht als ob es von mehreren Threads befohlen würde. Besonders schlecht bei einem Spindelantrieb sind die Kopfbewegungen sehr, sehr langsam. SSDs machen es viel besser, es dauert jedoch immer noch 50 Mikrosekunden, ein Overhead, den Sie einfach nicht wollen oder brauchen. Die ideale Anzahl von Threads für den Zugriff auf eine Platte, von der Sie andernfalls nicht erwarten können, dass sie gut zwischengespeichert wird, ist immer one.

45
Hans Passant

Als Erstes ist zu beachten, dass Sie versuchen, eine IO-gebundene Operation zu parallelisieren, wodurch die Timings erheblich verzerrt werden.

Das zweite, was zu beachten ist, ist die Art der parallelisierten Aufgaben: Sie rekursiv einen Verzeichnisbaum ab. Wenn Sie zu diesem Zweck mehrere Threads erstellen, greift wahrscheinlich jeder Thread gleichzeitig auf einen anderen Teil des Datenträgers zu. Dies führt dazu, dass der Lesekopf des Datenträgers überall hüpft und die Geschwindigkeit erheblich verlangsamt.

Versuchen Sie, Ihren Test zu ändern, um eine In-Memory-Struktur zu erstellen, und greifen Sie stattdessen auf mehrere Threads zu. Dann können Sie die Timings richtig vergleichen, ohne dass die Ergebnisse über alle Nützlichkeit hinaus verzerrt werden.

Darüber hinaus erstellen Sie möglicherweise eine große Anzahl von Threads, und diese sind (standardmäßig) Threadpool-Threads. Eine große Anzahl von Threads führt zu einer Verlangsamung, wenn die Anzahl der Prozessorkerne überschritten wird.

Beachten Sie außerdem, dass der Thread-Pool-Manager zwischen den einzelnen Threads des Threadpools eine Verzögerung einstellt, wenn Sie die Mindest-Threads des Thread-Pools (definiert durch ThreadPool.GetMinThreads() ) überschreiten. (Ich denke, das sind ungefähr 0,5s pro neuen Thread).

Wenn die Anzahl der Threads den von ThreadPool.GetMaxThreads() zurückgegebenen Wert überschreitet, wird der erzeugende Thread blockiert, bis einer der anderen Threads beendet wurde. Ich denke, dass dies wahrscheinlich passieren wird.

Sie können diese Hypothese testen, indem Sie ThreadPool.SetMaxThreads() und ThreadPool.SetMinThreads() aufrufen, um diese Werte zu erhöhen und zu sehen, ob es einen Unterschied macht.

(Beachten Sie schließlich, dass Sie, wenn Sie wirklich versuchen, rekursiv von C:\ abzusteigen, eine Ausnahme von IO erhalten, wenn ein geschützter Betriebssystemordner erreicht wird.)

HINWEIS: Legen Sie die Max/Min-Threadpool-Threads folgendermaßen fest:

ThreadPool.SetMinThreads(4000, 16);
ThreadPool.SetMaxThreads(4000, 16);

Nachverfolgen

Ich habe Ihren Testcode mit den oben beschriebenen Threadpool-Thread-Zählungen mit den folgenden Ergebnissen ausprobiert (nicht auf meinem gesamten Laufwerk C: \, sondern auf einem kleineren Teilsatz):

  • Modus 1 dauerte 06,5 Sekunden.
  • Modus 2 dauerte 15,7 Sekunden.
  • Modus 3 dauerte 16,4 Sekunden.

Dies entspricht meinen Erwartungen. Durch das Hinzufügen einer großen Menge Threading wird dies tatsächlich langsamer als Single-Threading. Die beiden parallelen Ansätze benötigen ungefähr die gleiche Zeit.


Für den Fall, dass jemand dies untersuchen möchte, gibt es hier einen bestimmenden Testcode (der Code des OP ist nicht reproduzierbar, da seine Verzeichnisstruktur nicht bekannt ist).

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;

namespace Demo
{
    internal class Program
    {
        private static DirWithSubDirs RootDir;

        private static void Main()
        {
            Console.WriteLine("Loading file system into memory...");
            RootDir = new DirWithSubDirs("Root", 4, 4);
            Console.WriteLine("Done");

            //ThreadPool.SetMinThreads(4000, 16);
            //ThreadPool.SetMaxThreads(4000, 16);

            var w = Stopwatch.StartNew();
            ThisIsARecursiveFunctionInMemory(RootDir);

            Console.WriteLine("Elapsed seconds: " + w.Elapsed.TotalSeconds);
            Console.ReadKey();
        }

        public static void ThisIsARecursiveFunctionInMemory(DirWithSubDirs currentDirectory)
        {
            var depth = currentDirectory.Path.Count(t => t == '\\');
            Console.WriteLine(depth + ": " + currentDirectory.Path);

            var children = currentDirectory.SubDirs;

            //Edit this mode to switch what way of parallelization it should use
            int mode = 3;

            switch (mode)
            {
                case 1:
                    foreach (var child in children)
                    {
                        ThisIsARecursiveFunctionInMemory(child);
                    }
                    break;

                case 2:
                    children.AsParallel().ForAll(t =>
                    {
                        ThisIsARecursiveFunctionInMemory(t);
                    });
                    break;

                case 3:
                    Parallel.ForEach(children, t =>
                    {
                        ThisIsARecursiveFunctionInMemory(t);
                    });
                    break;

                default:
                    break;
            }
        }
    }

    internal class DirWithSubDirs
    {
        public List<DirWithSubDirs> SubDirs = new List<DirWithSubDirs>();

        public String Path { get; private set; }

        public DirWithSubDirs(String path, int width, int depth)
        {
            this.Path = path;

            if (depth > 0)
                for (int i = 0; i < width; ++i)
                    SubDirs.Add(new DirWithSubDirs(path + "\\" + i, width, depth - 1));
        }
    }
}
6
Matthew Watson

Die Methoden Parallel.For und .ForEach werden intern als äquivalent zum Ausführen von Iterationen in Tasks implementiert, z. dass eine Schleife wie:

Parallel.For(0, N, i => 
{ 
  DoWork(i); 
});

ist äquivalent zu:

var tasks = new List<Task>(N); 
for(int i=0; i<N; i++) 
{ 
tasks.Add(Task.Factory.StartNew(state => DoWork((int)state), i)); 
} 
Task.WaitAll(tasks.ToArray());

Und aus der Perspektive jeder Iteration, die möglicherweise parallel zu jeder anderen Iteration läuft, ist dies ein ok mental - Modell, das jedoch nicht in Wirklichkeit geschieht. Parallel verwendet notwendigerweise nicht eine Task pro Iteration, da dies erheblich mehr Aufwand verursacht als notwendig ist. Parallel.ForEach versucht, die Mindestanzahl an Tasks zu verwenden, die erforderlich sind, um die Schleife so schnell wie möglich abzuschließen. Es beschleunigt Aufgaben, sobald Threads verfügbar sind, um diese Aufgaben zu verarbeiten, und jede dieser Aufgaben nimmt an einem Verwaltungsschema teil (ich denke, es wird Chunking genannt): Eine Aufgabe verlangt nach mehreren Iterationen, erhält diese und verarbeitet diese Aufgaben. und geht dann für mehr zurück. Die Stückgrößen variieren je nach Anzahl der teilnehmenden Aufgaben, Belastung der Maschine usw.

PLINQs .AsParallel () hat eine andere Implementierung, kann jedoch immer noch mehrere Iterationen in einen temporären Speicher abrufen, die Berechnungen in einem Thread durchführen (aber nicht als Task) und die Abfrageergebnisse in einen kleinen Puffer stellen. (Sie erhalten etwas, das auf ParallelQuery basiert, und dann weiter .Whatever () - Funktionen binden an eine alternative Gruppe von Erweiterungsmethoden, die parallele Implementierungen bereitstellen).

Nun, da wir eine kleine Vorstellung davon haben, wie diese beiden Mechanismen funktionieren, werde ich versuchen, eine Antwort auf Ihre ursprüngliche Frage zu geben:

Warum ist .AsParallel () langsamer als Parallel.ForEach ? Der Grund ergibt sich aus dem Folgenden. Tasks (oder ihre äquivalente Implementierung hier) blockierenNICHTbei E/A-artigen Aufrufen. Sie „warten“ und geben der CPU die Freiheit, etwas anderes zu tun. Aber (zitiert C # nutshell book): "PLINQ kann keine E/A-gebundenen Arbeiten ausführen, ohne Threads zu blockieren". Die Aufrufe sind synchron. Sie wurden mit der Absicht geschrieben, dass Sie den Grad der Parallelität erhöhen, wenn (und NUR wenn Sie) beispielsweise Webseiten pro Task herunterladen, die keine CPU-Zeit beanspruchen.

Und der Grund, warum Ihre Funktionsaufrufe sind genau analog zu E/A-gebundenen Aufrufenist folgender: Einer Ihrer Threads (nennen Sie ihn T) blockiert und tut nichts, bis alle seiner untergeordneten Threads abgeschlossen sind , was hier ein langsamer Prozess sein kann. T selbst ist nicht CPU-intensiv, während es darauf wartet, dass die Kinder die Blockierung aufheben, es tut nichts anderes als zu warten. Daher ist es identisch mit einem typischen E/A-gebundenen Funktionsaufruf.

3
Dean

Basierend auf der akzeptierten Antwort auf Wie genau funktioniert AsParallel?

.AsParallel.ForAll() greift auf IEnumerable zurück, bevor .ForAll() aufgerufen wird. 

es werden also 1 neuer Thread + N rekursive Aufrufe erstellt (von denen jeder einen neuen Thread generiert).

0
user1023602