Skip to content

Certainly. Let's dive into a more advanced C# optimization topic. We'll uncover a "hidden cost" that comes with using one of the language's most beloved features: LINQ and Lambda Expressions.


C# Core Optimization - Part 6: The "Hidden Cost" of LINQ - The Closure Trap ​

The Scenario πŸ“ ​

  • System: A data processing method that runs inside a very high-frequency loop (a "hot path").
  • The Problem: A developer notices that the Garbage Collector (GC) is working much more than expected, even though the logic seems simple.

What is a Closure? ​

A closure is a lambda expression (an anonymous function) that "captures" a variable from its surrounding scope.

csharp
int categoryId = 10;
// The lambda `p => p.CategoryId == categoryId` has "captured" the `categoryId` variable.
var products = allProducts.Where(p => p.CategoryId == categoryId);

To make this work, the C# compiler automatically generates a hidden class in the background to store the captured variable.

The Problematic Code (Hidden Heap Allocation) ​

When a closure is created inside a loop, a performance problem can arise.

csharp
public void ProcessProductsByCategories(List<Product> allProducts, List<int> categories)
{
    foreach (var categoryId in categories)
    {
        // MISTAKE: This lambda captures the `categoryId` variable.
        // For each iteration of the loop, a NEW instance of the "hidden class" (the closure)
        // is created on the HEAP.
        var productsInCategory = allProducts.Where(p => p.CategoryId == categoryId);

        // ... process `productsInCategory` ...
    }
}

Analyzing the Bottleneck 🧐 ​

  1. Repeated Heap Allocations: In each loop iteration, the compiler has to create a new instance of the hidden class to hold the categoryId for that specific iteration. If the categories list has 1,000 items, you are creating 1,000 new objects on the Heap.
  2. GC Pressure: These 1,000 objects quickly become garbage. This is the classic type of short-lived garbage that forces the GC's Gen 0 to run constantly, consuming CPU and degrading overall performance.

The Solution: Avoid Closures in Hot Paths βœ… ​

  • The Logic: In extremely performance-sensitive situations, you need to avoid creating closures inside hot loops.

  • The Optimized Code (Manual Loop): The most straightforward way to avoid the closure is to rewrite the logic with a manual foreach loop.

    csharp
    public void ProcessProductsByCategories_Optimized(List<Product> allProducts, List<int> categories)
    {
        // Instead of using LINQ inside the loop, we can group the products first.
        var productsByCategory = allProducts.GroupBy(p => p.CategoryId);
    
        foreach (var group in productsByCategory)
        {
            if (categories.Contains(group.Key))
            {
                // ... process the group (which is productsInCategory) ...
            }
        }
    }

    Another way is to write a nested foreach loop, sacrificing some of LINQ's elegance for maximum performance by not creating any closures at all.

Important Note on EF Core: When you use LINQ with EF Core (IQueryable), this problem usually does not happen. EF Core translates the expression tree into a SQL statement and turns the categoryId variable into a SQL parameter, rather than creating a closure to be executed in memory. This issue primarily affects LINQ to Objects (operating on in-memory collections).

Conclusion:

  • Lambda expressions that capture local variables (closures) can cause hidden memory allocations on the heap.
  • In most cases, this cost is negligible.
  • However, in very high-frequency loops ("hot paths"), it can create significant pressure on the GC.
  • Be aware of this behavior. If you identify a closure in a hot path as a bottleneck using a profiling tool, consider rewriting that logic manually to avoid the memory allocation.