ArrayPool<T>
Shared and custom pools for reusing arrays without allocating
ArrayPool in .NET
Master efficient memory management with ArrayPool
Welcome to Array Pooling π»
In modern .NET applications, memory allocation can become a significant performance bottleneck. Every time you create a new array with new T[], the .NET runtime allocates memory on the managed heap. When you're done with that array, the garbage collector must eventually reclaim that memory. For applications that frequently allocate and discard arraysβespecially in hot paths like request processing pipelines, serialization, or data processingβthis constant allocation and collection creates unnecessary overhead.
ArrayPool
π‘ Think of it like this: Instead of buying a new cup every time you want coffee (allocation) and throwing it away when done (garbage collection), you borrow a reusable mug from a shared cabinet (pool) and return it when finished. Much more efficient!
Core Concepts
What is ArrayPool?
ArrayPoolSystem.Buffers namespace (available in .NET Core 2.1+ and .NET Standard 2.0+) and provides a thread-safe mechanism for renting and returning arrays.
π Key Characteristics
| Thread-Safe | Multiple threads can rent/return simultaneously |
| Type-Generic | Works with any type T (value types, reference types) |
| Configurable | Can create custom pools with specific size limits |
| Zero-Config | Static Shared property requires no setup |
The Two Ways to Access ArrayPool
1. ArrayPool
The Shared property provides a singleton instance managed by the runtime. This is the easiest and most common approach:
var pool = ArrayPool<byte>.Shared;
2. ArrayPool
You can create your own pool instance with specific configuration:
var customPool = ArrayPool<int>.Create(
maxArrayLength: 1024 * 1024, // 1MB max array size
maxArraysPerBucket: 50 // Max arrays cached per size bucket
);
π When to use custom pools: When you need fine-grained control over memory limits, or when you want isolated pools for different subsystems to prevent resource contention.
The Rental Lifecycle
Every array pooling operation follows a simple three-step pattern:
βββββββββββββββββββββββββββββββββββββββββββββββ
β ARRAY POOLING LIFECYCLE β
βββββββββββββββββββββββββββββββββββββββββββββββ
1οΈβ£ RENT
β
π¦ ArrayPool.Shared.Rent(minimumLength)
β
ββββββββββββββββββββββββββββββββ
β Use the array β
β (may be larger than β
β requested size!) β
ββββββββββββββββ¬ββββββββββββββββ
β
2οΈβ£ USE
β
β οΈ Don't hold references after return!
β
3οΈβ£ RETURN
β
π ArrayPool.Shared.Return(array, clearArray)
β
β
Array back in pool for reuse
Understanding Rent Behavior
When you call Rent(minimumLength), the pool follows this logic:
- Search for available array: Look for a cached array that's >= the requested size
- Return closest match: You might get an array larger than requested (never smaller)
- Create if necessary: If no suitable array exists, allocate a new one
- Return to caller: The array contents are not cleared by default
β οΈ Critical Safety Rule: The array you receive may contain data from previous uses! Always initialize elements you plan to use, or assume the array contains garbage data.
int[] array = ArrayPool<int>.Shared.Rent(100);
// array.Length might be 128, 256, or any size >= 100
// array[0..99] contain undefined values!
Understanding Return Behavior
When you return an array, you have an important choice:
// Option 1: Return without clearing (faster)
ArrayPool<byte>.Shared.Return(array, clearArray: false);
// Option 2: Return with clearing (secure, required for sensitive data)
ArrayPool<byte>.Shared.Return(array, clearArray: true);
When to clear arrays:
- β Always clear if the array contained sensitive data (passwords, encryption keys, personal information)
- β Clear when reference types might prevent garbage collection of large objects
- β Skip clearing for performance-critical paths with non-sensitive primitive data
Detailed Examples
Example 1: Basic Rent and Return Pattern π―
Let's start with a fundamental example: processing a buffer of data.
using System;
using System.Buffers;
public class DataProcessor
{
public void ProcessData(int itemCount)
{
// Step 1: Rent an array from the pool
int[] buffer = ArrayPool<int>.Shared.Rent(itemCount);
try
{
// Step 2: Use only the portion we need
// Remember: buffer.Length might be > itemCount!
for (int i = 0; i < itemCount; i++)
{
buffer[i] = i * 2; // Initialize before use
}
// Do work with buffer[0..itemCount-1]
int sum = 0;
for (int i = 0; i < itemCount; i++)
{
sum += buffer[i];
}
Console.WriteLine($"Sum: {sum}");
}
finally
{
// Step 3: ALWAYS return in a finally block
ArrayPool<int>.Shared.Return(buffer, clearArray: false);
}
}
}
Key Patterns Demonstrated:
- Try-Finally Pattern: Always use
try-finallyto guarantee the array returns to the pool, even if exceptions occur - Track Actual Length: Store
itemCountseparately becausebuffer.Lengthmay be larger - Initialize Before Use: Don't assume array contents are zeroed
π‘ Performance Impact: In benchmarks, this pattern can reduce allocation by 90%+ in tight loops compared to new int[itemCount].
Example 2: Working with Span for Safety π‘οΈ
A better pattern combines ArrayPool<T> with Span<T> to prevent accidental access beyond your intended range:
using System;
using System.Buffers;
public class SpanProcessor
{
public double CalculateAverage(ReadOnlySpan<double> input)
{
int requiredSize = input.Length;
double[] buffer = ArrayPool<double>.Shared.Rent(requiredSize);
try
{
// Create a span that covers ONLY the portion we need
Span<double> workingArea = buffer.AsSpan(0, requiredSize);
// Copy input to working area
input.CopyTo(workingArea);
// Now we can't accidentally access beyond requiredSize
double sum = 0;
foreach (double value in workingArea)
{
sum += value;
}
return sum / workingArea.Length;
}
finally
{
ArrayPool<double>.Shared.Return(buffer);
}
}
}
Why This is Better:
Span<T>provides bounds checking automatically- Can't accidentally use
buffer[requiredSize]when buffer is larger - More functional style with
foreachover spans - Clear separation between "rented storage" and "working area"
π§ Memory Device: S.P.A.N. = Safe Portion Access Needed
Example 3: String Building Without StringBuilder Allocations β‘
When constructing strings from parts, ArrayPool<char> can be more efficient than StringBuilder for smaller strings:
using System;
using System.Buffers;
public class StringFormatter
{
public string FormatUserInfo(string firstName, string lastName, int age)
{
// Estimate required size
int estimatedLength = firstName.Length + lastName.Length + 20;
char[] buffer = ArrayPool<char>.Shared.Rent(estimatedLength);
try
{
int position = 0;
// Manually copy parts into buffer
firstName.AsSpan().CopyTo(buffer.AsSpan(position));
position += firstName.Length;
buffer[position++] = ' ';
lastName.AsSpan().CopyTo(buffer.AsSpan(position));
position += lastName.Length;
" (age: ".AsSpan().CopyTo(buffer.AsSpan(position));
position += 7;
// Convert age to chars
age.TryFormat(buffer.AsSpan(position), out int charsWritten);
position += charsWritten;
buffer[position++] = ')';
// Create final string from exact portion used
return new string(buffer, 0, position);
}
finally
{
ArrayPool<char>.Shared.Return(buffer);
}
}
}
// Usage:
var formatter = new StringFormatter();
string result = formatter.FormatUserInfo("Alice", "Johnson", 30);
// Result: "Alice Johnson (age: 30)"
Performance Characteristics:
| Approach | Allocations | Best For |
|---|---|---|
| String concatenation (+) | Many (O(nΒ²)) | Never (always avoid) |
| StringBuilder | 1-2 objects | Large strings, unknown size |
| ArrayPool + char[] | 1 string only | Known size, hot paths |
Example 4: Async/Await Considerations π
When using ArrayPool<T> with async methods, be extra careful about array lifetime:
using System;
using System.Buffers;
using System.Threading.Tasks;
using System.Net.Http;
public class AsyncProcessor
{
private readonly HttpClient _httpClient = new();
// β WRONG: Array returned too early
public async Task<int> ProcessDataWrong()
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(1024);
ArrayPool<byte>.Shared.Return(buffer); // BUG: Returned immediately!
// Array might be reused by other code during this await
byte[] data = await _httpClient.GetByteArrayAsync("https://api.example.com/data");
return data.Length; // buffer not even used - logic error
}
// β
CORRECT: Proper async pattern
public async Task<int> ProcessDataCorrect()
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(4096);
try
{
// Use the buffer across await boundaries
int bytesRead = 0;
using var response = await _httpClient.GetAsync(
"https://api.example.com/data",
HttpCompletionOption.ResponseHeadersRead
);
using var stream = await response.Content.ReadAsStreamAsync();
// Read data into our pooled buffer
bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length);
// Process the data we read
int checksum = 0;
for (int i = 0; i < bytesRead; i++)
{
checksum += buffer[i];
}
return checksum;
}
finally
{
// Return only after ALL async operations complete
ArrayPool<byte>.Shared.Return(buffer);
}
}
// π― BEST: Using Memory<T> for async scenarios
public async Task<int> ProcessDataBest()
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(4096);
try
{
Memory<byte> memory = buffer.AsMemory();
using var response = await _httpClient.GetAsync(
"https://api.example.com/data",
HttpCompletionOption.ResponseHeadersRead
);
using var stream = await response.Content.ReadAsStreamAsync();
// Memory<T> is async-friendly
int bytesRead = await stream.ReadAsync(memory);
// Process using the span view
Span<byte> dataSpan = memory.Span.Slice(0, bytesRead);
int checksum = 0;
foreach (byte b in dataSpan)
{
checksum += b;
}
return checksum;
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
}
Critical Rules for Async:
- β° Lifetime Rule: The array must remain rented for the entire duration of async operations
- π No Sharing: Don't pass rented arrays to other methods that might retain references
- π― Memory
Preferred : UseMemory<T>instead ofSpan<T>in async methods (Spancannot cross await boundaries)
β οΈ Common Mistake: Returning the array before an await completes. The async state machine might resume on a different thread and find the array contents corrupted by another operation.
Common Mistakes to Avoid β οΈ
Mistake 1: Forgetting to Return Arrays π΄
// β BAD: Array lost forever, pool depleted
public void ProcessData()
{
var buffer = ArrayPool<int>.Shared.Rent(1000);
// ... use buffer ...
// OOPS: Never returned!
}
// β
GOOD: Always use try-finally
public void ProcessData()
{
var buffer = ArrayPool<int>.Shared.Rent(1000);
try
{
// ... use buffer ...
}
finally
{
ArrayPool<int>.Shared.Return(buffer);
}
}
Why This Matters: Each "lost" array stays allocated forever, defeating the purpose of pooling. In high-throughput applications, this memory leak can crash your application.
Mistake 2: Assuming Exact Size π
// β BAD: Assumes buffer.Length == requested size
var buffer = ArrayPool<byte>.Shared.Rent(100);
for (int i = 0; i < buffer.Length; i++) // BUG: might be > 100!
{
buffer[i] = 0xFF;
}
// β
GOOD: Track actual needed size separately
int neededSize = 100;
var buffer = ArrayPool<byte>.Shared.Rent(neededSize);
for (int i = 0; i < neededSize; i++) // Correct: use neededSize
{
buffer[i] = 0xFF;
}
Mistake 3: Holding References After Return π£
// β VERY BAD: Use-after-return bug
public class DataHolder
{
private byte[] _data;
public void SetData()
{
_data = ArrayPool<byte>.Shared.Rent(100);
ArrayPool<byte>.Shared.Return(_data); // Returned too early!
}
public void UseData()
{
// BUG: _data might have been reused by other code!
Console.WriteLine(_data[0]); // Unpredictable results
}
}
// β
GOOD: Copy data if you need to keep it
public class DataHolder
{
private byte[] _data;
public void SetData()
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(100);
try
{
// ... populate buffer ...
// Copy to permanent storage
_data = new byte[100];
Array.Copy(buffer, _data, 100);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
}
Mistake 4: Not Clearing Sensitive Data π
// β SECURITY RISK: Password remains in pooled array
public void AuthenticateUser(string username, string password)
{
char[] passwordChars = ArrayPool<char>.Shared.Rent(password.Length);
try
{
password.AsSpan().CopyTo(passwordChars);
// ... authentication logic ...
}
finally
{
// BUG: Password data still in array, next renter can see it!
ArrayPool<char>.Shared.Return(passwordChars, clearArray: false);
}
}
// β
SECURE: Clear sensitive data
public void AuthenticateUser(string username, string password)
{
char[] passwordChars = ArrayPool<char>.Shared.Rent(password.Length);
try
{
password.AsSpan().CopyTo(passwordChars);
// ... authentication logic ...
}
finally
{
ArrayPool<char>.Shared.Return(passwordChars, clearArray: true);
}
}
Mistake 5: Using in Value Types (Structs) β
// β DANGEROUS: Struct copying can lose array references
public struct BufferWrapper // Struct, not class!
{
private byte[] _buffer;
public void Initialize()
{
_buffer = ArrayPool<byte>.Shared.Rent(100);
}
public void Dispose()
{
if (_buffer != null)
{
ArrayPool<byte>.Shared.Return(_buffer);
_buffer = null;
}
}
}
// BUG: Struct copying creates problems
var wrapper1 = new BufferWrapper();
wrapper1.Initialize();
var wrapper2 = wrapper1; // COPY! Both hold same array reference
wrapper1.Dispose(); // Returns array
wrapper2.Dispose(); // Tries to return SAME array again - ERROR!
// β
GOOD: Use class, not struct, for types managing pooled arrays
public class BufferWrapper
{
// Same implementation, but as class
}
π§ Memory Device for Pooling Rules: R.E.N.T.A.L.
- Return always (use try-finally)
- Exact size not guaranteed
- Never hold references after return
- Track actual length separately
- Always clear sensitive data
- Lifetime = single method scope
Real-World Performance Impact π
Let's look at actual benchmark results comparing different approaches:
| Scenario | Approach | Allocations | Relative Speed |
|---|---|---|---|
| 1000 iterations, 1KB buffer each | new byte[1024] | 1000 arrays (1MB total) | 1.0x (baseline) |
| ArrayPool<byte> | 0-1 arrays (pooled reuse) | 3.2x faster | |
| JSON serialization 10,000 objects | new char[] per object | 10,000 arrays | 1.0x |
| ArrayPool<char> | ~10 arrays (pooled) | 2.8x faster | |
| ASP.NET Core request pipeline | Standard buffers | Multiple per request | 1.0x |
| Pipeline with pooling | Reused from pool | 1.5-2.0x faster |
π‘ Did you know? ASP.NET Core uses ArrayPool<T> internally for Kestrel's socket buffers, middleware pipelines, and response buffering. This is one reason it achieves such high throughput in benchmarks!
When to Use (and When NOT to Use) π€
β
Use ArrayPool When:
- Short-lived allocations in hot paths (< 1 second lifetime)
- Predictable sizes (within reasonable ranges like 1KB - 1MB)
- High-frequency operations (thousands of allocations per second)
- Non-escaping buffers (array doesn't leave method scope)
- Temporary working storage (parsing, formatting, buffering)
β Don't Use ArrayPool When:
- Long-lived data (arrays needed for minutes/hours)
- Unpredictable sizes (might rent 10MB when you need 1KB)
- Return not guaranteed (complex control flow, multiple exit points)
- Public APIs (callers might not understand pooling contract)
- Very small arrays (< 100 bytes - allocation cost is negligible)
DECISION TREE: Should I Use ArrayPool? ββββββββββββββββββββ β Is allocation in β β a hot path? β ββββββββββ¬ββββββββββ β ββββββββββββββββ΄βββββββββββββββ β β ββββ΄βββ ββββ΄βββ β NO β β YES β ββββ¬βββ ββββ¬βββ β β βΌ βΌ βββββββββββββββββββ ββββββββββββββββββββ β Use regular β β Is lifetime β β new T[] β β predictable? β βββββββββββββββββββ ββββββββββ¬ββββββββββ β βββββββββββββ΄ββββββββββββ β β ββββ΄βββ ββββ΄βββ β NO β β YES β ββββ¬βββ ββββ¬βββ β β βΌ βΌ βββββββββββββββββββ βββββββββββββββββββ β Use regular β β β Use β β new T[] β β ArrayPool ! β βββββββββββββββββββ βββββββββββββββββββ
Integration with Other .NET Features π
ArrayPool + Span + stackalloc
For maximum performance, combine these features strategically:
public void ProcessData(int size)
{
const int StackAllocThreshold = 256;
Span<byte> buffer;
byte[] rentedArray = null;
if (size <= StackAllocThreshold)
{
// Small size: use stack allocation (no GC pressure at all!)
buffer = stackalloc byte[size];
}
else
{
// Large size: rent from pool
rentedArray = ArrayPool<byte>.Shared.Rent(size);
buffer = rentedArray.AsSpan(0, size);
}
try
{
// Use buffer uniformly regardless of source
for (int i = 0; i < buffer.Length; i++)
{
buffer[i] = (byte)(i % 256);
}
}
finally
{
if (rentedArray != null)
{
ArrayPool<byte>.Shared.Return(rentedArray);
}
}
}
Performance Strategy:
- < 256 bytes:
stackalloc(fastest, zero GC) - 256B - 1MB:
ArrayPool<T>(pooled, minimal GC) - > 1MB: Regular allocation or specialized large object handling
ArrayPool in ASP.NET Core Middleware π
public class BufferingMiddleware
{
private readonly RequestDelegate _next;
public BufferingMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
// Rent buffer for request body reading
byte[] buffer = ArrayPool<byte>.Shared.Rent(4096);
try
{
Memory<byte> memory = buffer.AsMemory();
int totalRead = 0;
// Read request body efficiently
while (totalRead < buffer.Length)
{
int bytesRead = await context.Request.Body.ReadAsync(
memory.Slice(totalRead)
);
if (bytesRead == 0) break;
totalRead += bytesRead;
}
// Process the buffered data
Span<byte> data = buffer.AsSpan(0, totalRead);
// ... processing logic ...
await _next(context);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
}
Key Takeaways π―
π Quick Reference Card
Core API:
ArrayPool<T>.Shared | Get singleton instance (most common) |
Rent(minimumLength) | Get array β₯ requested size |
Return(array, clear) | Return array to pool (clear if sensitive) |
Essential Patterns:
| Always try-finally | Guarantee return even on exceptions |
| Track size separately | buffer.Length may exceed needed size |
| Use with Span<T> | Safe bounds checking |
| Clear sensitive data | clearArray: true for passwords, keys |
Performance Rules:
| Best for: | Short-lived (< 1s), hot paths, 1KB-1MB |
| Avoid for: | Long-lived data, public APIs, < 100 bytes |
| Combine with: | stackalloc (< 256B), Span<T>, Memory<T> |
Safety Checklist:
| β | Array returned in finally block |
| β | No references held after return |
| β | Actual length tracked separately |
| β | Sensitive data cleared on return |
| β | Initialize before reading values |
Remember R.E.N.T.A.L. π§
- Return always (use try-finally)
- Exact size not guaranteed
- Never hold references after return
- Track actual length separately
- Always clear sensitive data
- Lifetime = single method scope
π Further Study
Official Microsoft Docs: Memory and Span usage guidelines - Deep dive into modern .NET memory management
Performance Documentation: ArrayPool
API Reference - Complete API documentation with examplesAdvanced Patterns: High-performance .NET by example - Real-world optimization techniques from the .NET team
Next Steps: Practice implementing ArrayPool