The Curious Case of .NET ConcurrentDictionary and Closures

While working through the Duende Software codebase recently, I kept running into the same IDE suggestion whenever a ConcurrentDictionary showed up: “Closure can be eliminated: method has overload to avoid closure creation.” The odd part was that there was no quick fix action attached to it, just a warning with no obvious way to resolve it.

That sent me digging into what the analyzer was actually flagging and how to fix it properly. This post walks through what closures are, why they can be a problem inside GetOrAdd, and how to rewrite your code to use the overload that avoids the allocation entirely.

What is a closure

If you have used an Action, a Func, a delegate, or LINQ, you have already used a closure without necessarily naming it. A closure happens when a lambda captures a variable from outside its own scope, turning that lambda into something closer to an object instance that carries state with it, rather than a plain stateless function.

Here is a minimal example of capture in action.

void SayHello(string name)
{
    var hello = () =>
    {
        // name is captured causing an allocation
        // and potential concurrency issues
        Console.WriteLine($"Hello {name}");
    };
    hello();
}

The compiler has to capture the name parameter so the hello lambda still has access to it whenever it eventually runs. That capture is convenient to write, but it is not free, and it can quietly cause three separate problems.

  • Extra allocations. The compiler generates a hidden class to hold the captured variables, and every closure creation allocates an instance of it. In a hot path, that adds measurable GC pressure.
  • Mutable shared state. If the captured value is a reference type, anything with access to it can change it after the closure was created, which leads to behavior that is hard to reason about, especially under concurrency.
  • Memory retention. A closure that outlives its expected scope keeps its captured references alive too, which can turn into a leak if the closure itself is long lived.

The fix, in general, is to avoid capturing external state and instead pass everything the lambda needs as an explicit argument.

void SayHello(string name)
{
    var hello = (string n) =>
    {
        Console.WriteLine($"Hello {n}");
    };
    hello(name);
}

This version does not capture name from the outer scope at all. The parameter n is passed in explicitly when hello is invoked, so there is nothing for the compiler to hold onto behind the scenes, and no hidden allocation.

Where GetOrAdd runs into this

ConcurrentDictionary.GetOrAdd is a common place this pattern shows up, because the value factory is almost always a lambda, and it is easy to close over a local variable without noticing.

using System.Collections.Concurrent;
 
ConcurrentDictionary<string, Item> concurrentDictionary
    = new();
 
var key = "khalid";
var value = "awesome";
 
var result = concurrentDictionary.GetOrAdd(key, (k) => {
    Console.WriteLine($"Building {k}");
    return new Item(value, DateTime.Now);
});
 
Console.WriteLine(result);

Look closely at the lambda passed to GetOrAdd. The parameter k is fine since it comes from the delegate signature, but value is pulled in from the enclosing scope. That is the closure the analyzer is warning about, and it is easy to miss because the code reads naturally and compiles without any hint that an extra allocation is happening on every call.

In a single threaded context this rarely matters. Under real concurrency, where multiple threads are racing to add the same key, GetOrAdd may invoke the factory more than once even though only one result is stored, which multiplies the cost of every closure allocation and any side effects inside it.

The overload that avoids the closure

ConcurrentDictionary exposes a lesser known overload of GetOrAdd that takes the state as a separate factoryArgument parameter instead of relying on capture. Here is the same logic rewritten to use it, with parameter names added for clarity.

using System.Collections.Concurrent;
 
ConcurrentDictionary<string, Item> concurrentDictionary = new();
 
var key = "khalid";
var value = "awesome";
 
var result = concurrentDictionary.GetOrAdd(
    key: key,
    valueFactory: (k, arg) =>
    {
        Console.WriteLine($"Building {k}");
        return new Item(arg, DateTime.Now);
    },
    factoryArgument: value);
 
Console.WriteLine(result);
 
record Item(string Value, DateTime Time);

This overload accepts three arguments: the key to look up, a value factory that now takes both the key and the extra argument as parameters, and the factoryArgument itself, which is value in this case. Because value is passed explicitly instead of captured, the compiler no longer needs to generate a closure class for the lambda, and GetOrAdd calls it directly with the state it needs.

If your factory genuinely needs more than one external value, the factoryArgument parameter only accepts a single object, so you will need to wrap those values in a small record or tuple and pass that as the single argument. It is a bit more ceremony than a plain closure, but it keeps the allocation explicit and under your control instead of implicit and easy to overlook.

Is this worth doing everywhere

For code that runs once at startup or is not on a hot path, the closure allocation is genuinely negligible, and rewriting every GetOrAdd call for this reason alone is not a good use of time. Where it does matter is in caching layers, request pipelines, or any code that calls GetOrAdd frequently under concurrent load, since that is exactly where the extra allocations and duplicate factory invocations add up.

It is also worth remembering that GetOrAdd does not guarantee the factory runs only once per key under contention. Two threads can both miss the cache and both invoke the factory, with only one result winning and getting stored. If your factory has side effects, such as the Console.WriteLine calls in these examples, or something more expensive like a database call, that behavior matters more than the closure allocation itself, and the fix for it is different: consider Lazy-wrapped values or a dedicated caching library if you need single execution guarantees.

The practical takeaway is simple. If you see the closure warning on a ConcurrentDictionary call in a path that actually runs under load, switch to the three parameter overload and pass state explicitly. If you see it in cold, rarely executed code, it is safe to leave as is.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading