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

📚 Interview question

How do MemoryPool<T> and IMemoryOwner<T> work, and when would you use them instead of ArrayPool<T>? Explain renting and disposing owners, why the buffer can be larger than requested, ownership transfer between components, how System.IO.Pipelines uses a memory pool, writing a custom pool or MemoryManager<T> for native or pinned memory, and common mistakes.

MemoryPool<T> and IMemoryOwner<T>: ownership-based pooling, pipelines and custom memory managers.

Senior Theory Expert buffers imemoryowner memorypool ownership pipelines

🎯 Key points

  • MemoryPool<T>.Rent returns an IMemoryOwner<T> whose Dispose returns the buffer
  • MemoryPool<T>.Shared is backed by ArrayPool<T>.Shared
  • The memory can be larger than requested, so slice to the length used
  • IMemoryOwner<T> makes ownership explicit and transferable through queues or channels
  • After transfer the previous holder must not use or dispose the buffer
  • System.IO.Pipelines rents segments from a MemoryPool and recycles them on AdvanceTo
  • Custom MemoryPool<T> or MemoryManager<T> can expose pinned or native memory as Memory<T>
  • Prefer ArrayPool<T> for local buffers and MemoryPool<T> when buffers cross components

⏱ 30-second 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>.

🗣 Full answer (senior)

How I would answer this in an interview

MemoryPool<T> rents memory as an IMemoryOwner<T>: a disposable object that owns a block and exposes it as Memory<T>. Disposing the owner returns the memory. Compared with ArrayPool<T> (raw arrays, manual Return), it makes ownership explicit and transferable, works with Memory<T> APIs (async, pipelines), and can be backed by non-array memory (native, pinned). I'd explain renting/disposing, why the buffer can be larger, ownership transfer, how Pipelines uses pools, custom pools/MemoryManager<T>, and common mistakes.

1. Renting and disposing owners

using IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(minBufferSize: 4096);
Memory<byte> memory = owner.Memory;               // may be larger than 4096
int read = await stream.ReadAsync(memory, ct);
Process(memory.Span[..read]);
// owner.Dispose() at end of scope returns the memory to the pool
  • MemoryPool<byte>.Shared is backed by ArrayPool<byte>.Shared (each owner wraps a rented array).
  • After Dispose(), owner.Memory must not be used — the shared implementation throws ObjectDisposedException when accessing Memory after dispose, but slices you already took are just views and won't protect you.

2. Why the buffer can be larger than requested

Rent(minBufferSize) guarantees at least that size. The underlying ArrayPool uses power-of-two buckets, so Rent(1000) typically returns 1024. Rent() with no size (-1) returns a default size (often 4 KB for the shared pool). Always track the used length and slice; never assume memory.Length == requested.

3. Ownership transfer between components

The main reason to use IMemoryOwner<T>: it makes who must dispose explicit:

public sealed record Frame(IMemoryOwner<byte> Owner, int Length) : IDisposable
{
    public ReadOnlyMemory<byte> Data => Owner.Memory[..Length];
    public void Dispose() => Owner.Dispose();
}

// Producer: rents and transfers ownership
var owner = MemoryPool<byte>.Shared.Rent(size);
int len = Fill(owner.Memory.Span);
await channel.Writer.WriteAsync(new Frame(owner, len), ct);   // producer no longer owns it

// Consumer: takes ownership and disposes
await foreach (var frame in channel.Reader.ReadAllAsync(ct))
    using (frame) await HandleAsync(frame.Data, ct);

