📌 Pinned Object Heap (POH) — Why pinning matters and how POH helps

TL;DR: Pinning an object tells the GC “don’t move this” — which is useful for interop but terrible for heap compacting. The Pinned Object Heap (POH) isolates pinned objects so the moving GC can keep doing its job without tripping over nails. This post explains pinning, shows examples, demonstrates how POH helps, and gives practical advice.

Why we need pinning at all (short)

The GC in .NET is a moving collector. It regularly relocates short-lived objects to compact the heap and reduce fragmentation, which keeps allocation fast and memory usage tight. But sometimes native code or low-level APIs need a stable memory address — a pointer that won’t change mid-use. That’s when you pin an object.

Pinning is the runtime equivalent of telling the GC “Hey, pause the moving — this object is nailed to the floor.” It’s useful and sometimes necessary (interop, fixed buffers, GCHandle.Alloc with Pinned), but if you nail too many things down the GC can’t tidy up the room anymore.

Imagine a janitor trying to rearrange furniture in a room full of people glued to the floor. The janitor gets frustrated. The janitor is the GC.

What goes wrong when you pin too much

  • Fragmentation: Pinned objects prevent object movement, leaving empty gaps between live objects. Over time, the heap can look like an uneven patchwork — lots of free space scattered in tiny pieces. That wastes memory and can prevent large allocations (you might have free bytes but not in one contiguous chunk).
  • Forced full/stop-the-world collections: If the GC can’t find a way to complete a normal compacting collection because of pins, it can fall back to heavier collections or introduce longer pauses.
  • Performance cliffs: Long-running services that pin large objects can see increasing pause times and eventual OOMs even when nominal memory use doesn’t look excessive.

These problems are most visible when you allocate many large objects (LOH) or hold pins for a long time. Historically, pins + LOH were a nasty combo.

Enter POH: what it is and what it does

POH stands for Pinned Object Heap. The idea is simple and brilliant: allocate pinned objects in a dedicated, non-moving heap separate from the regular moving heaps. That way, the GC can move and compact the normal heaps without being blocked by pinned objects.

Key benefits:

  • Fewer compaction-related pauses caused by pinned objects.
  • Reduced fragmentation of the moving heaps (the ones that benefit most from compaction).
  • Lower probability of expensive fallback GCs when interop-heavy code pins memory.

POH doesn’t magically make pinning free — pinned objects still can’t move — but it isolates the damage.

When POH helps most

  • Your app does native interop and frequently pins buffers (e.g., to call native APIs or when using fixed/GCHandle.Alloc with Pinned).
  • You see long GC pauses correlated with interop calls or long-lived pins.
  • You allocate and hold large buffers (e.g., large images, pooled buffers) and observe fragmentation or OOMs even though overall memory looks reasonable.

If the above matches you, POH will likely reduce pause variability and fragmentation.

Examples: pinning in C#

Example 1 — pinning with fixed (stack-based pinned pointer):

byte[] buffer = new byte[1024 * 1024]; // 1 MB
fixed (byte* p = buffer)
{
    // Safe to pass p to native code here — the buffer is pinned for the duration of this block.
    NativeDoSomething(p, buffer.Length);
}

This is safe and short-lived. fixed pins only for the scope of the fixed block — good.

Example 2 — pinning with GCHandle (longer-lived pin):

var handle = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
    IntPtr ptr = handle.AddrOfPinnedObject();
    // pass ptr to native code; but watch out — the buffer stays pinned until you free the handle.
}
finally
{
    handle.Free();
}

Pitfall: forgetting to call Free() (or keeping the handle alive longer than needed) pins the object longer than necessary.

Example 3 — the stealthy pin: ArrayPool<T>.Shared.Rent() + MemoryMarshal.GetReference

 // Rent a large buffer from the shared ArrayPool
byte[] buffer = ArrayPool<byte>.Shared.Rent(1024 * 1024); // 1 MB
try
{
    // Get a ref to the first element (used for unsafe pinning)
    ref byte first = ref MemoryMarshal.GetReference(buffer.AsSpan());

    // Pin the buffer
    var handle = GCHandle.Alloc(buffer, GCHandleType.Pinned);

    try
    {
        // Convert to an unmanaged pointer
        unsafe
        {
            byte* ptr = (byte*)Unsafe.AsPointer(ref first);
            Console.WriteLine($"Pinned buffer at address 0x{(ulong)ptr:X}");
        }

        // ❌ The subtle problem:
        // You are keeping this buffer pinned across an 'await'
        // The GC cannot move this object for the entire duration of this async operation
        await Task.Delay(5000); // simulate async work

        Console.WriteLine("Still pinned after async delay!");
    }
    finally
    {
        // Unpin the buffer
        handle.Free();
    }
}
finally
{
    // Return the buffer to the pool
    ArrayPool<byte>.Shared.Return(buffer);
}

