Memory & Garbage Collection · Lesson 25

Pinned lifetimes and the pinned-object heap

Separate strong ownership, pin duration, POH allocation and native pointer validity.

Explore the interactive Memory Lab

Change owners, step through generations, pinning and finalization, or compare memory under load. The model explains concepts; the SDK examples measure real runtime observations.

Open the Memory Lab ↗
.NET 10 SDK / desktop CoreCLR

Read or download the complete SDK project, then follow its setup on your computer. The separate C# companion practice below runs in SharpRune’s .NET 9 console environment.

Pinning is a movement constraint

Managed references normally let the runtime update object locations when movable objects are relocated. Native code holding a raw address cannot follow an arbitrary relocation automatically. A pin constrains movement for the relevant storage while that address must remain valid. It does not mean the program has freed the object, reduced its size or transferred its ownership to native code. Separate the owner keeping storage alive from the lease that requires its address to stay fixed.

Pinning is not a generic performance optimization. A long-lived pin can constrain compaction around otherwise movable objects. The effect depends on placement, duration, collection overlap and workload. A very short pin that ends before a collection can have a different cost from many scattered long-lived pins. Measure the real operation instead of declaring every pin harmless or every pin catastrophic.

Existing objects and POH allocation

Pinning an already allocated array does not migrate it to the pinned-object heap. Moving an object simply because the caller wants its current address fixed would itself require coordination and movement, undermining the operation's purpose. CoreCLR instead exposes allocation-time pinning APIs for suitable scenarios. An array allocated pinned can be placed in POH from its creation rather than moving an existing object there later.

POH still participates in managed lifetime management. An allocation-pinned array is not immortal, and retaining a pin is not equivalent to leaking a native allocation forever. A normal managed owner can keep it live; when the required strong ownership ends, a collection including the appropriate heap can reclaim it. POH avoids mixing those deliberately pinned allocations with ordinary movable storage, but it does not remove allocation cost, fragmentation concerns or the need for a bounded pool.

An address needs a complete lease

A native asynchronous API can return before it finishes using a buffer. Releasing the pin immediately after submission can therefore be incorrect even if the buffer remains strongly reachable. The native consumer needs both valid lifetime and stable address until its completion or acknowledged cancellation boundary. A cancellation request alone may not establish that the native side has stopped accessing memory.

Design one owner for the handle and one explicit terminal path. Success, failure during submission, cancellation before submission and cancellation after acceptance can have different obligations. Free the handle exactly once after the consumer is finished. Avoid copying ownership-bearing handle structs casually: two copies that both believe they own a release can create a double-free bug. SafeHandle and purpose-built wrappers can encode resource-specific release policy, but they still need a correct completion contract.

Trace the bounded portable pin

The portable program allocates four bytes and creates one pinned GCHandle. Inside try, IsAllocated is true and writing the first element changes it to 42. The finally block frees the owned handle. The following check reports released:True. The ordinary buffer is explicitly kept alive afterward so the code distinguishes ending the pin from ending managed ownership.

The example never obtains a raw pointer and never calls native code. It therefore demonstrates a bounded handle lifetime without pretending to validate a native protocol. Its output does not identify a physical heap address or prove POH placement. Pinning the existing byte array is precisely the operation that must not be described as migration to POH. If you add a pointer experiment, keep it in a suitable desktop environment and preserve the lease.

Inspect the toy arena and real counters

The pinning lab uses conceptual slots. A dead temporary creates a hole before a pinned buffer, and a live movable buffer starts farther away. Step through collection with the pin active, then reset and remove the pin. The toy compactor packs only within pin-separated regions. That rule is deliberately visible and is not a claim that CoreCLR uses the exact same placement strategy or slot sizes.

The SDK experiment compares an existing pinned array with an allocate-pinned array and prints actual generation and pinned-count observations. Generation 2 alone is not proof that an object is in POH because ordinary Gen 2 and LOH objects can also report that generation. PinnedObjectsCount is a collection observation, not an exact inventory of every address lease at the instant of printing. Use a matching-runtime diagnostic tool for region attribution.

Design a native-buffer pool

