Memory & Garbage Collection · Lesson 27

Span, Memory and hidden backing-store retention

Expose the difference between a view, a copy, an owner and a valid buffer lease.

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.

A view does not resize its owner

Span and Memory describe access to storage; slicing them does not automatically copy or shrink that storage. A twelve-byte view into a one-megabyte array can keep the entire backing array reachable while the view remains owned. This is often useful for efficient parsing, but it can become accidental retention when a long-lived cache stores small views into large temporary inputs.

Separate the logical length a consumer may read from the physical capacity of the owner. A parser might correctly expose only a short field while retaining a whole network message behind it. The consumer contract can be correct while the lifetime policy is expensive. The appropriate fix may be to copy a small selected field at a long-lived boundary, not to replace every short-lived slice with a new allocation.

Owners, consumers and leases

Memory<T> can be stored in ordinary managed objects and used across asynchronous operations, but it is not itself an ownership-transfer or disposal token. IMemoryOwner<T> expresses a distinct ownership abstraction. A consumer needs a valid lease on the memory for the entire operation, and the owner must not dispose or return backing storage while that lease remains active. Merely keeping a Memory value reachable does not extend a pool's valid lease after Return.

Span<T> is a ref-like view with language restrictions designed around safe lifetimes. Modern C# permits more useful ref-like code in async methods under specific constraints, but a span still cannot simply remain live across an arbitrary suspension point. Do not reduce the rule to an outdated slogan that no async method may ever mention Span, or the equally wrong claim that a heap-backed array makes every span lifetime unrestricted. Follow the compiler's actual restrictions and the API's ownership contract.

Copying can shorten retention

A copy produces independent storage for the selected content. That allocates and copies bytes, which may be worthwhile when a tiny long-lived result would otherwise retain a much larger transient input. Compare total retained capacity and copy throughput under representative lifetimes. Copying every intermediate parser slice can add excessive allocation, while never copying at a persistence boundary can produce a much larger live set.

ReadOnlyMemory means consumers cannot mutate through that particular API surface. It does not make the underlying array globally immutable. Another owner can still change the backing array. If a cached value must remain stable independently of its source, choose an ownership or copying policy that guarantees that stability. Read-only access and immutable storage are different contracts, especially when pooled buffers are reused.

Trace view identity and mutation

The portable program creates a one-megabyte byte array and a twelve-byte ReadOnlyMemory view. MemoryMarshal.TryGetArray extracts the backing array segment because this particular view is array-backed. The first output reports logical view length twelve, backing length 1,048,576 and copy length twelve. Those are real managed identities and lengths, not estimated heap sizes.

Changing source[0] afterward changes what view.Span[0] reads, while the independent copy retains the earlier value. The second line therefore reports view-first:9;copy-first:7. The extracted ArraySegment itself holds an array reference. If you kept it while testing whether copying released the original backing store, the diagnostic variable would create another owner and invalidate that inference. The desktop experiment explicitly clears that segment before changing the cached value.

Inspect the two-owner scenario

In the interactive slice scenario, the cache and the request can both retain the same view. Disable the cache entry while leaving the request root enabled and inspect the backing array's retaining path. Then end the request. The model depicts a box for the view to make the graph legible; it is not a claim that every Memory struct is a separate object with that exact shallow size in a real process.

The SDK program proves that an array-backed twelve-byte view retains a one-megabyte backing array and that ToArray creates a twelve-byte backing store for the copied result. It deliberately does not claim immediate collection or RSS shrinkage. MemoryMarshal.TryGetArray is not a universal operation for every memory implementation: custom memory managers can have different backing storage, so production code must respect its success result and abstraction boundary.

Design an async buffer API

Decide whether the callee borrows a view for the duration of the returned task or takes ownership of a memory owner. Document that distinction. A caller borrowing storage must await completion before returning it to a pool; fire-and-forget work cannot silently retain the view beyond the lease. An ownership-taking API must dispose exactly once on success, failure and cancellation, including failures before work is fully accepted.

For an interview, present a parser that stores small fields from a large response in a cache. Ask whether the values must outlive the request and whether source mutation is possible. A strong design chooses copying or durable ownership at that boundary, measures the cost and avoids double counting shared backing arrays. It does not assume that using ReadOnlyMemory automatically guarantees immutability, small retained size or safe post-return access.

Practice and review

Change the source mutation to an index outside the twelve-byte view and explain why the view's visible contents remain unchanged while its backing ownership still includes the array. Take a slice of the copy and verify its backing length remains twelve, not the smaller logical slice length. These observations separate visibility from ownership.

Write a success/cancellation/error ownership table for an async consumer of a rented array. Mark the last possible access in each path and place Return afterward. Finally, compare a workload retaining one thousand tiny slices with one retaining one thousand tiny copies, recording payload, backing capacity and allocation throughput separately rather than reporting a single unqualified memory number.

.NET 10 SDK / desktop CoreCLR

Span, Memory and hidden backing-store retention — 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;
ReadOnlyMemory<byte> cached = new byte[1_048_576].AsMemory(0, 12);
MemoryMarshal.TryGetArray(cached, out ArraySegment<byte> before);
Console.WriteLine($"view:{cached.Length};backing:{before.Array!.Length}");
// The extracted segment is itself an owner; clear it before discussing release.
before = default;
cached = cached.ToArray();
MemoryMarshal.TryGetArray(cached, out ArraySegment<byte> after);
Console.WriteLine($"copied-view:{cached.Length};backing:{after.Array!.Length}");
Console.WriteLine("This proves backing identity/length, not immediate collection or RSS shrinkage.");
GC.KeepAlive(cached);
README.mdDownload file ↗
# Span, Memory and hidden backing-store retention

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://learn.microsoft.com/en-us/dotnet/standard/memory-and-spans/memory-t-usage-guidelines
https://learn.microsoft.com/en-us/dotnet/api/system.runtime.interopservices.memorymarshal.trygetarray?view=net-10.0
https://learn.microsoft.com/en-us/dotnet/api/system.memory-1.slice?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 source = new byte[1_048_576]; source[0] = 7;
ReadOnlyMemory<byte> view = source.AsMemory(0, 12);
MemoryMarshal.TryGetArray(view, out ArraySegment<byte> segment);
var copy = view.ToArray();
Console.WriteLine($"view:{view.Length};backing:{segment.Array!.Length};copy:{copy.Length}");
source[0] = 9;
Console.WriteLine($"view-first:{view.Span[0]};copy-first:{copy[0]}");
Open C# practice

C# practice output

view:12;backing:1048576;copy:12
view-first:9;copy-first:7
Try this
Trace ownership before inferring collection. Expected portable output: view:12;backing:1048576;copy:12 view-first:9;copy-first:7

Further reading

Interview questions for this lesson