If you use pooling and then pin buffers, be careful to avoid scenarios where pooled buffers remain pinned across async waits or for long-lived operations. Pin duration matters more than pin frequency.

Diagnostics — how to detect pinning problems

Start with dotnet-counters and dotnet-trace (or PerfView). Watch for:

  • % Time in GC spikes and long GC pause durations. +- GC pause events with reasons pointing at pinned objects (trace details vary by runtime).
  • LOH growth and fragmentation signs: rising gc-heap-size while allocations are not obvious.

Quick command to watch live counters (Windows/PowerShell):

dotnet-counters monitor -p <PID> System.Runtime --refresh-interval 1

Capture a short trace when you observe a pause:

dotnet-trace collect --process-id <PID> --providers Microsoft-Windows-DotNETRuntime:0x1:5 -o trace.nettrace --duration 00:00:20

Open the trace in PerfView or Visual Studio to inspect GC start/end events and pause reasons. If you see pinned-object related entries, inspect code paths where pinning occurs.

Programmatic checks: GC.GetGCMemoryInfo() and GC.CollectionCount(...) can help you correlate events in logs with GC behavior.

How to avoid making the GC sad (best practices)

  1. Pin for as short as possible. Prefer fixed blocks over long-lived GCHandle pins.
  2. Use Span<T> / Memory<T> and ArrayPool<T> to reduce allocations and the need for pins.
  3. Don’t pin pooled buffers across async/await points. Pins across awaits are a common footgun.
  4. If you must hold pins, document and centralize the code that does it so it’s easily audited.
  5. Consider copying to a native buffer when the native API expects a long-lived pointer — that avoids pinning your managed heap.

Short code example: use ArrayPool<T> + Span<T> and pin only briefly:

var pool = ArrayPool<byte>.Shared;
byte[] buffer = pool.Rent(1024 * 1024);
try
{
    // work with Span — no pin yet
    Span<byte> span = buffer.AsSpan(0, 1024);

    // Pin only when calling native API and for the shortest time possible:
    fixed (byte* p = &span.GetPinnableReference())
    {
        NativeDoSomething(p, span.Length);
    }
}
finally
{
    pool.Return(buffer);
}

That pattern minimizes pin lifetime and reuses buffers.

POH is not a magic wand — but it helps a lot

Even with POH in the runtime, you should still follow the best practices above. POH reduces the pain from occasional pins and isolates pinned objects, but it doesn’t eliminate the cost of pinning. Think of POH like a padded isolation room for the nailed-down furniture — the janitor (GC) can clean the main room more easily, but the isolation room still has immovable objects.

Small reproducible example (make the problem, measure the fix)

I like having a tiny repro: allocate many small objects, then occasionally pin a subset for a long time and see how the heap behaves. Here’s a minimal pattern you can port into a console app: +

// Pseudocode outline — expand this into a Console app for experiments.
var live = new List<byte[]>();
var rand = new Random();

for (int i = 0; i < 10000; i++)
{
    // allocate many medium/large objects — some survive, some die
    var arr = new byte[100 * 1024]; // 100 KB => LOH in many runtimes
    if (rand.NextDouble() < 0.1)
        live.Add(arr);

    if (i % 1000 == 0)
    {
        Console.WriteLine($"Iteration {i}, live={live.Count}");
        Thread.Sleep(50);
    }
}

// Now pin some of the live arrays and hold them for a while (simulate interop)
var handles = new List<GCHandle>();
foreach (var b in live.Take(200))
{
    handles.Add(GCHandle.Alloc(b, GCHandleType.Pinned));
}

Console.WriteLine("Pinned some buffers — observe GC behavior with dotnet-counters/trace");

// Hold for a while to observe pauses...
Thread.Sleep(TimeSpan.FromSeconds(30));

foreach (var h in handles)
{
  h.Free();
}

Run dotnet-counters while this runs and watch gc-heap-size and % time in GC. On runtimes without POH the pauses and fragmentation will be worse, on runtimes with POH you should see fewer compaction-induced pauses.