C# Mehr Threads werden immer langsamer?

Ghost_Rider_R

Lieutenant
Registriert
Nov. 2009
Beiträge
752
Hallo zusammen,

ich entwickle gerade eine Anwendung, welche mit zunehmender Anzahl an Threads immer langsamer wird. Ich denke aber nicht, dass das Problem an der Parallelisierung als solches liegt, denn das dürfte hier sehr gut funktionieren, da die Threads nicht voneinander abhängen, sondern ich vermute eher, dass der Overhead der Threads dafür verantwortlich ist d.h. dass der Wechsel zwischen den Threads (bis knapp 100 maximal) zu viel Resourcen verschlingt.

Es ist auch so, dass mehr Rechenoperationen geschafft werden, je weniger Threads involviert sind, dabei sinkt auch noch die CPU last.
Sinkende CPU last bei steigender Leistung innerhalb der Anwendung? da muss irgendwas außenrum viel Energie verbraten, könnte das der Wechsel zwischen den Threads sein? kann dieser Overhead an sowas schuld sein? und wenn ja, was kann man da tun?

LG Ghost
 
Von welchen Leistungsabfällen in Abhängigkeit der Threadzahl reden wir den hier?
 
Und wieviele Hardwarethreads gibt es?

Klar daß ein C2D mit 20 threads überfordert wäre- ebenso klar ist aber auch, daß es durchaus sinnvolle Obergrenzen gibt (in Abhängigkeit des zu lösenden Problems).
 
100 Threads sind viel zu viel. Threadswapping ist extrem teuer im Vergleich. Ein Programm sollte wenn es die Threads aktiv nutzt nicht mehr als die doppelte Kernanzahl pi mal daumen nutzen. Klar es gibt Ausnahmen aber so grob

Warum ist ganz einfach.

Das Programm führt sagen wir mal zu 100% Auslastung der Threads.

Dann bringen dir mehr Thread als kernanzahl sowieso nix. Weil es keine Ressourcen gibt. Hyperthread mal ausgenommen die im Schnitt 25 % mehr Leistung bringen als ein echter Kern.

Dauer ist wichtig, dauert deine Operation zu kurz wird das Handlung der Threads den Löwenanteil der Zeit verschlingen und damit die performance Bremsen.

Das du mit weniger Threads. Mehr Leistung erhältst ist ein Trugschluss.

Dadurch das du die CPU weniger belastests, wird die CPU höher takten und dadurch mehr schaffen weil mehr Energie verbraten werden kann und dann geswitcht wird.
Das heißt aber nicht das multivore nicht, deutlich besser wäre.

Dein Programm ist zu schwach um saubere multicore performance zu liefern.

Ich denke mal das du wegen der 100 threads das Problem zu klein zu drücken versuchst und das schadet dir mehr als es bringt.
 
  • Gefällt mir
Reaktionen: falauss und BeBur
context switches fressen leistung. das wird ein gewichtiger faktor, sobald jeder hardware-kern gleichzeitig mehr als einen thread abarbeiten muss (und der workload sauber parallelisiert ist und keine groesseren i/o-blocks oder aehnliches beinhaltet).

unter der annahme, dass du keinen 50-kerner mit HT hast, sind 100 threads straight up zu viele. optimale leistung solltest du fuer die anzahl threads erhalten, wie du threads auf deine(r/n) cpu verbaut hast.

Es ist auch so, dass mehr Rechenoperationen geschafft werden, je weniger Threads involviert sind, dabei sinkt auch noch die CPU last.
Sinkende CPU last bei steigender Leistung innerhalb der Anwendung?
dazu muesstest du jetzt mal ein diagramm bauen fuer performance vs. thread count, inkl. information ueber deine cpu. anhanddessen kann man dann erklaerungen versuchen
 
Die Faustregel ist: Optimale Anzahl Threads = Anzahl physische Kerne im System.
Hast du mehr Threads kommt es durch Kontext-Wechsel zu einem gewissen Overhead und Verlust. Werden die Daten geshared kann es durch Thread Contention ebenfalls zu Performance-Problemen kommen.

Last but not least: Auslastung der Threads. Ist deine Workload zu klein ist der Overhead in der Regel zu groß. Es gibt verschiedene Ansätze die Versuchen dies zu umgehen (Go: gofunctions, Java: Work-Steal-Thread-Pools, Kotlin: Coroutines, Akka etc.). Bei C# bin ich mir gerade nicht sicher.

Achja und eine ganz offensichtliche Sache: Versuch Threads wiederzuverwenden!
 
KitKat::new() schrieb:
Ja ich war mir jetzt nicht sicher ob C# sowas hat. Mein Wissen darüber ist etwas eingerostet :D
 
Natürlich hat C# einen Threadpool. Den Threadpool zu verwenden ist die effizienteste Art, in C# parallele und asynchrone Aufgaben durchzuführen.

Hab mal kurz ein Beispiel geschrieben. Hier werden 10000 Threads erzeugt, die jeweils eine Zahl 100 mal hochzählen. Danach werden 10000 Tasks in den Thread Pool geschickt, die dasselbe machen:

C#:
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;

namespace ThreadTaskTest
{
    class Program
    {
        static int Zahl;

        static void Main(string[] args)
        {
            // Teil 1: Threads
            List<Thread> threads = new List<Thread>();
            var watch = Stopwatch.StartNew();
            for (int i = 0; i < 10000; ++i)
            {
                var thread = new Thread(Inkrement);
                thread.Start();
                threads.Add(thread);
            }
            foreach (var thread in threads)
                thread.Join();
            watch.Stop();
            Console.WriteLine("Threads:");
            Console.WriteLine("Zahl = " + Zahl);
            Console.WriteLine("Benötigte Zeit: " + watch.ElapsedMilliseconds + "ms");

            // Teil 2: Thread Pool
            Zahl = 0;
            List<Task> tasks = new List<Task>();
            watch.Restart();
            for (int i = 0; i < 10000; ++i)
            {
                var task = Task.Run(Inkrement);
                tasks.Add(task);
            }
            Task.WhenAll(tasks).Wait();
            watch.Stop();
            Console.WriteLine("\nTasks:");
            Console.WriteLine("Zahl = " + Zahl);
            Console.WriteLine("Benötigte Zeit: " + watch.ElapsedMilliseconds + "ms");

            Console.ReadKey();
        }

        static void Inkrement()
        {
            for (int i = 0; i < 100; ++i)
                Interlocked.Increment(ref Zahl);
        }
    }
}

Ausgabe:

Code:
Threads:
Zahl = 1000000
Benötigte Zeit: 752ms

Tasks:
Zahl = 1000000
Benötigte Zeit: 24ms

Zu beachten ist hierbei, dass die Tasks im Threadpool in eine Warteschlange kommen und nicht zwingenderweise Parallel ausgeführt werden. Man kann das manipulieren indem man Delays in die Tasks einbaut, z.B. Task.Delay(1).Wait(), während dem Delay wird schonmal der nächste Task abgearbeitet. Bei Bedarf erzeugt sich der Threadpool automatisch neue Threads.
Ich kann nicht zu sehr ins Detail gehen, weil das ein riesen Thema mit einem Haufen Möglichkeiten ist. Ein guter Anfang wäre es sich in die TPL einzulesen:
https://docs.microsoft.com/de-de/dotnet/standard/parallel-programming/task-parallel-library-tpl
 
Zuletzt bearbeitet:
  • Gefällt mir
Reaktionen: TorenAltair
Wenn das C# ist, kann man natürlich Pools hernehmen (geht sicher auch anderswo, aber da steck ich nicht so im Threading drin).

Muß aber den Pool entsprechend konfigurieren. Wenn ich mich recht entsinne ist der standardmäßig auf zwei Threads begrenzt.

Und es war sicherlich nur ein Beispiel von @michi.o, aber trotzdem zur Sicherheit:
- globale Variablen in Threads meiden, soweit es irgend möglich ist. Besonders wenn da reingeschrieben werden soll.
Manchmal geht es nicht ohne größeren Aufwand - oder gefühlt gar nicht--- anders, aber jeder Schreibvorgang nach Global bringt naturgemäß Mutexes mit sich und die Threads müssen aufeinander warten; wenn man also die Möglichkeit hat, sich auf threadlokale Werte zu beschränken und Ergebnisse anderweitig zurückzugeben, dann sollte man sich das zumindest anschauen.
Schlimmer: Ein Thread hängt und dann hängt die gesamte Anwendung infolgedessen, weil alle anderen Threads auf die blockierte Ressource zugreifen wollen; also muß ich mir was einfallen lassen wie ich sowas abwenden kann.


Protip: Mit C# und sicherlich auch anderen Programmiersprachen kann man zur Laufzeit ermitteln, wieviele Hardwarethreads vorhanden sind und kann auf dieser Basis seinen Threadpool dynamisch konfigurieren.
Das macht aber natürlich nur Sinn, wenn man sein Problem in variabel viele Teile zerlegen kann.
 
Die meisten Tips und Fallstricke wurden ja bereits genannt.
Hinzufügen würde ich noch, dass es je nach deiner Problemstellung auch sinnvoll sein könnte mit parallelen Schleifen zu arbeiten. Siehe dazu:

