„Mach es asynchron, dann ist es schneller“ ist einer der häufigsten Irrtümer in .NET. Asynchron heißt: Während gewartet wird, ist der Thread frei für anderes. Parallel heißt: Mehrere Kerne rechnen zugleich. Das eine hilft beim Warten, das andere beim Rechnen, und wer beides verwechselt, baut Code, der unter Last langsamer wird statt schneller. Diese Lektion trennt die beiden sauber und zeigt, wie viele Aufrufe auf einmal die richtige Zahl sind: Für den Import der Kladde sind es vier, und Sie messen, warum.
Warten oder rechnen?
Arbeit gibt es in zwei Sorten:
- E/A-gebunden (I/O-bound): Das Programm wartet auf etwas außerhalb, auf die Datenbank, das Netz, eine Datei. Der Prozessor hat in der Zeit nichts zu tun. Fast alles, was die Kladde tut, ist von dieser Sorte.
- Rechengebunden (CPU-bound): Der Prozessor rechnet die ganze Zeit, etwa beim Auswerten großer Mengen im Speicher, beim Komprimieren oder beim Hashen von Kennwörtern (Lektion 3.3.6).
Für E/A hilft await: Der Thread geht zurück in den Pool, während gewartet wird. Für Rechenarbeit hilft nur ein weiterer Kern, und den
bekommt man mit Parallelität. Wie unterschiedlich das wirkt, zeigt ein kleines Programm mit zwanzig Wartezeiten und sechzehn Rechnungen:
// warten-rechnen.cs: twenty waits and sixteen calculations, one after another and side by side
#:property PublishAot=false
using System.Diagnostics;
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("de-DE");
Console.WriteLine($"Logische Kerne: {Environment.ProcessorCount}");
await Measure("20 x warten, nacheinander", async () =>
{
for (int i = 0; i < 20; i++)
{
await Task.Delay(200);
}
});
await Measure("20 x warten, Task.WhenAll", () => Task.WhenAll(Enumerable.Range(0, 20).Select(_ => Task.Delay(200))));
await Measure("16 x rechnen, nacheinander", () =>
{
for (int i = 0; i < 16; i++)
{
CountPrimes(1_000_000);
}
return Task.CompletedTask;
});
await Measure("16 x rechnen, Task.WhenAll ohne Task.Run", () =>
Task.WhenAll(Enumerable.Range(0, 16).Select(_ => Task.FromResult(CountPrimes(1_000_000)))));
await Measure("16 x rechnen, Task.WhenAll mit Task.Run", () =>
Task.WhenAll(Enumerable.Range(0, 16).Select(_ => Task.Run(() => CountPrimes(1_000_000)))));
static async Task Measure(string name, Func<Task> work)
{
var watch = Stopwatch.StartNew();
await work();
Console.WriteLine($"{name,-42} {watch.Elapsed.TotalSeconds,5:0.00} s");
}
// Pure work for the processor: no waiting, only arithmetic.
static int CountPrimes(int limit)
{
int count = 0;
for (int n = 2; n < limit; n++)
{
bool prime = true;
for (int d = 2; d * d <= n; d++)
{
if (n % d == 0)
{
prime = false;
break;
}
}
if (prime)
{
count++;
}
}
return count;
}
Auf einem Testrechner mit 32 logischen Kernen, dann noch einmal mit DOTNET_PROCESSOR_COUNT=2, als hätte er zwei; Ihre Zahlen sind andere:
Logische Kerne: 32
20 x warten, nacheinander 4,28 s
20 x warten, Task.WhenAll 0,21 s
16 x rechnen, nacheinander 2,42 s
16 x rechnen, Task.WhenAll ohne Task.Run 2,73 s
16 x rechnen, Task.WhenAll mit Task.Run 0,23 s
Logische Kerne: 2
20 x warten, nacheinander 4,25 s
20 x warten, Task.WhenAll 0,21 s
16 x rechnen, nacheinander 2,32 s
16 x rechnen, Task.WhenAll ohne Task.Run 2,37 s
16 x rechnen, Task.WhenAll mit Task.Run 1,19 s
Vier Beobachtungen:
- Gleichzeitig warten ist fast umsonst. Zwanzig Wartezeiten von 200 Millisekunden dauern zusammen 0,21 Sekunden, mit zwei Kernen genauso wie mit 32. Warten braucht keinen Kern (Lektion 3.11.1).
Task.WhenAllmacht Rechenarbeit nicht parallel. OhneTask.Runläuft jede Rechnung schon beim Aufruf, auf dem aufrufenden Thread, eine nach der anderen. DasTaskam Ende ist nur eine Hülle um ein fertiges Ergebnis.Task.Runverteilt Rechenarbeit auf Kerne. Mit 32 Kernen wird es etwa zehnmal schneller, mit zwei Kernen nur doppelt so schnell. Mehr als die Zahl der Kerne geht nicht.- Eine Methode mit
asyncim Namen ist nicht automatisch asynchron. Was sie vor ihrem ersten echtenawaittut, tut sie auf dem Thread des Aufrufers.