Mit diesem Modul geht es eine Ebene tiefer: Was passiert eigentlich, wenn hundert Besucher gleichzeitig die Kladde aufrufen? Bisher hat ASP.NET Core das für Sie erledigt, und meistens ist das auch richtig so. Aber zwei Werkstattberichte aus K3 hatten ihre Ursache genau hier: die zwei Timer, die zugleich fällig wurden (Lektion 3.8.4), und der blockierende Aufruf, der eine kleine Replik in die Knie zwingt (Lektion 3.9.5). Dieses Modul macht gründlich, was dort nur angerissen war. Es beginnt mit den Bausteinen: Threads und dem Thread-Pool, auf dem fast alles in .NET läuft.
Für die Beispiele legen Sie einen Ordner k3-uebungen/3-11 an. Jedes Beispiel ist eine Datei, die Sie mit dotnet run <datei>.cs starten.
Den Lastgenerator last.cs aus Lektion 3.8.6 kopieren Sie in denselben Ordner.
Prozess, Thread, Kern
- Ein Prozess ist ein laufendes Programm mit eigenem Speicher. Die Kladde in einer Replik von Container Apps ist ein Prozess.
- Ein Thread ist ein Ausführungsstrang in einem Prozess. Alle Threads eines Prozesses teilen sich seinen Speicher; jeder hat seinen eigenen Stapel (Stack) für lokale Variablen und Aufrufe.
- Ein Kern (genauer: ein logischer Prozessor) führt zu jedem Zeitpunkt genau einen Thread aus. Das Betriebssystem wechselt zwischen den Threads, viele Male je Sekunde. Mehr Threads als Kerne heißt: Sie wechseln sich ab.
Environment.ProcessorCount nennt die Zahl der logischen Prozessoren, die .NET sieht. In einem Container ist das, was ihm zugeteilt ist: Eine
Replik mit einem halben Kern sieht einen Prozessor.
Was kostet ein Thread? Das Betriebssystem legt für jeden einen Stapel und eigene Verwaltungsdaten an, und jeder Wechsel zwischen Threads kostet
Zeit. Ein Vergleich: tausend Wartezeiten von einer Sekunde, einmal als tausend Threads, einmal als tausend Aufgaben mit await.
// threads.cs: a thousand waits, once as threads and once as tasks
#:property PublishAot=false
using System.Diagnostics;
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("de-DE");
Console.WriteLine($"Logische Kerne: {Environment.ProcessorCount}");
using var process = Process.GetCurrentProcess();
long memoryBefore = PrivateMemory(process);
var watch = Stopwatch.StartNew();
var threads = Enumerable.Range(0, 1000).Select(_ => new Thread(() => Thread.Sleep(1000))).ToList();
threads.ForEach(thread => thread.Start());
int threadsWhileWaiting = ThreadCount(process);
long memoryWhileWaiting = PrivateMemory(process);
threads.ForEach(thread => thread.Join());
Console.WriteLine($"1.000 Threads: {watch.Elapsed.TotalSeconds:0.0} s, {threadsWhileWaiting,5:N0} Threads im Prozess, " +
$"{(memoryWhileWaiting - memoryBefore) / 1024.0 / 1024:N0} MB mehr Speicher");
memoryBefore = PrivateMemory(process);
watch.Restart();
Task all = Task.WhenAll(Enumerable.Range(0, 1000).Select(_ => Task.Delay(1000)));
threadsWhileWaiting = ThreadCount(process);
memoryWhileWaiting = PrivateMemory(process);
await all;
Console.WriteLine($"1.000 Aufgaben: {watch.Elapsed.TotalSeconds:0.0} s, {threadsWhileWaiting,5:N0} Threads im Prozess, " +
$"{(memoryWhileWaiting - memoryBefore) / 1024.0 / 1024:N0} MB mehr Speicher");
static int ThreadCount(Process process)
{
process.Refresh();
return process.Threads.Count;
}
static long PrivateMemory(Process process)
{
process.Refresh();
return process.PrivateMemorySize64;
}
Auf einem Testrechner mit 32 logischen Kernen; Ihre Zahlen sind andere:
Logische Kerne: 32
1.000 Threads: 1,3 s, 1.008 Threads im Prozess, 59 MB mehr Speicher
1.000 Aufgaben: 1,0 s, 9 Threads im Prozess, 0 MB mehr Speicher
Tausend Threads, die nur warten, belegen tausend Stapel und Dutzende Megabyte. Tausend Aufgaben, die mit await warten, belegen gar keinen
Thread: Während Task.Delay läuft, gibt es nichts zu tun, und erst wenn die Zeit um ist, setzt ein Thread die Aufgabe fort. Diese Einsicht
trägt das ganze Modul: Warten braucht keinen Thread, nur Arbeit braucht einen.