https://coders-corner.net/2013/02/1...parallel-for-und-parallel-foreach-entwickeln/

Extrem simpel zu verwenden und können je nach Workload sehr effizient sein.
Oft sind separate Threads einfach zu viel des Guten. Vor allem wenn man große Datenmengen, die nicht voneinander abhängig sind, parallel abarbeiten will.
 
Zu viel Blabla hier.
DIE optimale Anzahl von Threads gibt es nicht. Was habe ich bei einem Downloadmanager, wenn dieser nur so viele Threads verwendet als man CPU-Kerne hat?
Wir wissen hier nichtmal ungefähr was gemacht wird.
Du hast 2 Möglichkeiten an brauchbare Antworten zu kommen:
1) Code her.
2) Selber profilen.

Ich empfehle letzteres. Viel Erfolg.
 
  • Gefällt mir
Reaktionen: Killkrog
Ghost_Rider_R schrieb:
vermute eher, dass der Overhead der Threads dafür verantwortlich ist d.h. dass der Wechsel zwischen den Threads (bis knapp 100 maximal) zu viel Resourcen verschlingt.

Es ist auch so, dass mehr Rechenoperationen geschafft werden, je weniger Threads involviert sind, dabei sinkt auch noch die CPU last.
Sinkende CPU last bei steigender Leistung innerhalb der Anwendung? da muss irgendwas außenrum viel Energie verbraten, könnte das der Wechsel zwischen den Threads sein? kann dieser Overhead an sowas schuld sein? und wenn ja, was kann man da tun?

Welche Datenmengen werden denn wie lange pro Thread verarbeitet und wie groß sind die CPU Caches?
Eine vollständige parallele Ausführung also die theoretische echte lineare Verbesserung ist durch ein Programm, wenn überhaupt nur annähernd im Idealfall möglich.
Sobald zB alle Daten aller Threads nicht zusammen in den L1,2,3 CPU Cache passen gibt es schon direkt starke Effekte, da werden die Daten ständig neu von RAM zur CPU kopiert was dazu führt, dass die Performance mit steigender Threadanzahl pro Thread sinkt. Ist völlig normal. In welchem Ausmaß das passiert und wie nah oder wie weit weg es von der idealtheoretischen lineare Verbesserung ist, hängt von etliche Faktoren ab die du hier bisher nicht aufzeigst.

Was man tun kann ist zB, Daten sehr klein halten das alles in den CPU Cache passt (kann man sich nicht immer aussuchen), CPU kaufen mit sehr viel Cache (zB Server CPUs), Cachefreundlich Coden, der CPU versuchen die branch prediction leicht zu machen, Compilerflags, usw usw.
Aber wie gesagt ohne mehr Infos kann man auch nur schwer was sagen.

Ansonsten dazu mal angucken:
https://de.wikipedia.org/wiki/Amdahlsches_Gesetz
https://de.wikipedia.org/wiki/Gustafsons_Gesetz
 
Hallo zusammen,

entschuldigt bitte die längere Antwortzeit, ich bringe auch einige Infos mit 😊.

Im Enddefekt bin ich nur am rumspielen und möchte meine C# Skills verbessern, es gibt nicht wirklich eine konkrete Aufgabenstellung.

Ich habe mein Problem jetzt aber wunderbar eingrenzen können, damit jeder nachvollziehen kann was ich meine.

Ein ganz simples Beispiel, welches pro Worker eine Zahl hochzählt. Theoretisch sollte ja mit zunehmender Parallelisierung die Leistung steigen, dem ist aber nicht so.

Beispiel mit einem Worker-Thread: -> 458.975.503 Hochzählungen pro Sekunde bei 6% CPU Last
1 Thread.jpg



#################################################################################

Dann genau der gleiche Code mit 12 Threads (zzgl. des Main Threads, welcher die GUI aktualisiert, 12 Kerne / 24 Threads CPU) -> Nur noch gerade einmal 14.309.545 Hochzählungen pro Sekunde, dafür jetzt aber 57% CPU Last:

12 Threads.jpg


Ich hätte eigentlich erwartet, dass ich bei 12x 458.975.503 = 5.507.706.036 Hochzählungen pro Sekunde lande abzgl. etwas Overhead, aber stattdessen so viel weniger, bei mehr CPU Last?!

Anbei noch der Code zum nachstellen:
TXT File mit Quellcode

Vielen Dank für eure zahlreichen Beiträge, ich habe jeden Einzelnen aufmerksam durchgelesen!

LG Ghost Rider
 
  • Gefällt mir
