A practitioner's guide to the four .NET primitives that eliminate allocations in hot paths: Span for stack-sliced parsing, Memory for async-compatible slices, System.IO.Pipelines for backpressure-aware streaming, and System.Threading.Channels for producer-consumer concurrency - with a full working service example that combines all four.
The profiler says your service is slow. You look at the flame graph and most of the time is in GC collections, not in your actual business logic. That's the signal that you're allocating more than you need to in the hot path.
Four primitives address this at different layers: Span for synchronous slices without heap allocation, Memory for the same across async boundaries, System.IO.Pipelines for network-level parsing without double-buffering, and System.Threading.Channels for producer-consumer pipelines without locks. This isn't an overview. I'll assume you know why string.Substring in a tight loop is a problem. The question is how to actually use these in a service under load.
Span - zero-allocation slicing
Span is a stack-allocated struct that represents a contiguous region of memory - an array slice, a stack-allocated buffer, or unmanaged memory. Creating a Span from an existing array doesn't allocate. The span points into the original memory.
// This allocates - Substring returns a new string object
string s = "Content-Type: application/json";
string value = s.Substring(14); // "application/json" - heap allocation
// This doesn't
ReadOnlySpan<char> span = s.AsSpan(14); // points into the original string, zero alloc
// Comparison without allocation
bool isJson = span.SequenceEqual("application/json".AsSpan());
The most common use is parsing. HTTP headers, CSV rows, binary protocols - any place where you're slicing strings or byte arrays in a loop. Here's log line parsing that does zero heap allocations per line:
// Parse: "2024-01-15T10:23:41Z INFO user_id=42 action=login"
static (LogLevel level, ReadOnlySpan<char> rest) ParseLevel(ReadOnlySpan<char> line)
{
// Skip the timestamp
int firstSpace = line.IndexOf(' ');
ReadOnlySpan<char> remainder = line.Slice(firstSpace + 1);
int levelEnd = remainder.IndexOf(' ');
ReadOnlySpan<char> levelSpan = remainder.Slice(0, levelEnd);
LogLevel level = levelSpan switch {
_ when levelSpan.SequenceEqual("INFO".AsSpan()) => LogLevel.Info,
_ when levelSpan.SequenceEqual("WARN".AsSpan()) => LogLevel.Warning,
_ when levelSpan.SequenceEqual("ERROR".AsSpan()) => LogLevel.Error,
_ => LogLevel.Unknown
};
return (level, remainder.Slice(levelEnd + 1));
// No allocations - levelSpan and remainder point into the original buffer
}
The hard constraint: Span cannot cross the heap. You can't put it in a field, return it from an async method, or store it in a List>. It lives on the stack only. The moment you cross an async boundary, you need Memory.
// This doesn't compile
async Task<string> ProcessAsync(Span<char> span) // error: Span cannot be used in async method
{
await Task.Delay(1);
return span.ToString();
}
Memory - heap-safe slices for async code
Memory is the heap-storable equivalent of Span. It wraps the same pointer+length concept but can live in fields, async methods, and collections. The pattern: receive data into a Memory buffer, do async work with it, then call .Span inside synchronous sections.
// Memory<T> survives async boundaries
async Task ProcessBufferAsync(Memory<byte> buffer)
{
await SomeAsyncOperation(); // fine - Memory<T> lives on the heap
Process(buffer.Span); // .Span gives you a Span<T> for sync work
}
// MemoryPool<byte> avoids allocating your own buffers - the pool manages them
static async Task ReadAndProcessAsync(Stream stream)
{
using IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(4096);
Memory<byte> buffer = owner.Memory;
int bytesRead = await stream.ReadAsync(buffer);
if (bytesRead == 0) return;
// Slice to actual data - still no allocation
Memory<byte> data = buffer.Slice(0, bytesRead);
ProcessFrame(data.Span); // sync work on the stack-scoped span
} // owner.Dispose() returns the buffer to the pool
MemoryPool.Shared is worth understanding separately. Rent(minBytes) returns an IMemoryOwner whose .Memory is a pre-allocated byte array from a reuse pool. When the owner is disposed, the buffer returns to the pool. No GC pressure for the buffer allocation itself - which matters when you're reading tens of thousands of frames per second.
System.IO.Pipelines - partial reads done right
The problem Pipelines solves: reading framed messages from a network socket. The classic Stream approach has a subtle correctness problem. If a 1000-byte message arrives in two TCP packets (600 bytes then 400 bytes), your Stream.ReadAsync will return 600 bytes on the first call. You need to buffer the incomplete message, wait for the rest, then process the complete frame. Getting this right means maintaining a circular buffer, tracking read/write positions, and handling the case where a single read gives you 1.5 messages. Easy to get wrong under production load.
// The partial-read problem with raw Stream
async Task ProcessConnectionAsync(TcpClient client)
{
using var stream = client.GetStream();
byte[] buffer = new byte[4096];
while (true)
{
int read = await stream.ReadAsync(buffer);
// What if this read gave us half a message?
// What if it gave us 1.5 messages?
// You're now managing your own framing buffer - and it's easy to get wrong.
ProcessMessages(buffer, read);
}
}
System.IO.Pipelines handles framing and backpressure as first-class concerns. A Pipe has a writer side (fill it from the socket) and a reader side (consume complete messages). The reader declares two positions: consumed (memory can be reclaimed) and examined (I've looked at this, but no complete message yet). The distinction prevents the busy-loop you'd get if the pipe re-delivered data you'd already inspected.
// System.IO.Pipelines - correct partial-read handling
async Task ProcessConnectionAsync(TcpClient client)
{
var pipe = new Pipe();
await Task.WhenAll(
FillPipeAsync(client.GetStream(), pipe.Writer),
ReadPipeAsync(pipe.Reader)
);
}
async Task FillPipeAsync(Stream stream, PipeWriter writer)
{
try
{
while (true)
{
Memory<byte> buffer = writer.GetMemory(512); // pipe manages the buffer
int bytesRead = await stream.ReadAsync(buffer);
if (bytesRead == 0) break;
writer.Advance(bytesRead);
FlushResult result = await writer.FlushAsync();
if (result.IsCompleted) break; // reader signalled completion
}
}
finally { await writer.CompleteAsync(); }
}
async Task ReadPipeAsync(PipeReader reader)
{
try
{
while (true)
{
ReadResult result = await reader.ReadAsync();
ReadOnlySequence<byte> buffer = result.Buffer;
SequencePosition consumed = buffer.Start;
while (TryParseMessage(ref buffer, out Message msg))
{
await HandleMessageAsync(msg);
consumed = buffer.Start;
}
// consumed: pipe can reclaim this memory
// examined (buffer.End): I've seen everything but no complete message yet -
// don't call me again until there's more data
reader.AdvanceTo(consumed, buffer.End);
if (result.IsCompleted) break;
}
}
finally { await reader.CompleteAsync(); }
}
// Length-prefixed frame parser - no allocations for incomplete frames
static bool TryParseMessage(ref ReadOnlySequence<byte> buffer, out Message message)
{
// Need at least 4 bytes for the length prefix
if (buffer.Length < 4) { message = default; return false; }
// Read length prefix without allocating - stack-allocate the 4 bytes
Span<byte> lengthBytes = stackalloc byte[4];
buffer.Slice(0, 4).CopyTo(lengthBytes);
int frameLength = BinaryPrimitives.ReadInt32LittleEndian(lengthBytes);
// Wait until we have the complete frame
if (buffer.Length < 4 + frameLength) { message = default; return false; }
ReadOnlySequence<byte> body = buffer.Slice(4, frameLength);
message = ParseBody(body); // parse in-place or copy - your choice
buffer = buffer.Slice(4 + frameLength); // advance past this frame
return true;
}
Backpressure is structural: FlushAsync on the writer blocks when the reader is behind, preventing unbounded memory growth. You don't write this logic yourself - it's in the pipe.
System.Threading.Channels - producer-consumer without locks
Channel is .NET's built-in producer-consumer queue with async read/write, bounded capacity, and backpressure. The advantage over ConcurrentQueue: channels are async-native and don't require polling. Consumers await new items instead of spinning on TryDequeue in a loop.
// Bounded channel - backpressure kicks in when full
Channel<WorkItem> channel = Channel.CreateBounded<WorkItem>(new BoundedChannelOptions(1000)
{
FullMode = BoundedChannelFullMode.Wait, // producer awaits space - true backpressure
SingleWriter = false,
SingleReader = false,
});
// Producer - WriteAsync yields when channel is at capacity
async Task ProduceAsync(ChannelWriter<WorkItem> writer, IAsyncEnumerable<WorkItem> source)
{
await foreach (var item in source)
{
await writer.WriteAsync(item); // blocks if full - backpressure propagates upstream
}
writer.Complete();
}
// Consumer - ReadAllAsync exits cleanly when channel is complete
async Task ConsumeAsync(ChannelReader<WorkItem> reader)
{
await foreach (WorkItem item in reader.ReadAllAsync())
{
await ProcessItemAsync(item);
}
}
BoundedChannelFullMode controls what happens when the channel is at capacity. Wait is the right default for services where you want backpressure to propagate - the producer slows down naturally when the consumer can't keep up. DropOldest is useful for telemetry sampling where stale data is less valuable than new data. DropWrite (WriteAsync returns false without blocking) is useful for rate-limiting at ingestion where you want to explicitly account for drops.
// Fan-out to multiple consumers - same channel, multiple consumer tasks
var channel = Channel.CreateBounded<WorkItem>(1000);
// Start N consumers before any producer writes
var consumers = Enumerable
.Range(0, 8)
.Select(_ => ConsumeAsync(channel.Reader))
.ToArray();
// Multiple producers can write concurrently - channel handles the synchronization
var producers = Enumerable
.Range(0, 4)
.Select(i => ProduceAsync(channel.Writer, GetBatch(i)))
.ToArray();
await Task.WhenAll(producers);
channel.Writer.Complete(); // no more items - consumers will drain and exit
await Task.WhenAll(consumers);
// Use Channel.CreateUnbounded<T>() when:
// - The producer is bounded and you control it (DB cursor, file read)
// - You need guaranteed delivery and can afford the memory
// Don't use it for external input you don't control - OOM under spikes
Putting all four together: a log ingestion service
Here's what it looks like when these four primitives share a service. A TCP log ingestion endpoint: the socket fills a pipe, the pipe reader parses lines with Span, complete parsed lines go into a channel, and multiple processor workers consume from the channel.
public sealed class LogIngestionService : BackgroundService
{
private readonly Channel<ParsedLogLine> _channel;
private readonly ILogProcessor[] _processors;
public LogIngestionService(ILogProcessor[] processors)
{
_processors = processors;
_channel = Channel.CreateBounded<ParsedLogLine>(new BoundedChannelOptions(10_000)
{
FullMode = BoundedChannelFullMode.Wait,
SingleWriter = true, // only the parse loop writes
SingleReader = false, // multiple processor workers read
});
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
var listener = new TcpListener(IPAddress.Any, 9200);
listener.Start();
// Consumers start before we accept any connections
Task[] consumers = _processors
.Select(p => ConsumeAsync(p, ct))
.ToArray();
try
{
while (!ct.IsCancellationRequested)
{
TcpClient client = await listener.AcceptTcpClientAsync(ct);
_ = HandleConnectionAsync(client, ct); // fire-and-forget per connection
}
}
finally
{
_channel.Writer.Complete();
await Task.WhenAll(consumers);
}
}
private async Task HandleConnectionAsync(TcpClient client, CancellationToken ct)
{
using var _ = client;
var pipe = new Pipe();
await Task.WhenAll(
FillAsync(client.GetStream(), pipe.Writer, ct),
ParseAsync(pipe.Reader, ct)
);
}
private static async Task FillAsync(Stream stream, PipeWriter writer, CancellationToken ct)
{
try
{
while (!ct.IsCancellationRequested)
{
Memory<byte> buffer = writer.GetMemory(1024);
int read = await stream.ReadAsync(buffer, ct);
if (read == 0) break;
writer.Advance(read);
if ((await writer.FlushAsync(ct)).IsCompleted) break;
}
}
finally { await writer.CompleteAsync(); }
}
private async Task ParseAsync(PipeReader reader, CancellationToken ct)
{
try
{
while (!ct.IsCancellationRequested)
{
ReadResult result = await reader.ReadAsync(ct);
ReadOnlySequence<byte> buffer = result.Buffer;
SequencePosition consumed = buffer.Start;
while (TryReadLine(ref buffer, out ReadOnlySequence<byte> line))
{
// All of this stays on the stack - no heap allocations per line
if (TryParseLine(line, out ParsedLogLine parsed))
await _channel.Writer.WriteAsync(parsed, ct);
consumed = buffer.Start;
}
reader.AdvanceTo(consumed, buffer.End);
if (result.IsCompleted) break;
}
}
finally { await reader.CompleteAsync(); }
}
private static bool TryReadLine(
ref ReadOnlySequence<byte> buffer,
out ReadOnlySequence<byte> line)
{
SequencePosition? newline = buffer.PositionOf((byte)'\n');
if (newline is null) { line = default; return false; }
line = buffer.Slice(0, newline.Value);
buffer = buffer.Slice(buffer.GetPosition(1, newline.Value));
return true;
}
private static bool TryParseLine(
ReadOnlySequence<byte> line,
out ParsedLogLine parsed)
{
if (line.Length > 2048) { parsed = default; return false; }
// Copy to stack for Span<byte> work - no heap allocation for the working buffer
Span<byte> bytes = stackalloc byte[(int)line.Length];
line.CopyTo(bytes);
int firstSpace = bytes.IndexOf((byte)' ');
if (firstSpace < 0) { parsed = default; return false; }
ReadOnlySpan<byte> timestampPart = bytes.Slice(0, firstSpace);
ReadOnlySpan<byte> rest = bytes.Slice(firstSpace + 1);
parsed = new ParsedLogLine(
Timestamp: ParseTimestamp(timestampPart),
Raw: line.ToArray() // one allocation - only when we actually need to keep the data
);
return true;
}
private async Task ConsumeAsync(ILogProcessor processor, CancellationToken ct)
{
await foreach (ParsedLogLine line in _channel.Reader.ReadAllAsync(ct))
await processor.ProcessAsync(line, ct);
}
}
When not to reach for these
Span and Memory add cognitive load. For most service code - request handlers, repository methods, domain logic - allocation overhead is irrelevant and the constraint that Span can't cross the heap is a maintenance burden. Profile before reaching for this. If your flame graph doesn't show GC pressure in that code path, leave it alone.
System.IO.Pipelines is specifically for streaming I/O with partial-read framing. If your network reads always return complete messages (HTTP request bodies, short socket commands with a simple delimiter), a Stream with manual buffering is simpler and correct. Pipelines earn their complexity at high connection counts where the partial-read logic is the hard part.
Channel is overkill when producer and consumer run at similar rates with no backpressure risk, or when you just need fire-and-forget background work (Task.Run is fine). Use it when you specifically need bounded capacity, graceful shutdown via Complete(), or async awaiting on an empty queue without polling.
The question is always the same: does the profiler show allocation pressure or contention in this specific path? These four primitives are the answer to specific, measurable problems. Used speculatively, they're just complexity.
Top comments (0)