A pinned pool can amortize allocation and avoid repeated movement constraints on ordinary storage, but it retains memory even when traffic falls. Bound count and capacity, track leases in flight, and return buffers only after native consumption ends. If a pool grows during a burst, specify whether and when unused capacity can shrink. A pool with no maximum simply replaces repeated allocation pressure with high-water-mark retention.

For an interview, draw four separate concepts: the managed owner, the pin or allocation-pinned storage, the native consumer and the completion signal. Explain which event permits each resource to be released. Discuss how an error before native acceptance differs from an error after acceptance. A strong answer protects correctness first, then proposes a representative benchmark for throughput, pause behavior and retained memory under the expected concurrency.

Practice and review

Move the buffer write after Free in the portable program and explain why managed indexing remains valid while the buffer is still owned. Then explain why the same conclusion would not permit using a previously obtained native pointer after its pin lease ended. Reachability, managed access and raw-address validity are different questions.

In the region scenario, compare pinning an existing SOH object conceptually with allocating the POH buffer. Remove the POH owner's root and collect only Gen 0, then Gen 2. Finish with a cancellation timeline for a native upload: allocate, pin, submit, cancel request, receive completion acknowledgement, unpin and return to pool. Identify the exact point at which reusing that storage becomes safe.

.NET 10 SDK / desktop CoreCLR

Pinned lifetimes and the pinned-object heap — CoreCLR experiment

Set up the SDK project

  1. Install .NET 10 SDK and unzip the project into a new directory.
  2. Read README.md and run dotnet run -c Release --project MemoryLab.csproj in this isolated process.
  3. Record the runtime, effective configuration and full output. Repeat the same workload; numeric observations are not universal expected values.

Download complete SDK project ↗

Project files

MemoryLab.csprojDownload file ↗
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net10.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable></PropertyGroup></Project>
Program.csDownload file ↗
using System.Runtime.InteropServices;
var ordinary = new byte[1024];
var allocatedPinned = GC.AllocateArray<byte>(1024, pinned: true);
var handle = GCHandle.Alloc(ordinary, GCHandleType.Pinned);
try { Console.WriteLine($"existing-generation:{GC.GetGeneration(ordinary)};allocate-pinned-generation:{GC.GetGeneration(allocatedPinned)};handle:{handle.IsAllocated}"); }
finally { handle.Free(); }
GC.Collect(2, GCCollectionMode.Forced, blocking: true);
var info = GC.GetGCMemoryInfo();
Console.WriteLine($"snapshot:{info.Index};pinned-observed:{info.PinnedObjectsCount}");
Console.WriteLine("POH placement is an allocation policy; generation 2 alone is not proof of region.");
GC.KeepAlive(ordinary); GC.KeepAlive(allocatedPinned);
README.mdDownload file ↗
# Pinned lifetimes and the pinned-object heap

Environment: .NET 10 SDK / desktop CoreCLR

Run `dotnet run -c Release --project MemoryLab.csproj` with .NET 10 SDK.

This is an isolated desktop CoreCLR experiment. It may deliberately request bounded collections for observation. Never add these forced collection calls to a production request path. Numeric counters, generations, addresses and RSS vary. The portable SharpRune program is separate.

Primary references:
https://devblogs.microsoft.com/dotnet/internals-of-the-poh/
https://learn.microsoft.com/en-us/dotnet/api/system.gc.allocatearray?view=net-10.0
https://learn.microsoft.com/en-us/dotnet/api/system.runtime.interopservices.gchandle.free?view=net-10.0

Expected result

The bounded program exits successfully and prints genuine CoreCLR observations. Counts, generations, heap sizes and timings vary; compare ownership invariants and matching observation windows rather than hardcoded measurements.

Use the listed SDK and dependencies for this project. Open C# practice runs the separate portable exercise.

C# companion practice

using System.Runtime.InteropServices;
var buffer = new byte[] { 4, 8, 15, 16 };
var handle = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
    Console.WriteLine($"handle:{handle.IsAllocated}");
    buffer[0] = 42;
    Console.WriteLine($"first:{buffer[0]}");
}
finally { handle.Free(); }
Console.WriteLine($"released:{!handle.IsAllocated}");
GC.KeepAlive(buffer);
Open C# practice

C# practice output

handle:True
first:42
released:True
Try this
Trace ownership before inferring collection. Expected portable output: handle:True first:42 released:True

Further reading

Interview questions for this lesson