📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What are `Memory<T>` and `ReadOnlyMemory<T>`, and how do they differ from spans? Explain why they can live on the heap and cross await, how .Span works, slicing, getting at the underlying array with MemoryMarshal.TryGetArray, ownership and lifetime rules, and how they appear in Stream, PipeReader and System.Text.Json APIs.

Memory<T>/ReadOnlyMemory<T>: heap-storable buffers for async code, ownership and API usage.

Mid-Level Theory Advanced async buffers memory span streams

🎯 Key points

  • Memory<T> and ReadOnlyMemory<T> are normal structs, so they can be stored in fields and used across await
  • They view arrays, strings (read-only) or MemoryManager<T> memory, never the stack
  • Use .Span for synchronous processing and get it once outside loops
  • Slicing memory is cheap and does not copy
  • MemoryMarshal.TryGetArray exposes the underlying array segment without copying
  • Memory is a view, not an owner; the renter or allocator controls lifetime
  • IMemoryOwner<T> expresses ownership transfer and is disposed to release the buffer
  • Modern Stream, Socket and Pipelines APIs take Memory<byte> and return ValueTask

⏱ 30-second answer

Memory<T> and ReadOnlyMemory<T> are ordinary structs (not ref structs) that describe a slice of an array, a string (read-only), or memory from a MemoryManager<T>. Because they never point at the stack, they can be stored in fields, captured in lambdas and used across await, which spans cannot. You call .Span in synchronous code to do the actual work. Slicing is cheap and does not copy. MemoryMarshal.TryGetArray gets the underlying ArraySegment<T> for older APIs. Memory does not own anything: whoever rented or allocated the buffer owns it, so a Memory<T> over a pooled array is invalid once that array is returned. Async I/O APIs (Stream.ReadAsync(Memory<byte>), Socket, PipeReader, JsonSerializer with UTF-8) use them.

🗣 Full answer (senior)

How I would answer this in an interview

Memory<T> and ReadOnlyMemory<T> represent the same kind of contiguous region as spans, but they're ordinary structs, not ref structs — so they can be stored on the heap, kept in fields, captured by lambdas and passed across await. When you need to work with the data, you get a span via .Span. I'd explain why they exist, .Span, slicing, MemoryMarshal.TryGetArray, ownership and lifetime rules, and where they appear in Stream, PipeReader and System.Text.Json.

1. Why they can live on the heap and cross await

Span<T> holds a managed pointer that may point to the stack, so it must stay on the stack. Memory<T> instead holds an object reference (an array, a string, or a MemoryManager<T>) plus an index and length — nothing that can dangle into a stack frame. So it can be:

public sealed class Message(ReadOnlyMemory<byte> body) { public ReadOnlyMemory<byte> Body { get; } = body; }  // field: OK

public async Task<int> ReadIntoAsync(Stream stream, Memory<byte> buffer, CancellationToken ct)
{
    int read = await stream.ReadAsync(buffer, ct);   // Memory<T> crosses await
    Process(buffer.Span[..read]);                    // span used synchronously
    return read;
}

2. How .Span works

memory.Span inspects the underlying object:

  • T[] → span over the array segment,
  • string (for ReadOnlyMemory<char>) → span over the string's chars,
  • MemoryManager<T> → calls its GetSpan() (native memory, pinned buffers, custom pools).

This involves a type check and a few instructions — cheap, but not free: in loops, get the span once (var span = memory.Span;) and index the span.

3. Slicing

ReadOnlyMemory<byte> packet = buffer.AsMemory(0, length);
ReadOnlyMemory<byte> header = packet[..8];
ReadOnlyMemory<byte> payload = packet.Slice(8);

Slicing is O(1) and allocation-free (adjusts index/length), like spans.

4. Getting at the underlying array: MemoryMarshal.TryGetArray

Some older APIs need an ArraySegment<T>/array:

if (MemoryMarshal.TryGetArray(memory, out ArraySegment<byte> segment))
    legacySocket.Send(segment.Array!, segment.Offset, segment.Count);
else
    legacySocket.Send(memory.ToArray());   // fallback copy (e.g. native memory)

TryGetArray succeeds only for array-backed memory. MemoryMarshal.TryGetString does the same for string-backed ReadOnlyMemory<char>. Never assume the backing store is an array.

5. Ownership and lifetime rules

Memory<T> doesn't own the memory — it's a view. The important questions are who owns the buffer and how long the view is valid:

  • Owner (IMemoryOwner<T> from MemoryPool<T>, an ArrayPool rental, a PipeReader buffer) controls lifetime; when it's disposed/returned, every Memory<T>/Span<T> over it becomes invalid (but still "works" — reading reused or freed memory: silent corruption).
  • Rules (from .NET's memory guidelines):
    • A method receiving Memory<T> borrows it for the duration of the call; it mustn't store it beyond that unless the API documents ownership transfer.
    • If you store a Memory<T>, the caller must guarantee the buffer outlives your use (or transfer ownership via IMemoryOwner<T>).
    • Whoever owns an IMemoryOwner<T> must dispose it exactly once, after all consumers are done.
  • With PipeReader, the ReadOnlySequence<byte> from ReadAsync is valid only until AdvanceTo is called.

6. Where they appear in the BCL

  • Stream: ReadAsync(Memory<byte>, CancellationToken) and WriteAsync(ReadOnlyMemory<byte>, CancellationToken) (returning ValueTask), the efficient async overloads.
  • Sockets: Socket.ReceiveAsync(Memory<byte>, ...).
  • System.IO.Pipelines: PipeWriter.GetMemory(sizeHint) returns Memory<byte> to write into, then Advance(n) and FlushAsync; PipeReader yields ReadOnlySequence<byte> (a chain of ReadOnlyMemory<byte> segments).
  • System.Text.Json: JsonSerializer.Deserialize<T>(ReadOnlySpan<byte>), Utf8JsonReader over ReadOnlySequence<byte>, JsonDocument.Parse(ReadOnlyMemory<byte>) (keeps a reference to the memory, so the buffer must stay valid while the document is used).
  • StringBuilder.GetChunks() yields ReadOnlyMemory<char> chunks.
  • Channel<ReadOnlyMemory<byte>> for passing buffers between producers and consumers (with care for ownership).

7. A production example

A TCP gateway read frames into ArrayPool buffers and passed ReadOnlyMemory<byte> slices to a channel for async processing — but returned the buffer to the pool immediately after enqueuing. Consumers occasionally processed data from other frames (the buffer had been reused), producing corrupted messages. We changed the contract to pass IMemoryOwner<byte> (ownership transfer) through the channel; the consumer disposed it after processing. Corruption disappeared, and the ownership rule was documented on the channel's message type.

🔍 What the interviewer is checking

The interviewer checks whether you know when to use memory types and understand buffer ownership.

Green flags

  • Explains Memory as a heap-safe view usable across await, with .Span for work.
  • Knows TryGetArray and the Stream/Socket/Pipelines APIs.
  • States ownership rules clearly and mentions IMemoryOwner<T>.
  • Spots use-after-return risks with pooled buffers.

Red flags

  • Thinks Memory owns or frees memory.
  • Uses Memory in every synchronous method.
  • Stores views over pooled buffers without thinking about lifetime.

📚 Learn in depth

1. Layout

public readonly struct Memory<T>
{
    private readonly object? _object;   // T[], string (for char), or MemoryManager<T>
    private readonly int _index;        // may encode flags in high bits (e.g. pre-pinned)
    private readonly int _length;
}

16 bytes on 64-bit. Because _object is a normal reference, the GC tracks it like any field.

2. MemoryManager<T>

Abstract class for custom memory (native memory, memory-mapped files, pinned pools): implements GetSpan(), Pin()/Unpin() (returning a MemoryHandle with a pointer), and Dispose. Memory<T>.Pin() pins array-backed memory (via GCHandle) or delegates to the manager — used for async I/O with native APIs.

3. ReadOnlySequence<T>

Represents possibly non-contiguous data as a linked list of ReadOnlyMemory<T> segments (ReadOnlySequenceSegment<T>). Pipelines produce these; parsers use SequenceReader<T> to read across segment boundaries without copying. IsSingleSegment + FirstSpan gives a fast path.

4. ValueTask overloads

Stream.ReadAsync(Memory<byte>) returns ValueTask<int>: when data is already buffered, it completes synchronously without allocating a Task. Prefer these overloads over ReadAsync(byte[], int, int).

5. Choosing span vs memory

Situation Use
Synchronous processing Span<T>/ReadOnlySpan<T>
Crossing await, stored in fields, captured Memory<T>/ReadOnlyMemory<T>
Ownership transfer IMemoryOwner<T>
Non-contiguous buffers ReadOnlySequence<T>

API design: async methods take Memory<T>; their synchronous helpers take spans.

Try it yourself

  1. Store a ReadOnlyMemory<char> slice of a string in a class and read it later via .Span.
  2. Use MemoryMarshal.TryGetArray on array-backed and MemoryPool-backed memory.
  3. Implement a small pipe reader loop using PipeReader, ReadOnlySequence<byte> and AdvanceTo.
  4. Reproduce use-after-return with ArrayPool and a stored Memory<T>, then fix with IMemoryOwner<T>.

💻 Code

using System.Buffers;
using System.Runtime.InteropServices;

public sealed class Chunk(ReadOnlyMemory<byte> data)   // Memory can be a field; Span cannot
{
    public ReadOnlyMemory<byte> Data { get; } = data;
    public int Checksum()
    {
        int sum = 0;
        foreach (byte b in Data.Span) sum = unchecked(sum * 31 + b);  // .Span for synchronous work
        return sum;
    }
}

public static class Program
{
    public static async Task Main()
    {
        using var stream = new MemoryStream(Enumerable.Range(0, 10_000).Select(i => (byte)i).ToArray());

        byte[] rented = ArrayPool<byte>.Shared.Rent(4096);   // the renter owns this array
        try
        {
            Memory<byte> buffer = rented.AsMemory(0, 4096);
            var chunks = new List<Chunk>();
            int read;
            while ((read = await stream.ReadAsync(buffer)) > 0)    // Memory crosses await
            {
                // Copy before keeping: the pooled buffer is reused on the next read
                chunks.Add(new Chunk(buffer[..read].ToArray()));
            }
            Console.WriteLine($"{chunks.Count} chunks, first checksum {chunks[0].Checksum()}");

            if (MemoryMarshal.TryGetArray<byte>(buffer, out ArraySegment<byte> segment))
                Console.WriteLine($"array-backed: offset {segment.Offset}, count {segment.Count}");
        }
        finally
        {
            ArrayPool<byte>.Shared.Return(rented);           // after this, 'buffer' must not be used
        }

        string text = "NSW,VIC,QLD,WA";
        ReadOnlyMemory<char> region = text.AsMemory(4, 3);   // slice of a string, no copy
        Console.WriteLine(region.ToString());                // VIC
    }
}

⚠ Common mistakes

1. Using Memory<T> after the buffer is returned

What happens: storing slices of pooled buffers beyond their lifetime. Why it is wrong: silent data corruption. Do instead: clear ownership rules; transfer IMemoryOwner<T> when needed.

2. Calling .Span in tight loops

What happens: memory.Span[i] inside loops. Why it is wrong: repeated type checks. Do instead: get the span once.

3. Assuming array backing

What happens: casting or reflecting to get the array. Why it is wrong: memory may be native or string-backed. Do instead: MemoryMarshal.TryGetArray with a fallback.

4. Holding PipeReader buffers after AdvanceTo

What happens: keeping slices of the read sequence. Why it is wrong: the pipe may reuse the memory. Do instead: copy data you need to keep before advancing.

5. Using spans in async signatures

What happens: trying async Task Foo(Span<byte> buffer). Why it is wrong: not allowed. Do instead: take Memory<byte> and use .Span synchronously.

6. ToArray() everywhere

What happens: converting memory to arrays to call APIs. Why it is wrong: copies and allocations. Do instead: use memory/span overloads.

7. Forgetting to dispose IMemoryOwner<T>

What happens: rented memory never returned. Why it is wrong: pool depletion and more allocations. Do instead: using the owner.

8. JsonDocument over transient buffers

What happens: parsing a pooled buffer and returning it while the document is still used. Why it is wrong: the document references the buffer. Do instead: keep the buffer alive until the document is disposed, or deserialise to objects.

🏛 Senior insight: trade-offs

Memory types are where performance code meets ownership design. The type system does not track who owns a buffer, so the team has to, through conventions and types:

Intention Type to pass
"Use this during the call" ReadOnlySpan<T> / Span<T>
"Use this until the returned task completes" ReadOnlyMemory<T> / Memory<T>
"You own this now and must release it" IMemoryOwner<T>
"Keep a copy" an array or immutable collection

A senior engineer documents these rules on public APIs and is suspicious of any Memory<T> stored in a field next to an ArrayPool call. Most pooled-buffer production bugs are lifetime bugs, not performance bugs.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate calls .Span. Why: Memory hands work to spans via .Span.
    How to answer

    Span<T> is a ref struct holding a reference and a length: a typed, bounds-checked view over contiguous memory, whether that memory is an array, a stackalloc buffer, native memory or part of a string (through ReadOnlySpan<char>). Slicing (span[2..6], Slice) creates a new view without copying, so parsers and formatters can work on pieces of a buffer with no allocations. Because it can point at the stack, it is stack-only: it cannot be a field of a class, be boxed, be captured by a lambda, or live across await/yield (C# 13 allows span locals in async and iterator methods as long as they do not cross those points). Helpers in MemoryExtensions (IndexOf, SequenceEqual, Sort, Trim) are vectorised. CollectionsMarshal.AsSpan(list) exposes a list's backing array, but adding to the list invalidates it.

  2. When: Candidate talks about who owns the buffer. Why: IMemoryOwner<T> expresses ownership of pooled memory.
    How to answer

    MemoryPool<T> rents buffers as IMemoryOwner<T>: an IDisposable object whose .Memory property is the buffer and whose Dispose() returns it. That makes ownership explicit: whoever holds the owner must dispose it, and you can transfer it (through a channel, a queue or a method) so the receiver disposes. MemoryPool<T>.Shared is backed by ArrayPool<T>.Shared. The memory may be larger than requested, so slice to the length you use. Use it over ArrayPool<T> when buffers cross components or async boundaries or when you want to swap the memory source. System.IO.Pipelines gets segments from a MemoryPool<byte>; Kestrel uses its own pinned block pool for sockets. A custom MemoryManager<T> can expose native or pinned memory as Memory<T>.

  3. When: Candidate wraps ArrayPool arrays in Memory<T>. Why: Pooled buffers behind Memory<T> cause lifetime bugs.
    How to answer

    Pooling bugs are lifetime bugs: the pool gives a returned array to the next renter, so any code still using it reads or writes someone else's data. The usual causes are use after return (returning in finally while an async continuation, a queued Memory<T> or a lazy enumerable still uses the array), double return (two renters then share one array), treating array.Length as the data length (Rent returns an array at least as large as requested, with old contents), not clearing buffers that held secrets (Return(array, clearArray: true)), and returning arrays not rented from that pool. To debug: reproduce under concurrency, add canaries or a poisoning wrapper pool in tests that fills returned arrays with a marker and detects double returns, trace System.Buffers.ArrayPoolEventSource events, and review every rent for a single owner and a single return in finally.

🛠 Real-world problems: how you handle them

Corrupted uploads from a pooled buffer reused too early
Situation
A document upload service read chunks into an ArrayPool buffer and queued ReadOnlyMemory<byte> slices to a background virus-scan and storage worker.
Signals
Intermittent checksum mismatches on stored files under load, never in single-user testing; about [0.3%] of uploads affected.
Investigation
Added checksums at enqueue and dequeue time and saw them differ; code review showed the buffer was returned to the pool right after enqueueing.
Resolution
Changed the queue item to carry an IMemoryOwner<byte> from MemoryPool, disposed by the worker after storage; slices were copied only when small.
Prevention
Unit test with a pool that poisons returned buffers, and a review guideline that Memory<T> crossing threads must travel with its owner.

How to tell it (STAR)

**Situation:** our [document upload service] stored a small fraction of files with corrupt content, only under load. **Task:** as the service owner I had to find and fix it before an audit. **Action:** I added checksums at each stage, proved the data changed inside our queue, and found we returned a pooled buffer while the worker still held a Memory slice of it. I changed the queue to pass IMemoryOwner<byte> so the worker owned and released the buffer, and added a poisoning test pool. **Result:** corruption dropped to [zero] across [two million] uploads and the ownership rule is now part of our code review checklist.

↩ Usually asked after

More in Memory-Oriented C#

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me