A Dictionary<TKey,TValue> is not safe for two threads at once. ConcurrentDictionary lets many threads read and write the map without your own lock. Thread-safe means the map operations will not corrupt the buckets. It does not run your factory once, expire old values, or put tenant id in the key.
Multi-tenancy means many customers share one app; each must only see their data. Thread-safe is not tenant-safe.
New to this → stay here. Merging a PR → wrong vs right. On-call / interview → GetOrAdd twice · tenant keys · if an interviewer asks.
Terms used here: Factory = the lambda GetOrAdd calls to create a missing value. Stampede = many threads miss the same key at once. Single-flight = only one expensive load runs; others wait for that result. IMemoryCache = cache with size and TTL; use it when you need expiration.
Smallest example
var map = new ConcurrentDictionary<string, int>();
map.TryAdd("a", 1);
map.TryGetValue("a", out var n);
You do not wrap TryGetValue in your own lock “to be safe.”
GetOrAdd: the factory is not atomic
Microsoft’s docs: the valueFactory runs outside the dictionary lock so unknown code cannot block every writer.
Thread A: miss → OpenConnection(id) ──────────────────► discard (leaked socket)
Thread B: miss → OpenConnection(id) → wins insert
var map = new ConcurrentDictionary<string, SqlConnection>();
var connection = map.GetOrAdd(tenantId, id =>
{
// Can run twice for the same id under a stampede
return OpenConnection(id);
});
Do not put side effects, logging “initialized,” or HttpClient.Send in that factory.
Single-flight with Lazy
var map = new ConcurrentDictionary<string, Lazy<FeeSchedule>>();
FeeSchedule GetSchedule(string tenantId) =>
map.GetOrAdd(
tenantId,
id => new Lazy<FeeSchedule>(() => LoadFromSql(id)))
.Value;
You may still allocate extra Lazy<T> objects. Only one LoadFromSql runs per key with default LazyThreadSafetyMode.ExecutionAndPublication. .Value blocks other threads until the winner finishes.
Advanced: Lazy<Task<T>> — a faulted task stays cached until you remove the key. Do not capture a request CancellationToken into the factory (first caller cancels → everyone shares the canceled task).
Wrong vs right
I would reject a key of "fees" in a multi-tenant app and List.Add on a value stored in the map.
// Wrong — last clinic wins
map.GetOrAdd("fees", _ => Load(clinicId));
// Right
map.GetOrAdd($"{clinicId}:{version}", _ => Load(clinicId, version));
var report = map.GetOrAdd(key, _ => new Report());
report.Lines.Add(line); // List<T> is not thread-safe
AddOrUpdate vs GetOrAdd
Comparison: GetOrAdd versus AddOrUpdate versus TryAdd.
| Method | Use when |
|---|---|
GetOrAdd | Read-through: return existing or insert |
AddOrUpdate | Insert or replace using an update delegate |
TryAdd / TryUpdate / TryRemove | You need to branch on success |
AddOrUpdate’s delegates can also run more than once. Treat them as pure. For a hot counter, Interlocked is cheaper.
Tenant keys and unbounded growth
A key of "fees" is thread-safe and still a cross-tenant leak. Include tenant id (and a version) in the key. Thread-safe is not tenant-safe.
A singleton dictionary grows until the process dies. IMemoryCache with size limits is the product cache. ConcurrentDictionary is for coordination: single-flight, idempotency keys, “is this message in flight.”
FrozenDictionary for read-mostly maps
A fee schedule loaded at startup and never mutated is not a ConcurrentDictionary problem. using System.Collections.Frozen; then ToFrozenDictionary — faster reader, not a concurrent writer. If ops publish a new schedule, build a new frozen map and swap the field.
Common mistakes
- Factory that hits SQL with a request-scoped
DbContextcaptured into a singleton map - Caching
HttpContextor AsyncLocal user into a static dictionary - Using
Countin a hot loop
What this is not
Broader primitives: lock vs Monitor. Staff-loop cache questions: expert C# interviews. Topic map: async & threading hub.
If an interviewer asks
Thread-safe collections; why Singleton services must be thread-safe; GetOrAdd running twice.
Strong answer: “Thread-safe structure, not a cache policy. Factory can run twice. Keys include tenant.”
. Bring the key shape and the DI lifetime of the dictionary.