Lektion 3.11.4 hat gezeigt, wie leicht ein Dictionary unter gleichzeitigen Zugriffen kaputtgeht, und wie eine Sperre es rettet. Für die
häufigsten Fälle gibt es fertige Sammlungen, die gleichzeitige Zugriffe von sich aus vertragen, und für die Übergabe von Arbeit zwischen Teilen
eines Programms gibt es Channels. Beide nehmen Ihnen Sperren ab, aber nicht das Nachdenken: Auch eine nebenläufige Sammlung hat Stellen, an
denen sie anders arbeitet, als man denkt. Am Ende dieser Lektion weckt ein Channel den Postausgang der Kladde, sobald eine Buchung eine Mail
braucht, statt bis zum nächsten Takt zu warten.
ConcurrentDictionary und seine Fabrik
ConcurrentDictionary<TKey, TValue> aus System.Collections.Concurrent erlaubt Lesen und Schreiben aus vielen Threads zugleich. Seine wichtigste
Methode ist GetOrAdd(key, factory): Gibt es den Schlüssel, liefert sie den Wert, sonst ruft sie die Fabrik auf und speichert das Ergebnis.
Eine Falle steckt darin, und Microsoft dokumentiert sie: Die Fabrik läuft außerhalb der inneren Sperren. Fragen mehrere Threads gleichzeitig
nach demselben fehlenden Schlüssel, ruft jeder die Fabrik auf; gespeichert wird nur ein Ergebnis, die anderen werden verworfen.
// getoradd.cs: 32 tasks ask a ConcurrentDictionary for the same key at once
#:property PublishAot=false
using System.Collections.Concurrent;
int plainCalls = 0;
var plain = new ConcurrentDictionary<string, string>();
Parallel.For(0, 32, i => plain.GetOrAdd("2025-03-03", day =>
{
Interlocked.Increment(ref plainCalls);
Thread.Sleep(50); // a slow load, like a database query
return $"freie Zeiten für {day}";
}));
Console.WriteLine($"GetOrAdd mit Fabrik: Fabrik {plainCalls}-mal aufgerufen, {plain.Count} Eintrag");
int lazyCalls = 0;
var lazy = new ConcurrentDictionary<string, Lazy<string>>();
Parallel.For(0, 32, i => _ = lazy.GetOrAdd("2025-03-03", day => new Lazy<string>(() =>
{
Interlocked.Increment(ref lazyCalls);
Thread.Sleep(50);
return $"freie Zeiten für {day}";
})).Value);
Console.WriteLine($"GetOrAdd mit Lazy<string>: Fabrik {lazyCalls}-mal aufgerufen, {lazy.Count} Eintrag");
GetOrAdd mit Fabrik: Fabrik 32-mal aufgerufen, 1 Eintrag
GetOrAdd mit Lazy<string>: Fabrik 1-mal aufgerufen, 1 Eintrag
Zweiunddreißig Datenbankabfragen für einen Wert. Ist die Fabrik billig und ohne Nebenwirkung, ist das egal. Ist sie teuer oder verändert sie
etwas, etwa weil sie eine Zeile anlegt oder eine Mail schickt, ist es ein Fehler. Der Ausweg ist ein Lazy<T> als Wert: GetOrAdd legt dann
höchstens ein paar billige Lazy-Hüllen an, von denen eine gewinnt, und alle Threads holen sich .Value aus derselben Hülle, die die Fabrik
genau einmal ausführt. Warum Lazy<T> das sicher kann, erklärt Lektion 3.11.7.
Dasselbe gilt für AddOrUpdate: Die Funktion zum Ändern kann unter Gedränge mehrmals laufen. Und ConcurrentDictionary schützt jeden einzelnen
Aufruf, nicht eine Folge von Aufrufen. if (!dict.ContainsKey(k)) dict[k] = v; ist auch mit ConcurrentDictionary ein Wettlauf; dafür gibt es
GetOrAdd und TryAdd.