Rules (from .NET's memory usage guidelines):

  • The holder of an IMemoryOwner<T> is responsible for disposing it exactly once.
  • Passing Memory<T> (not the owner) to a method lends it for the call only.
  • Passing IMemoryOwner<T> transfers ownership; the sender must not use or dispose it afterwards.
  • Document ownership in API names/comments (e.g. TakeOwnership).

With raw ArrayPool arrays, the same discipline relies on convention and is easier to get wrong.

4. How System.IO.Pipelines uses a memory pool

  • Pipe is created with PipeOptions(pool: MemoryPool<byte>.Shared, minimumSegmentSize: 4096, ...).
  • The writer calls GetMemory(sizeHint) → the pipe rents segments from the pool as needed; you write and call Advance(n) then FlushAsync().
  • The reader gets a ReadOnlySequence<byte> spanning one or more segments; calling AdvanceTo(consumed, examined) lets the pipe return fully consumed segments to the pool.
  • Kestrel uses a specialised pinned block memory pool (PinnedBlockMemoryPool, 4 KB blocks on the Pinned Object Heap) so socket I/O doesn't need repeated pinning.

The pipe owns all buffers; you never dispose them yourself — you only must not keep references after AdvanceTo.

5. Custom pools and MemoryManager<T> for native or pinned memory

public sealed unsafe class NativeMemoryManager : MemoryManager<byte>
{
    private byte* _ptr; private readonly int _length;
    public NativeMemoryManager(int length) { _length = length; _ptr = (byte*)NativeMemory.Alloc((nuint)length); }
    public override Span<byte> GetSpan() => new(_ptr, _length);
    public override MemoryHandle Pin(int elementIndex = 0) => new(_ptr + elementIndex);   // already fixed
    public override void Unpin() { }
    protected override void Dispose(bool disposing) { if (_ptr != null) { NativeMemory.Free(_ptr); _ptr = null; } }
}

Memory<byte> memory = new NativeMemoryManager(64 * 1024).Memory;   // Memory<T> over native memory
  • MemoryManager<T> lets Memory<T> represent native, memory-mapped, pinned or GPU-mapped memory.
  • A custom MemoryPool<T> can hand out IMemoryOwner<T>s backed by such managers or by POH arrays (GC.AllocateArray<byte>(n, pinned: true)), useful for long-lived native I/O where pinning cost matters.
  • Custom pools should be thread-safe, bounded, and ideally detect misuse in debug builds.

6. When to use which

Situation Use
Short-lived synchronous scratch buffer in one method ArrayPool<T> (or stackalloc if small)
Buffer crosses await or components, ownership must be clear MemoryPool<T> / IMemoryOwner<T>
Streaming I/O protocols System.IO.Pipelines (pool managed for you)
Native/pinned memory exposed as Memory<T> MemoryManager<T> (+ custom pool)
Pooled growable buffers RecyclableMemoryStream, pooled IBufferWriter<T>

7. Common mistakes

  • Use after dispose via previously obtained Memory<T>/Span<T> slices.
  • Double dispose of owners (the shared implementation tolerates it, custom ones may not; logic is wrong either way).
  • Leaking owners (never disposed) — the pool allocates new arrays; memory pressure grows.
  • Assuming exact length.
  • Holding pipe buffers after AdvanceTo.
  • Disposing an owner you lent out while the borrower still uses it.
  • Sensitive data not cleared (the shared pool doesn't clear on return).

8. A production example

A telemetry ingestion service passed ReadOnlyMemory<byte> slices of ArrayPool buffers from a socket reader to a parsing Channel, returning the arrays right after enqueuing — causing sporadic corrupted records. We switched to IMemoryOwner<byte> frames with explicit ownership transfer (consumer disposes), and later migrated the socket reading to System.IO.Pipelines with the parser working on ReadOnlySequence<byte> and copying out only the fields it kept. Corruption disappeared and throughput rose ~25% thanks to fewer copies.

🔍 What the interviewer is checking

The interviewer checks advanced buffer management knowledge for high-throughput services.

Green flags

  • Explains IMemoryOwner<T> as disposable ownership and transfer rules.
  • Slices to the used length and knows buffers may be larger.
  • Knows how Pipelines uses pools and AdvanceTo.
  • Mentions custom pools or MemoryManager<T> for native memory.

Red flags

  • Treats Memory<T> as an owner.
  • Disposes owners twice or never.
  • Has never seen Pipelines while claiming high-performance networking experience.

📚 Learn in depth

1. MemoryPool<T>.Shared implementation

ArrayMemoryPool<T> rents from ArrayPool<T>.Shared and wraps the array in an ArrayMemoryPoolBuffer (IMemoryOwner<T>). Memory throws ObjectDisposedException after disposal; Dispose returns the array once (subsequent calls no-op). MaxBufferSize is Array.MaxLength. Each owner is a small allocation — cheaper than allocating the buffer, but not free.

2. MemoryManager<T> contract

  • GetSpan() — returns the full span.
  • Pin(int) / Unpin() — provide a stable pointer (no-op for native memory; GCHandle-based for managed arrays).
  • TryGetArray(out ArraySegment<T>) — optional, for array-backed managers.
  • Memory property — creates a Memory<T> referencing the manager.
  • Dispose(bool) — releases the underlying memory.

Because Memory<T> stores a reference to the manager, the manager must remain valid while any Memory<T> is used.

3. Pipelines back-pressure and segments

PipeOptions include pauseWriterThreshold/resumeWriterThreshold for back-pressure, minimumSegmentSize, schedulers, and useSynchronizationContext. Segments are linked BufferSegments holding IMemoryOwner<byte> from the pool, returned when consumed.

4. Kestrel's pinned block pool

Kestrel allocates 4 KB blocks on the Pinned Object Heap (since .NET 5) for socket buffers, avoiding pin/unpin overhead per I/O and SOH fragmentation. It's internal, but shows the pattern for custom pools serving native I/O.

5. Diagnosing pool problems

  • Track outstanding owners in debug builds (counts, stack traces).
  • Use dotnet-counters to watch allocation rates (leaking owners cause allocations to climb).
  • Poison memory on dispose in test pools to expose use-after-dispose.

Try it yourself

  1. Rent 1000 bytes from MemoryPool<byte>.Shared and print Memory.Length.
  2. Access owner.Memory after dispose and observe the exception; then use a pre-taken slice and observe no exception (the danger).
  3. Implement the Frame ownership-transfer pattern with a Channel.
  4. Write a minimal PipeReader loop and observe segments returned after AdvanceTo.

💻 Code

// Console apps need the System.IO.Pipelines package; ASP.NET Core includes it
using System.Buffers;
using System.IO.Pipelines;
using System.Text;
using System.Threading.Channels;

public readonly record struct Message(IMemoryOwner<byte> Owner, int Length) : IDisposable
{
    public ReadOnlySpan<byte> Data => Owner.Memory.Span[..Length];   // slice: Memory may be larger
    public void Dispose() => Owner.Dispose();                          // returns the buffer to the pool
}

public static class Program
{
    public static async Task Main()
    {
        // 1. Ownership transfer through a channel
        var channel = Channel.CreateBounded<Message>(capacity: 16);

        var producer = Task.Run(async () =>
        {
            foreach (string text in new[] { "order:1", "order:2", "order:3" })
            {
                IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(64);
                int length = Encoding.UTF8.GetBytes(text, owner.Memory.Span);
                await channel.Writer.WriteAsync(new Message(owner, length)); // consumer now owns it
            }
            channel.Writer.Complete();
        });

        await foreach (Message message in channel.Reader.ReadAllAsync())
        {
            using (message)                                                   // consumer disposes
                Console.WriteLine(Encoding.UTF8.GetString(message.Data));
        }
        await producer;

        // 2. Pipelines: the pipe rents and returns segments for you
        var pipe = new Pipe(new PipeOptions(pool: MemoryPool<byte>.Shared, minimumSegmentSize: 4096));
        Memory<byte> memory = pipe.Writer.GetMemory(32);
        int written = Encoding.UTF8.GetBytes("hello pipelines\n", memory.Span);
        pipe.Writer.Advance(written);
        await pipe.Writer.FlushAsync();
        await pipe.Writer.CompleteAsync();

        ReadResult result = await pipe.Reader.ReadAsync();
        Console.Write(Encoding.UTF8.GetString(result.Buffer));               // ReadOnlySequence<byte>
        pipe.Reader.AdvanceTo(result.Buffer.End);                             // lets the pipe recycle segments
        await pipe.Reader.CompleteAsync();

        using IMemoryOwner<byte> big = MemoryPool<byte>.Shared.Rent(1000);
        Console.WriteLine(big.Memory.Length >= 1000);                        // True: often 1024
    }
}

⚠ Common mistakes

1. Using memory after disposing the owner

What happens: slices kept after Dispose. Why it is wrong: the memory may be reused by others — corruption. Do instead: finish using all slices before disposing.

2. Leaking owners

What happens: IMemoryOwner<T> never disposed. Why it is wrong: pool can't reuse buffers; allocations climb. Do instead: using, or explicit ownership transfer with disposal by the consumer.

3. Ambiguous ownership

What happens: both producer and consumer dispose, or neither does. Why it is wrong: double return or leaks. Do instead: document and enforce transfer semantics.

4. Assuming exact sizes

What happens: using memory.Length as data length. Why it is wrong: buffers are often larger than requested. Do instead: track used length.

5. Holding pipe buffers after AdvanceTo

What happens: storing ReadOnlySequence slices beyond the read loop iteration. Why it is wrong: segments return to the pool. Do instead: copy out what you need.

6. Using MemoryPool for tiny synchronous buffers

What happens: owners for small local scratch space. Why it is wrong: extra allocation for the owner object. Do instead: stackalloc or ArrayPool.

7. Custom managers without proper disposal

What happens: native memory managers not disposed. Why it is wrong: native memory leaks invisible to the GC. Do instead: deterministic disposal and finaliser-safe SafeHandle patterns.

8. Not clearing sensitive data

What happens: owners returned with secrets in memory. Why it is wrong: next renter may see them. Do instead: clear spans before disposing.

🏛 Senior insight: trade-offs

MemoryPool<T> and IMemoryOwner<T> exist to make buffer ownership a type, which is the missing piece in ArrayPool<T>. In a system where buffers move between producers, queues and workers, that clarity prevents the use-after-return bugs that otherwise appear under load.

Design question Answer
Does the buffer outlive the method? pass an IMemoryOwner<T>
Is data streaming from a socket or file? use Pipelines and let it manage buffers
Do you need special memory (pinned, native)? custom MemoryPool<T> or MemoryManager<T>
Is it a short local buffer? ArrayPool<T> or stackalloc

The senior perspective is that pooling is an architecture decision with ownership rules that must be documented, enforced in types and tested, not a local micro-optimisation.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate compares with ArrayPool. Why: ArrayPool misuse is what owners prevent.
    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.

  2. When: Candidate uses owner.Memory. Why: Memory<T> is the view an owner exposes.
    How to 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.

  3. When: Candidate parses ReadOnlySequence segments. Why: Parsing pipeline data uses spans.
    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.

🛠 Real-world problems: how you handle them

Channel-based ingestion corrupted messages until owners were passed
Situation
An [IoT ingestion] service read device messages into ArrayPool arrays and wrote them to a Channel processed by [8] workers.
Signals
About [1 in 20,000] messages failed checksum validation in workers, only at peak load.
Investigation
Found the reader returned arrays to the pool after WriteAsync to the channel, assuming the worker had copied the data; a poisoning pool in a load test reproduced the failure.
Resolution
Changed the channel item to a Message record holding IMemoryOwner<byte> and length; workers dispose after processing, and shutdown drains and disposes remaining items.
Prevention
A counting pool exported outstanding-buffer metrics to dashboards, plus integration tests with the poisoning pool.

How to tell it (STAR)

**Situation:** our [IoT ingestion service] occasionally saw corrupted device messages at peak times. **Task:** I needed to find the cause and make the pipeline robust. **Action:** I found the reader returned pooled arrays while workers still processed them. I redesigned the channel to carry IMemoryOwner<byte> so the worker owned and disposed each buffer, added draining on shutdown and an outstanding-buffer metric. **Result:** checksum failures dropped to [zero], and the outstanding-buffer graph gave us an early warning for leaks.

↩ Usually asked after

More in Memory-Oriented C#

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me