Lesson 3.11.4 showed how easily a Dictionary breaks under concurrent access, and how a lock rescues it. For the
most common cases there are ready-made collections that tolerate concurrent access on their own, and for handing work over between parts
of a program there are channels. Both take locks off your hands, but not the thinking: even a concurrent collection has places where
it works differently than you would expect. At the end of this lesson a channel wakes the Kladde's outbox as soon as a booking needs a mail,
instead of waiting for the next tick.
ConcurrentDictionary and its factory
ConcurrentDictionary<TKey, TValue> from System.Collections.Concurrent allows reading and writing from many threads at once. Its most important
method is GetOrAdd(key, factory): if the key exists, it returns the value, otherwise it calls the factory and stores the result.
There is a trap in it, and Microsoft documents it: the factory runs outside the inner locks. If several threads ask at the same time
for the same missing key, each one calls the factory; only one result is stored, the others are discarded.
// 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 $"free times for {day}";
}));
Console.WriteLine($"GetOrAdd with factory: factory calls: {plainCalls}, entries: {plain.Count}");
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 $"free times for {day}";
})).Value);
Console.WriteLine($"GetOrAdd with Lazy<string>: factory calls: {lazyCalls}, entries: {lazy.Count}");
GetOrAdd with factory: factory calls: 32, entries: 1
GetOrAdd with Lazy<string>: factory calls: 1, entries: 1
Thirty-two database queries for one value. If the factory is cheap and has no side effects, that does not matter. If it is expensive or changes
something, for example because it creates a row or sends a mail, it is a bug. The way out is a Lazy<T> as the value: GetOrAdd then creates
at most a few cheap Lazy wrappers, one of which wins, and all threads get .Value from the same wrapper, which runs the factory
exactly once. Why Lazy<T> can do this safely is explained by lesson 3.11.7.
The same applies to AddOrUpdate: the function that does the changing can run several times under contention. And ConcurrentDictionary protects every single
call, not a sequence of calls. if (!dict.ContainsKey(k)) dict[k] = v; is a race even with ConcurrentDictionary; for that there are
GetOrAdd and TryAdd.