return input.Split(',')
.Select(s => int.Parse(s))
.ToList();
}
// GOOD: Zero allocations using Span<T>
public void ParseNumbersReadOnly(ReadOnlySpan<char> input, Span<int> output, out int count)
{
count = 0;
while (!input.IsEmpty && count < output.Length)
{
if (int.TryParse(input, out int value, out int charsConsumed))
{
output[count++] = value;
input = input.Slice(charsConsumed);
// Skip delimiter
if (!input.IsEmpty)
input = input.Slice(1);
}
else
{
// Handle error or skip invalid char
input = input.Slice(1);
}
}
}
**Architecture Decision:**
* **Use `Span<T>`** when processing data in hot paths, especially parsing or transformation logic.
* **Constraint:** `Span<T>` cannot be stored as a field in a class. It is strictly for stack-bound operations. If you need to store state, use `Memory<T>` or `ArraySegment<T>`, but be aware these are reference types and carry allocation overhead.
### 2. `ArrayPool<T>` for Variable-Size Buffers
When you need a buffer whose size varies or exceeds stack limits, `ArrayPool<T>` allows you to rent and return arrays, drastically reducing Gen0 pressure.
**Implementation:**
```csharp
using System.Buffers;
public async Task ProcessPayloadAsync(byte[] payload)
{
// Rent a buffer. If size > 85KB, this may still hit LOH,
// but pooling mitigates repeated LOH pressure.
byte[] buffer = ArrayPool<byte>.Shared.Rent(payload.Length);
try
{
// Copy data to rented buffer
payload.CopyTo(buffer.AsSpan(0, payload.Length));
// Process buffer...
await TransformAsync(buffer.AsMemory(0, payload.Length));
}
finally
{
// CRITICAL: Always return the buffer.
// Failure to return causes memory leaks and pool starvation.
ArrayPool<byte>.Shared.Return(buffer);
}
}
Architecture Decision:
- Use
ArrayPool<T> for buffers that are frequently allocated and discarded in loops or async methods.
- Security Note: Pooled arrays are not cleared upon return. If handling sensitive data, you must
Array.Clear the buffer before returning it to the pool to prevent data leakage between requests.
3. Structs and in Parameters
Avoiding boxing is essential. Passing structs by value can cause copies; passing by ref allows mutation; passing by in allows read-only access without copying.
// BAD: Struct passed by value causes copy on every call in hot path
public void UpdatePhysics(Vector3 position) { ... }
// GOOD: Read-only reference avoids copy, compiler enforces immutability
public void UpdatePhysics(in Vector3 position) { ... }
4. C# 12 ref readonly and Collections
For collections, avoid List<T> in tight loops where the size is known. Use arrays or Span<T> over arrays. If dynamic sizing is required, consider System.Collections.Generic collections carefully, as Add operations can trigger internal array resizing (allocation).
// Pre-allocate if size is predictable
var list = new List<T>(estimatedCapacity);
// Or use a stackalloc array for small, known sizes (C# 8.0+)
Span<MyStruct> items = stackalloc MyStruct[64];
Pitfall Guide
-
using on Value Types:
- Mistake:
using var span = new Span<int>(...);
- Explanation:
Span<T> does not implement IDisposable. Forcing a using statement on a struct that implements IDisposable can cause boxing if the struct is implicitly converted to IDisposable. Always verify if the type is a ref struct or value type that manages resources natively.
- Best Practice: Use
using only for reference types or structs that explicitly require deterministic cleanup (e.g., FileStream).
-
Closures Capturing Large Objects:
- Mistake: Capturing a large array or class instance in a lambda used in a hot loop.
- Explanation: The compiler generates a closure class. If the lambda is stored or used frequently, the closure instance remains on the heap, keeping the captured object alive longer than necessary and preventing collection.
- Best Practice: Pass data explicitly via parameters or use
struct delegates where possible. Avoid capturing this or large fields in local functions within hot paths.
-
Ignoring the LOH Threshold:
- Mistake: Allocating
byte[100_000] repeatedly.
- Explanation: Objects > 85,000 bytes are allocated on the LOH. LOH collections are expensive. Repeatedly creating large buffers causes LOH fragmentation and frequent full GCs.
- Best Practice: Use
ArrayPool<T> for large buffers. For objects > 85KB that must be allocated, consider GCSettings.LargeObjectHeapCompactionMode if fragmentation is critical, but prefer pooling.
-
String Concatenation in Loops:
- Mistake:
str += "data" inside a loop.
- Explanation: Strings are immutable. Each concatenation creates a new string object. While the C# compiler may optimize simple loops into
string.Concat, complex logic or conditional appends still generate garbage.
- Best Practice: Use
StringBuilder for general cases. In high-performance paths, use Span<char> with stackalloc or write directly to a rented buffer using Utf8Formatter.
-
Abusing GC.Collect():
- Mistake: Calling
GC.Collect() to "free memory" after a batch job.
- Explanation: Manual collection forces the GC to run regardless of need, often promoting objects to higher generations prematurely and disrupting the GC's heuristics. It usually degrades throughput and increases latency.
- Best Practice: Never call
GC.Collect() in production code unless you have a specific, measured reason (e.g., after a massive load test teardown). Rely on the GC's adaptive algorithms.
-
Boxing in Generic Collections:
- Mistake: Using
Dictionary<object, object> or non-generic collections.
- Explanation: Storing value types in reference-typed collections causes boxing, creating a heap object for every value.
- Best Practice: Always use generic collections (
Dictionary<TKey, TValue>). If you must store heterogeneous types, use System.Text.Json.Nodes or discriminated unions (records) rather than object.
-
Forgetting to Clear Pooled Arrays:
- Mistake: Returning a buffer with sensitive data to
ArrayPool.
- Explanation: The pool reuses the array. The next renter will see the previous data.
- Best Practice: Implement a wrapper or explicit
Array.Clear for sensitive buffers before returning to the pool.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| High-Frequency Parsing | Span<T> + stackalloc | Zero heap allocation, optimal CPU cache usage. | High Dev Effort, Low Infra Cost. |
| Variable Size Buffers | ArrayPool<T> | Reuses memory, prevents LOH pressure. | Medium Dev Effort, Low Infra Cost. |
| General Business Logic | new + List<T> | Developer velocity, readable code. | Low Dev Effort, Medium Infra Cost. |
| Async Streams | IAsyncEnumerable<T> + yield return | Avoids materializing large collections in memory. | Medium Dev Effort, Low Infra Cost. |
| Sensitive Data Handling | SecureString (Legacy) or Array.Clear + Pool | Prevents data leakage in pooled memory. | Medium Dev Effort, Low Infra Cost. |
Configuration Template
Optimize the runtime configuration for throughput and latency. Add this to your .runtimeconfig.json or csproj:
{
"runtimeOptions": {
"configProperties": {
"System.GC.Server": true,
"System.GC.Concurrent": true,
"System.GC.RetainVM": false,
"System.GC.LatencyMode": "Interactive",
"System.GC.HeapHardLimit": 0,
"System.GC.HeapCount": 0
}
}
}
System.GC.Server: true: Enables Server GC, optimized for multi-core throughput. Essential for backend services.
System.GC.Concurrent: true: Allows GC to run on a background thread, reducing foreground pauses.
System.GC.LatencyMode: "Interactive": (Use cautiously) Reduces Gen2 collection frequency to minimize pauses, suitable for low-latency apps. Default is usually Batch for throughput.
System.GC.RetainVM: false: Returns memory to the OS when possible, reducing memory footprint in containerized environments.
Quick Start Guide
-
Install Tooling:
dotnet tool install -g BenchmarkDotNet.Tool
dotnet tool install -g dotnet-counters
dotnet tool install -g dotnet-gcdump
-
Create Baseline Benchmark:
Write a method simulating your hot path. Use [MemoryDiagnoser] in BenchmarkDotNet to measure allocations.
[MemoryDiagnoser]
public class MemoryBenchmarks
{
[Benchmark]
public void ProcessData() { /* Your logic */ }
}
-
Apply Optimization:
Refactor the method using ArrayPool<T> or Span<T>. Ensure correctness via unit tests.
-
Measure Delta:
Run the benchmark again. Compare Alloc Rate and Gen 0/1/2 collections. Target a reduction in allocations by at least one order of magnitude.
-
Deploy and Monitor:
Deploy to a staging environment. Run dotnet-counters monitor against the process. Verify that allocation rates have dropped and GC pauses have decreased under load.
By systematically applying these patterns, you transform C# from a language where memory is an afterthought to a platform where memory is a deterministic resource you control. This shift is mandatory for building systems that meet modern performance and cost-efficiency requirements.