ThreadPool.QueueUserWorkItem vs Thread.Start (Vergleich der Ausführungsgeschwindigkeit)

ThreadPool.QueueUserWorkItem vs Thread.Start (Vergleich der Ausführungsgeschwindigkeit)

Im Rahmen der Optimierung der Laufzeit einer Anwendung stolperte ich über einen in der Praxis recht oft vorkommenden Fall. Der Profiler zeigte (nach langem Konfigurieren und Suchen dank der schlechten Dokumentation des mäßigen Profilers im Visual Studio 2008 TS) eine bestimmte recht kostenintensive I/O-Funktion, die oft aufgerufen wird. Klarer Fall: so etwas läßt sich einfach durch Parallelisierung optimieren.

Und zwar.. weil dies ein rein schreibender Zugriff ist, dessen Ausführung in keinster Weise mit irgendetwas synchronisiert werden müßte. Also ein klassischer Kandidat für einen schnellen asynchronen Aufruf. Sollte man meinen. Denn „mal schnell asynchron machen” wollte ich es auch nicht, immerhin ist ja das Erstellen von Threads auch nicht kostenlos. Und ich möchte ja Geschwindigkeit gewinnen und nicht noch langsamer werden.

Also habe ich zunächst das ExecutNonQuery durch ein BeginExecuteNonQuery ersetzt mit der entsprechenden Syntax (Callback, etc.) und bin dann über ein Einfrieren meiner Applikation gestolpert. Und habe dann gesehen, daß noch zahlreiche andere Stellen im Code immer noch synchron zugreifen. Da die Umstellung hier eine größere Codeänderung mit sich bringen würde (mit fraglichem Ausgang, immerhin muß ja hier teilweise auch am SQL Server umkonfiguriert werden, damit asynchrone Befehle fehlerfrei funktionieren) entschied ich mich dann für einen klassischen asynchronen Aufruf des synchronen Kommandos ExecuteNonQuery.

Da ich, wie gesagt, eine schnellere (bzw. weniger langsame) Lösung haben wollte, und ich mir Sorgen machte, daß das Starten von vielen 10.000 Threads durchaus einen Einfluß auf die Performance haben könnte, habe ich in einer kurzen Demo-Applikation (Experimente in einer großen, aufwendig zu benutzenden Applikation dauern entschieden zu lange) aufgesetzt.

Test-Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
private static void ThreadTest()
{
    const int innerruns = 5000;
    const int totalrepetitions = 10000;
    const int printevery = 500;
    int cnt = 0;
    DateTime beg = DateTime.Now;
    for (int i = 0; i < totalrepetitions; ++i)
    {
        ThreadPool.QueueUserWorkItem(
            (a) =>
            {
                double d = 1.0;
                for (int j = 0; j < innerruns; ++j)
                    d = d * Math.Sin(d) * 1.1;
                if (++cnt % printevery == 0)
                Console.WriteLine(cnt + "result: " + d.ToString("f3"));
            }
            );
    }
    DateTime end = DateTime.Now;
    double diff = (end - beg).TotalSeconds;
    Console.WriteLine("diff: " + diff);
 
    Thread.Sleep(5000);
 
    beg = DateTime.Now;
    for (int i = 0; i < totalrepetitions; ++i)
    {
        Thread t = new Thread(() =>
            {
                double d = 1.0;
                for (int j = 0; j < innerruns; ++j)
                    d = d * Math.Sin(d) * 1.1;
                if (++cnt % printevery == 0)
                Console.WriteLine(cnt + "result: " + d.ToString("f3"));
            }
        );
        t.Priority = ThreadPriority.BelowNormal;
        t.Start();
    }
    end = DateTime.Now;
    diff = (end - beg).TotalSeconds;
    Console.WriteLine("diff: " + diff);
}

Eine einfache Funktion, die erst ein paar Threads als BackgroundWorker aus dem ThreadPool startet und anschließend ein eher klassicher Ansatz per Thread.Start. Dazu eine kleine Aufgabe (im Lambda-Ausdruck), die primär dazu dient, den Thread eine Weile am Laufen zu halten und die CPU zu beschäftigen.

ThreadPool vs Thread

Erwartungsgemäß ist der ThreadPool einiges schneller als der klassische Start eines Threads, der ja noch den Overhead des Kreierens etc. beinhaltet. Wirklich gravierend waren die Unterschiede jedoch auch nicht.

Der Lauf im Debugger brachte folgende Ausgabe:

1
2
3
4
5
6
7
8
9
10
diff: 0,0156251
500result: 0,000
1000result: 0,000
1500result: 0,000
...
18000result: 0,000
18500result: 0,000
19000result: 0,000
19500result: 0,000
diff: 17,3751112

Hier fallen ein paar Dinge auf:

  • Das Befüllen des ThreadPool ist direkt fertig, noch vor der ersten Aufgabe (wie auch immer das funktioniert – vermutlich gibt es hier tatsächlich eine Queue, aus der die WorkerThreads dann die anstehenden Aufgaben abarbeiten)
  • (bei Drehen an den Parametern sieht man:) es werden nicht alle Zahlen ausgegeben. Aufgrund der einfachen (nicht gelockten) Ausführung wird vermutlich counter nicht korrekt erhöht und nie bei 19999 ankommen.
  • Thread.Start endet nicht bevor die Threads auch fertig sind. Dies hat mich sehr verwundert und ich habe keine richtige Erlärung, außer vlt. daß immer nur eine gewisse Anzahl von Threads laufen können, und diese dann (zumindest gemäß der Anzeige) vor der letzten Zeitausgabe enden (die letzten 20-30 Threads erzeugen keine Ausgabe mehr).

Debug vs Release

Rein interessehalber habe ich alles noch einmal als Release-Compilat sowie ohne Debugger laufen gelassen.

Die Unterschiede sind deutlich.

1
2
3
4
5
6
7
8
9
10
diff: 0
500result: 0,000
1000result: 0,000
1500result: 0,000
...
18000result: 0,000
18500result: 0,000
19000result: 0,000
19500result: 0,000
diff: 0,8906307

Wobei fairerweise gesagt werden sollte, daß die Zeitmessung des Startens via ThreadPool am Rande der Meßgenauigkeit von Windows ist, die standardmäßig bei ca. 15-16ms liegt (jedoch je nach System erfolgreich auf 1ms geändert werden könnte, was ich mir in diesem Fall jedoch gespart habe).

Fazit

Threads aus dem ThreadPool müssen nicht erst erzeugt und initialisiert werden und sind damit – wie erwartet – deutlich schneller. Wer die Vorteile (Synchronisation, mehr Kontrolle, …) von eigenen Threads nicht braucht, fährt mit ihnen sehr gut – und günstig. Den alten Aufruf packe ich einfach in einem (anonyme, beim Aufruf deklarierte) Lambda-Funktion. Das ist sehr bequem und erlaubt mir sogar, per Suchen und Ersetzen den Code an mehreren Stellen upzudaten.

Den Unterschied zwischen Debug- sowie Release-Builds werde ich ebenfalls weiterverfolgen, wobei hier evtl. auch durch die vielen (Initialisierungs-)Speicherzugriffe die Ausführungsgeschwindigkeit um den Faktor 20 unterschiedlich ist.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.