Module 1 · Memory and ownership in modern C# · Lesson 2 of 2
Safe ArrayPool<byte> Ownership and Finally-Based Returns
Watch
Learning outcome
You will be able to use ArrayPool<byte> without accidentally treating a rented array like owned storage. The key interview idea is that a rented buffer has a temporary owner responsible for its return; it is not permanent storage. You must track the portion that contains valid data, avoid reading past that length, and return the buffer in a finally block so it goes back to the pool even if parsing, encoding, or validation throws.
Intuition
A pool is a shared shelf of reusable arrays. Rent gives you a shelf item temporarily; it does not transfer permanent ownership. The array may be larger than requested, may contain old data, and can be handed to another caller after you return it. That means the safe mental model is:
- the array reference is the container,
- the
lengthyou tracked is the truth,
- the
finallyblock is the checkout desk.
If you forget the length, you might print stale bytes or parse garbage. If you forget finally, an exception can prevent reuse and increase allocation pressure; an unreachable managed array can still be collected. If you keep using the array after Return, your code may appear to work in tests but break later when another part of the program reuses the same buffer.
Deep dive
The most important rule is that ArrayPool<byte> optimizes reuse, not safety. A returned array is still a normal managed array, but your code should assume it becomes fair game for the pool as soon as you call Return. That means you should not cache the array globally, hand it to unrelated code that may outlive the scope, or keep a Span<byte>/Memory<byte> view and use it after returning.
For interview answers, distinguish between capacity and valid content. Rent(n) guarantees at least n elements of capacity, but not exact size. Code should therefore write the number of bytes produced and pass that count forward. For binary data, prefer writing into a caller-provided destination and returning the count. If a rented array is transferred to a caller, the API must explicitly transfer the obligation to return it to the same pool exactly once.
The safest pattern in application code is:
- rent in the narrowest scope possible,
- write or fill the buffer,
- use only the slice that is valid,
- clear required data before relinquishing the buffer,
- return in
finally.
Clearing is a policy choice. Return(buffer, clearArray: true) clears an array when the pool retains it; a pool may discard an array. If sensitive bytes must be erased regardless of pool retention, clear those bytes explicitly before returning the array. Choose clearing from the data’s sensitivity and the trust boundary; another renter’s correct behavior is not a confidentiality guarantee.
Failure modes
Common mistakes interviewers like to probe:
- Assuming exact length: a rented buffer can be larger than requested.
- Using old tail bytes: if you only wrote 12 bytes into a 64-byte rent, the other 52 bytes are uninitialized from your perspective and may contain leftovers.
- Unclear asynchronous ownership: retaining a rented array across an await can be valid if ownership stays exclusive and the array is returned only after all asynchronous consumers finish. Do not preserve a span across an await boundary; an async consumer can receive
Memory<byte>with a bounded lifetime.
- Missing
finally: exceptions skip normal cleanup unless you guard the return.
- Use-after-return: the array can be reused by someone else, so post-return access is a logic bug even though the runtime will not stop you.
A useful interview phrase is: “I return the buffer in finally because the pool is a shared resource, and I only trust the slice that I explicitly wrote.”
Interview drill
Try answering these aloud:
- Why does
Rent(1024)not mean the returned array has length 1024?
- What is the difference between owning a
byte[]and borrowing one fromArrayPool<byte>?
- Why is
finallythe correct place to return a rented buffer?
- When would you pass
clearArray: truetoReturn?
- Why is it unsafe to keep using a buffer after returning it?
Revision checklist
- [ ] I track the number of valid bytes separately from the array length.
- [ ] I can identify the temporary owner and its return obligation.
- [ ] I return every rent in a
finallyblock.
- [ ] I do not use the buffer after returning it.
- [ ] I know when clearing the buffer is necessary.
- [ ] I can explain that
Rentgives capacity, not exact size.
Worked pooled-buffer example
The example uppercases ASCII letters, rejects ! and non-ASCII input, and returns an independent string. It is an ownership demonstration, not an allocation-free text API. The optional clearWritten policy resets this method’s written prefix before return.
Code walkthrough
The ASCII contract is enforced before each cast. ASCII occupies U+0000 through U+007F; a cast is not Unicode encoding. The counter advances after each successful write, so BAD! has a three-byte prefix and A\u0100 has a one-byte prefix when validation fails. Null input is rejected before renting. The returned string reads only written bytes. Non-ASCII production text needs an explicit encoding and sufficient byte capacity.
Keep all consumers inside the lease unless the API explicitly transfers responsibility. The sample performs no diagnostic output inside cleanup. Its clearing option covers only bytes it wrote, including a partially written prefix; it does not erase the source string, result string or unrelated copies. This is a data-handling demonstration using non-sensitive fixtures, not a complete secret-erasure design.
Always return an array to the same pool exactly once, after its final consumer finishes. Pooling is a performance choice that should be justified by measurement; it does not remove normal ownership and data-handling responsibilities.
Executable code examples
Return rented buffers in finally and track valid length
Program.cs
using System;
using System.Buffers;
using System.Text;
Show("mixed-case", "Hello");
Show("invalid", "BAD!");
Show("non-ascii", "A\u0100");
Show("empty", "");
static void Show(string name, string input)
{
try
{
Console.WriteLine($"{name}: [{PooledTransform.Transform(input)}]");
}
catch (FormatException ex)
{
Console.WriteLine($"{name}: {ex.Message}");
}
}
public static class PooledTransform
{
public static string Transform(string text, ArrayPool<byte>? pool = null,
bool clearWritten = false)
{
ArgumentNullException.ThrowIfNull(text);
pool ??= ArrayPool<byte>.Shared;
byte[] buffer = pool.Rent(text.Length);
int written = 0;
try
{
foreach (char c in text)
{
if (c > '\u007F')
throw new FormatException($"Non-ASCII character after {written} byte(s).");
if (c == '!')
throw new FormatException($"Invalid character after {written} byte(s).");
char upper = c is >= 'a' and <= 'z' ? (char)(c - ('a' - 'A')) : c;
buffer[written] = (byte)upper;
written++;
}
return Encoding.ASCII.GetString(buffer, 0, written);
}
finally
{
if (clearWritten)
buffer.AsSpan(0, written).Clear();
pool.Return(buffer);
}
}
}Worked interview answers
-
Rent(1024)requests a minimum capacity. Record the produced count separately.
- With a rent, one component has the temporary return obligation. A normal array has no pool-return contract.
- In this example, validation can exit early. The return remains in the cleanup path on success and on that exception.
-
clearArray: truerequests clearing for reuse. If erasure must occur even when the pool discards an array, explicitly clear the relevant bytes before returning.
- After return, another renter can overwrite the storage. Old array, span and memory aliases no longer have permission to use it.
Solved exercise: partial writes and cleanup
Call Transform("BAD!", pool, clearWritten: true) using a test pool that provides twelve bytes filled with 0x7E. The operation writes B, A, D, then throws. Inside the pool’s Return hook, a copied snapshot must have three leading zero bytes followed by nine unchanged 0x7E bytes. There is one rent and one return, with the exact array returned to that pool. The caller must not inspect the actual returned array to make this check.
Solved exercise: an asynchronous consumer
A caller rents one buffer and starts an asynchronous consumer that is paused at an explicit gate. Should the caller return the buffer as soon as it receives the task?
No. The task may still read the buffer. Keep the lease until that consumer finishes, then return the array exactly once in finally. In a test, the return count should be zero while the gate is closed and one after the awaited operation completes, faults or is canceled. Requesting cancellation alone is not completion. This is a reasoning exercise; the runnable listing above is synchronous.
Run and check
Use a .NET 10 console project with C# 14. Put the complete listing in Program.cs. Save the project file below as PooledBuffers.csproj. Expected output:
mixed-case: [HELLO] invalid: Invalid character after 3 byte(s). non-ascii: Non-ASCII character after 1 byte(s). empty: []
Project file
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>14.0</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>Run dotnet run --project PooledBuffers.csproj -c Release in that directory.
Primary references
ArrayPool Rent; ArrayPool Return; Buffer lifetime guidance; ASCII encoding contract.
Optional video
Microsoft .NET: Stephen Toub and Scott Hanselman build an array pool to explain reuse and implementation trade-offs. Start at 17:54 for the pooling implementation discussion. This September 2024 recording is optional background; keep the ownership and cleanup rules in this lesson when using a pool.