Reaktionen: TriggerThumb87
Dein Code ist nicht threadsafe.

Deine x threads überschreiben alle den gleichen Zähler, synchronisieren dabei aber nicht, von welchem Ausgangswert sie schreiben. zB können da zwei Threads gleichzeitig bei "zaehler" eine 7 auslesen, addieren jeweils also +1, und schreiben dann beide eine 8 zurück, obwohl es dadurch schon 9 inkrements sein sollten.
mWn nennt man das hier Write-Write Hazard.

Gib jedem Thread einen eigenen Zähler und addier die am Ende. Dann sollten deine ca 5.5Mrd Inkrements rauskommen.
 
  • Gefällt mir
Reaktionen: Arc Angeling
...gerade gemacht und siehe da, ich tingel irgendwo bei 9Mrd rum. Super vielen vielen Dank für den Tipp!!!

Könnt ihr mir das eventuell noch ein kleines bisschen ausführlicher erklären, was da genau passiert ist? Threadsafe ist mir bis dato noch kein Begriff 🤔
 
"zaehler++" ist keine atomare Operation (atomar bedeutet: Es wird von nur einem Opcode repräsentiert). Im Maschinencode läuft das i.d.R. so ab:
  • lese aktuellen Wert von zaehler
  • inkrementiere um 1
  • schreibe Ergebnis zurück nach zaehler
Deine 12 Threads machen genau das selbe. Gleichzeitig. Nun kommt es dazu dass das Ergebnis das ein Thread berechnet nicht die Ergebnisse der anderen Threads berücksichtigt.

Synchronisierung widerrum kümmert sich darum das o.g. drei Aktionen (lese, increment, schreibe) nur ein Thread jeweils durchführen darf, um zu garantieren dass sich die Threads nicht gegenseitig überschreiben.

Dies führt aber zu einem erhöhten Aufwand und i.d.R. weniger Performance.

Eine Warnung noch: Selbst wenn eine Operation atomar erscheint, ist das bei 64-Bittigen Variablen nicht garantiert da diese in zwei 32-Bit Variablen aufgeteilt werden kann.

Allgemein solltest du davon ausgehen dass nichts synchronisiert und daher Threadsafe ist, außer du nutzt spezielle Klassen / Methoden die dir dies garantieren, wie z.B. diese hier:

https://docs.microsoft.com/de-de/dotnet/api/system.threading.interlocked.increment?view=net-5.0

Dieses Grundkonzept gilt für die meisten Hochsprachen.
 
Zuletzt bearbeitet:
  • Gefällt mir
Reaktionen: TriggerThumb87
Ghost_Rider_R schrieb:
Könnt ihr mir das eventuell noch ein kleines bisschen ausführlicher erklären, was da genau passiert ist? Threadsafe ist mir bis dato noch kein Begriff 🤔
Du hast eine Race Condition eingefügt (in Rust wäre dir das nicht passiert ;) ):
https://stackoverflow.com/questions/34510/what-is-a-race-condition

3 Lösungen:
 
Zuletzt bearbeitet:
codengine schrieb:
atomar bedeutet: Es wird von nur einem Opcode repräsentiert

Diese Aussage stieß bei mir auf Misstrauen, ich habe daher versucht, mich schlau zu machen. Das Ergebnis scheint tatsächlich "Nein" zu sein.
https://www.halolinux.us/kernel-reference/atomic-operations.html

Es ist etwas schwierig zu lesen, gerade wenn man vorher versucht hat andere Quellen zu finden, in denen beispielsweise "instruction" und "operation" wild hin und her geworfen und synonym verwenden werden. Es gibt aber mehrere Faktoren und unterschiedliche Definitionen von atomar. Daher hier grobe Denkanstöße wo es zu Unterscheidungen oder Problemen kommen kann:

-Mehrkernsystem?
-Ist der Bus breit genug?
-Wie wird gecached?
-Für x86 bestimmte Operationen (damit meine ich opcodes) "prefixed by a rep byte (0xf2, 0xf3), which forces the control unit to repeat the same instruction several times)"
-uvm.

@Ghost_Rider_R:
Wie du siehst, sind Informationen in der Informationsverarbeitung wichtig. :p
 
Zuletzt bearbeitet:
  • Gefällt mir
Reaktionen: Arc Angeling
@TriggerThumb87
Ja, absolut, ich habe auch versucht schnellstmöglich mehr Informationen beizusteuern, nur bei 3 Kindern liegt meine Threadpriority wo anders, sonst gibt das ne riesen Exception 😂
 
  • Gefällt mir
Reaktionen: TriggerThumb87
Zurück
Oben