ReadOnlySequence<T>
Multi-segment buffer representation for scattered memory reads
ReadOnlySequence<T> in .NET
Master efficient buffer management in .NET with ReadOnlySequence<T> using free flashcards and spaced repetition practice. This lesson covers discontiguous memory representation, segment navigation, and performance optimizationβessential concepts for building high-performance .NET applications that minimize allocations and copies.
Welcome to ReadOnlySequence<T> π»
When working with streaming data, network protocols, or large file processing in .NET, you'll often encounter data that doesn't arrive in a single contiguous block. Traditional arrays and Span<T> work beautifully for contiguous memory, but what happens when your data is scattered across multiple buffers? This is where ReadOnlySequence<T> becomes indispensable.
ReadOnlySequence<T> is a struct in the System.Buffers namespace that represents a read-only sequence of items that may span multiple memory segments. Think of it as a linked list of memory segments that you can traverse efficiently without copying data. It's the foundation of high-performance I/O in modern .NET, powering System.IO.Pipelines, Kestrel web server, and many other performance-critical components.
Why ReadOnlySequence<T> Exists π―
Imagine receiving data over a network. The data arrives in chunks:
- First packet: 512 bytes
- Second packet: 1024 bytes
- Third packet: 256 bytes
With traditional approaches, you'd either:
- Copy everything into a single large buffer (expensive, causes allocations)
- Process each chunk separately (complex logic, potential protocol issues)
ReadOnlySequence<T> provides a third option: treat the disconnected chunks as a single logical sequence without copying. You get unified sequential access while the underlying memory remains fragmented.
<pre> Traditional Approach (Copying): βββββββββββ βββββββββββ βββββββββββ βChunk 1 β βChunk 2 β βChunk 3 β ββββββ¬βββββ ββββββ¬βββββ ββββββ¬βββββ β β β βββββββββββββ΄ββββββββββββ βΌ βββββββββββββββββββββββββββββ β Large Copied Buffer β β Allocation! βββββββββββββββββββββββββββββ
ReadOnlySequence Approach (Zero-Copy): βββββββββββ βββββββββββ βββββββββββ βChunk 1 ββ βChunk 2 ββ βChunk 3 β βββββββββββ βββββββββββ βββββββββββ β β βββββ ReadOnlySequence βββββββββ β No copying, unified view! </pre>
Core Concepts π§
1. Structure and Representation
ReadOnlySequence<T> is a struct (value type) that wraps either:
- Single segment: Backed by a single
ReadOnlyMemory<T>(contiguous) - Multiple segments: Backed by a linked sequence of memory blocks
The beauty is that you interact with it the same way regardless of whether it's single or multi-segment.
<table> <tr> <th>Property</th> <th>Type</th> <th>Description</th> </tr> <tr> <td><strong>Length</strong></td> <td>long</td> <td>Total number of items across all segments</td> </tr> <tr> <td><strong>IsEmpty</strong></td> <td>bool</td> <td>True if sequence contains zero items</td> </tr> <tr> <td><strong>IsSingleSegment</strong></td> <td>bool</td> <td>True if backed by single contiguous memory</td> </tr> <tr> <td><strong>First</strong></td> <td>ReadOnlyMemory<T></td> <td>First segment's memory</td> </tr> <tr> <td><strong>Start</strong></td> <td>SequencePosition</td> <td>Position pointing to first item</td> </tr> <tr> <td><strong>End</strong></td> <td>SequencePosition</td> <td>Position pointing past last item</td> </tr> </table>
2. SequencePosition: The Navigation Token π§
SequencePosition is an opaque struct that represents a position within a ReadOnlySequence<T>. Think of it as a bookmarkβit doesn't contain the data itself, but points to a specific location.
public readonly struct SequencePosition
{
// Internal: tracks which segment and offset within that segment
// You rarely construct these manuallyβmethods return them
}
Key characteristics:
- Opaque: You can't inspect its internals directly
- Relative: Only valid for the sequence it came from
- Lightweight: Just two fields internally (object reference + integer index)
π‘ Tip: Treat SequencePosition like an iterator in C++βit's meaningless outside its original sequence.
3. Creating ReadOnlySequence<T> Instances
From Single Segment (Contiguous Memory)
byte[] array = new byte[1024];
ReadOnlyMemory<byte> memory = array;
var sequence = new ReadOnlySequence<byte>(memory);
Console.WriteLine($"IsSingleSegment: {sequence.IsSingleSegment}"); // True
Console.WriteLine($"Length: {sequence.Length}"); // 1024
From Multiple Segments (Custom Implementation)
For multi-segment sequences, you typically implement ReadOnlySequenceSegment<T>. This is an abstract class that forms a linked list:
public class BufferSegment : ReadOnlySequenceSegment<byte>
{
public BufferSegment(ReadOnlyMemory<byte> memory)
{
Memory = memory;
}
public BufferSegment Append(ReadOnlyMemory<byte> memory)
{
var segment = new BufferSegment(memory)
{
RunningIndex = RunningIndex + Memory.Length
};
Next = segment;
return segment;
}
}
Creating a multi-segment sequence:
// Create three separate buffers
var buffer1 = new byte[512];
var buffer2 = new byte[1024];
var buffer3 = new byte[256];
// Build linked segment chain
var firstSegment = new BufferSegment(buffer1);
var secondSegment = firstSegment.Append(buffer2);
var thirdSegment = secondSegment.Append(buffer3);
// Create sequence from first and last segments
var sequence = new ReadOnlySequence<byte>(
firstSegment, 0, // Start: first segment, offset 0
thirdSegment, buffer3.Length // End: last segment, its length
);
Console.WriteLine($"IsSingleSegment: {sequence.IsSingleSegment}"); // False
Console.WriteLine($"Length: {sequence.Length}"); // 1792 (512+1024+256)
<pre> Linked Segment Structure: ββββββββββββββββββββββββββββββββββββ β BufferSegment 1 β β Memory: byte[512] β β RunningIndex: 0 β β Next: ββββββββββββββββββββ β ββββββββββββββββββββββββββββΌββββββββ βΌ ββββββββββββββββββββββββββββββββββββ β BufferSegment 2 β β Memory: byte[1024] β β RunningIndex: 512 β β Next: ββββββββββββββββββββ β ββββββββββββββββββββββββββββΌββββββββ βΌ ββββββββββββββββββββββββββββββββββββ β BufferSegment 3 β β Memory: byte[256] β β RunningIndex: 1536 β β Next: null β ββββββββββββββββββββββββββββββββββββ </pre>
4. Traversing the Sequence πΆββοΈ
Fast Path: Single Segment
Always check IsSingleSegment first for optimal performance:
public void ProcessSequence(ReadOnlySequence<byte> sequence)
{
if (sequence.IsSingleSegment)
{
// Fast path: direct span access
ReadOnlySpan<byte> span = sequence.First.Span;
ProcessSpan(span);
}
else
{
// Slow path: iterate segments
ProcessMultiSegment(sequence);
}
}
Iterating Through Segments
Use ReadOnlySequence<T>.GetEnumerator() to walk through segments:
public int CountBytes(ReadOnlySequence<byte> sequence)
{
int total = 0;
foreach (ReadOnlyMemory<byte> segment in sequence)
{
total += segment.Length;
Console.WriteLine($"Segment: {segment.Length} bytes");
}
return total;
}
Manual iteration with positions:
public void ProcessWithPositions(ReadOnlySequence<byte> sequence)
{
SequencePosition position = sequence.Start;
while (sequence.TryGet(ref position, out ReadOnlyMemory<byte> memory))
{
ReadOnlySpan<byte> span = memory.Span;
// Process this segment
ProcessSegment(span);
}
}
5. Slicing Operations βοΈ
One of the most powerful features is zero-copy slicing:
ReadOnlySequence<byte> sequence = GetDataFromNetwork();
// Get first 100 bytes
ReadOnlySequence<byte> header = sequence.Slice(0, 100);
// Get everything after position 100
ReadOnlySequence<byte> body = sequence.Slice(100);
// Slice between two positions
SequencePosition start = sequence.GetPosition(50);
SequencePosition end = sequence.GetPosition(150);
ReadOnlySequence<byte> middle = sequence.Slice(start, end);
β οΈ Important: Slicing creates a new ReadOnlySequence<T> that references the same underlying memoryβno copying occurs!
6. Searching Within Sequences π
Common pattern: finding delimiters (like newlines in text protocols):
public ReadOnlySequence<byte> ReadLine(ReadOnlySequence<byte> buffer)
{
SequencePosition? position = buffer.PositionOf((byte)'\n');
if (position == null)
{
return default; // No newline found
}
// Return everything up to (but not including) the newline
return buffer.Slice(0, position.Value);
}
Searching for multi-byte patterns:
public SequencePosition? FindPattern(
ReadOnlySequence<byte> sequence,
ReadOnlySpan<byte> pattern)
{
var reader = new SequenceReader<byte>(sequence);
while (!reader.End)
{
if (reader.IsNext(pattern, advancePast: false))
{
return reader.Position;
}
reader.Advance(1);
}
return null;
}
Practical Examples πΌ
Example 1: HTTP Protocol Parser
public class HttpRequestParser
{
private static readonly byte[] NewLine = new byte[] { (byte)'\r', (byte)'\n' };
private static readonly byte[] DoubleNewLine =
new byte[] { (byte)'\r', (byte)'\n', (byte)'\r', (byte)'\n' };
public bool TryParseRequest(
ReadOnlySequence<byte> buffer,
out HttpRequest request,
out SequencePosition consumed)
{
request = null;
consumed = buffer.Start;
// Find end of headers (double CRLF)
SequencePosition? headersEnd = buffer.PositionOf(DoubleNewLine);
if (headersEnd == null)
{
return false; // Incomplete request
}
// Extract request line and headers
ReadOnlySequence<byte> headersSequence =
buffer.Slice(0, headersEnd.Value);
// Parse request line (first line)
SequencePosition? firstLineEnd = headersSequence.PositionOf(NewLine);
if (firstLineEnd == null) return false;
ReadOnlySequence<byte> requestLine =
headersSequence.Slice(0, firstLineEnd.Value);
// Convert to string for parsing (necessary here)
string requestLineStr = Encoding.UTF8.GetString(requestLine);
var parts = requestLineStr.Split(' ');
request = new HttpRequest
{
Method = parts[0],
Path = parts[1],
Version = parts[2]
};
// Mark how much we consumed
consumed = buffer.GetPosition(4, headersEnd.Value); // Skip \r\n\r\n
return true;
}
}
Why this is efficient:
- No copying of buffer data until absolutely necessary (encoding to string)
- Works regardless of how network packets arrived
- Can process partial requests incrementally
Example 2: Length-Prefixed Message Decoder
public class MessageDecoder
{
// Protocol: [4-byte length][message bytes]
public bool TryDecodeMessage(
ReadOnlySequence<byte> buffer,
out ReadOnlySequence<byte> message,
out SequencePosition consumed)
{
message = default;
consumed = buffer.Start;
// Need at least 4 bytes for length prefix
if (buffer.Length < 4)
{
return false;
}
// Read length (handles cross-segment reading)
int messageLength = ReadInt32(buffer.Slice(0, 4));
// Check if full message is available
if (buffer.Length < 4 + messageLength)
{
return false;
}
// Extract message (zero-copy slice)
message = buffer.Slice(4, messageLength);
// Mark position after this message
consumed = buffer.GetPosition(4 + messageLength);
return true;
}
private int ReadInt32(ReadOnlySequence<byte> sequence)
{
// Fast path: single segment
if (sequence.IsSingleSegment)
{
return BinaryPrimitives.ReadInt32BigEndian(sequence.First.Span);
}
// Slow path: copy to stack buffer
Span<byte> buffer = stackalloc byte[4];
sequence.CopyTo(buffer);
return BinaryPrimitives.ReadInt32BigEndian(buffer);
}
}
Key patterns demonstrated:
- Length checking before attempting to read
- Efficient integer reading with fast/slow paths
- Position tracking for consuming data
stackallocfor temporary small buffers
Example 3: JSON Token Scanner
public class JsonTokenizer
{
public IEnumerable<JsonToken> Tokenize(ReadOnlySequence<byte> json)
{
var reader = new SequenceReader<byte>(json);
while (!reader.End)
{
reader.AdvancePastAny((byte)' ', (byte)'\t', (byte)'\r', (byte)'\n');
if (reader.End) break;
if (reader.TryPeek(out byte current))
{
switch ((char)current)
{
case '{':
reader.Advance(1);
yield return new JsonToken(TokenType.OpenBrace);
break;
case '}':
reader.Advance(1);
yield return new JsonToken(TokenType.CloseBrace);
break;
case '"':
yield return ReadString(ref reader);
break;
case '-':
case '0': case '1': case '2': case '3': case '4':
case '5': case '6': case '7': case '8': case '9':
yield return ReadNumber(ref reader);
break;
default:
yield return ReadKeyword(ref reader);
break;
}
}
}
}
private JsonToken ReadString(ref SequenceReader<byte> reader)
{
reader.Advance(1); // Skip opening quote
SequencePosition start = reader.Position;
// Find closing quote (simplified - doesn't handle escapes)
while (reader.TryRead(out byte b))
{
if (b == '"')
{
break;
}
}
return new JsonToken(TokenType.String);
}
}
Why SequenceReader<T> matters here:
- Provides high-level reading operations
- Handles segment boundaries automatically
- Maintains position tracking
- Supports lookahead (
TryPeek) without advancing
Example 4: Custom BufferWriter Integration
public class ChunkedBufferWriter : IBufferWriter<byte>
{
private readonly List<byte[]> _segments = new();
private byte[] _current;
private int _position;
private const int ChunkSize = 4096;
public ChunkedBufferWriter()
{
_current = new byte[ChunkSize];
_segments.Add(_current);
}
public void Advance(int count)
{
_position += count;
if (_position >= ChunkSize)
{
_current = new byte[ChunkSize];
_segments.Add(_current);
_position = 0;
}
}
public Memory<byte> GetMemory(int sizeHint = 0)
{
int available = ChunkSize - _position;
if (sizeHint > available)
{
_current = new byte[Math.Max(ChunkSize, sizeHint)];
_segments.Add(_current);
_position = 0;
}
return _current.AsMemory(_position);
}
public Span<byte> GetSpan(int sizeHint = 0) =>
GetMemory(sizeHint).Span;
// Convert written data to ReadOnlySequence
public ReadOnlySequence<byte> AsSequence()
{
if (_segments.Count == 1)
{
return new ReadOnlySequence<byte>(
_segments[0].AsMemory(0, _position));
}
var firstSegment = new BufferSegment(_segments[0]);
var currentSegment = firstSegment;
for (int i = 1; i < _segments.Count - 1; i++)
{
currentSegment = currentSegment.Append(_segments[i]);
}
// Last segment uses only written portion
currentSegment = currentSegment.Append(
_segments[^1].AsMemory(0, _position));
return new ReadOnlySequence<byte>(
firstSegment, 0,
currentSegment, _position);
}
}
Usage:
var writer = new ChunkedBufferWriter();
// Write data that spans multiple chunks
for (int i = 0; i < 10000; i++)
{
var span = writer.GetSpan(4);
BinaryPrimitives.WriteInt32LittleEndian(span, i);
writer.Advance(4);
}
// Get as sequence without copying
ReadOnlySequence<byte> sequence = writer.AsSequence();
Console.WriteLine($"Total bytes: {sequence.Length}");
Console.WriteLine($"Segments: {sequence.IsSingleSegment ? 1 : "multiple"}");
Common Mistakes β οΈ
Mistake 1: Copying Data Unnecessarily
β Wrong:
public byte[] ProcessSequence(ReadOnlySequence<byte> sequence)
{
// Defeats the entire purpose!
byte[] array = sequence.ToArray();
return ProcessArray(array);
}
β Right:
public void ProcessSequence(ReadOnlySequence<byte> sequence)
{
if (sequence.IsSingleSegment)
{
ProcessSpan(sequence.First.Span);
}
else
{
foreach (ReadOnlyMemory<byte> segment in sequence)
{
ProcessSpan(segment.Span);
}
}
}
Mistake 2: Assuming Contiguous Memory
β Wrong:
public int ReadInt32(ReadOnlySequence<byte> sequence)
{
// Crashes if sequence spans segments!
return BinaryPrimitives.ReadInt32LittleEndian(
sequence.First.Span);
}
β Right:
public int ReadInt32(ReadOnlySequence<byte> sequence)
{
if (sequence.Length < 4)
throw new ArgumentException("Too short");
if (sequence.IsSingleSegment)
{
return BinaryPrimitives.ReadInt32LittleEndian(
sequence.First.Span);
}
Span<byte> temp = stackalloc byte[4];
sequence.Slice(0, 4).CopyTo(temp);
return BinaryPrimitives.ReadInt32LittleEndian(temp);
}
Mistake 3: Keeping SequencePosition After Sequence Changes
β Wrong:
public void ProcessMessages()
{
var buffer = GetBuffer();
SequencePosition position = buffer.GetPosition(100);
// Buffer gets modified/recycled
buffer = GetNewBuffer();
// Position is now invalid!
var slice = buffer.Slice(position); // β Undefined behavior
}
β Right:
public void ProcessMessages()
{
var buffer = GetBuffer();
// Use positions only within same sequence lifetime
ProcessBuffer(buffer);
// Get new buffer and new positions
buffer = GetNewBuffer();
SequencePosition newPosition = buffer.Start;
}
Mistake 4: Not Handling Empty Sequences
β Wrong:
public byte GetFirstByte(ReadOnlySequence<byte> sequence)
{
return sequence.First.Span[0]; // Throws if empty!
}
β Right:
public bool TryGetFirstByte(ReadOnlySequence<byte> sequence, out byte value)
{
value = default;
if (sequence.IsEmpty)
{
return false;
}
value = sequence.First.Span[0];
return true;
}
Mistake 5: Inefficient String Conversion
β Wrong:
public string ConvertToString(ReadOnlySequence<byte> sequence)
{
// Creates temporary array!
return Encoding.UTF8.GetString(sequence.ToArray());
}
β Right:
public string ConvertToString(ReadOnlySequence<byte> sequence)
{
if (sequence.IsSingleSegment)
{
return Encoding.UTF8.GetString(sequence.First.Span);
}
// For multi-segment, renting is better than ToArray
int length = (int)sequence.Length;
byte[] rented = ArrayPool<byte>.Shared.Rent(length);
try
{
sequence.CopyTo(rented);
return Encoding.UTF8.GetString(rented, 0, length);
}
finally
{
ArrayPool<byte>.Shared.Return(rented);
}
}
Performance Considerations π
Benchmarking Single vs Multi-Segment
[MemoryDiagnoser]
public class SequenceBenchmarks
{
private ReadOnlySequence<byte> _singleSegment;
private ReadOnlySequence<byte> _multiSegment;
[GlobalSetup]
public void Setup()
{
var data = new byte[4096];
_singleSegment = new ReadOnlySequence<byte>(data);
// Create 8 segments of 512 bytes each
var first = new BufferSegment(new byte[512]);
var current = first;
for (int i = 1; i < 8; i++)
{
current = current.Append(new byte[512]);
}
_multiSegment = new ReadOnlySequence<byte>(first, 0, current, 512);
}
[Benchmark]
public int CountBytes_SingleSegment()
{
return (int)_singleSegment.Length;
}
[Benchmark]
public int CountBytes_MultiSegment()
{
return (int)_multiSegment.Length;
}
[Benchmark]
public int IterateSegments_Single()
{
int total = 0;
foreach (var segment in _singleSegment)
{
total += segment.Length;
}
return total;
}
[Benchmark]
public int IterateSegments_Multi()
{
int total = 0;
foreach (var segment in _multiSegment)
{
total += segment.Length;
}
return total;
}
}
Typical results: <table> <tr> <th>Method</th> <th>Mean</th> <th>Allocated</th> </tr> <tr> <td>CountBytes_SingleSegment</td> <td>0.5 ns</td> <td>0 B</td> </tr> <tr> <td>CountBytes_MultiSegment</td> <td>0.5 ns</td> <td>0 B</td> </tr> <tr> <td>IterateSegments_Single</td> <td>12 ns</td> <td>0 B</td> </tr> <tr> <td>IterateSegments_Multi</td> <td>85 ns</td> <td>0 B</td> </tr> </table>
π‘ Takeaway: Length property is always fast, but iteration cost scales with segment count. Always check IsSingleSegment when possible.
Integration with System.IO.Pipelines π
ReadOnlySequence<T> is the heart of the Pipelines API:
public async Task ProcessPipelineAsync(PipeReader reader)
{
while (true)
{
ReadResult result = await reader.ReadAsync();
ReadOnlySequence<byte> buffer = result.Buffer;
SequencePosition consumed = buffer.Start;
SequencePosition examined = buffer.End;
try
{
// Process complete messages in buffer
while (TryParseMessage(buffer, out var message, out var position))
{
ProcessMessage(message);
buffer = buffer.Slice(position);
consumed = position;
}
}
finally
{
// Tell pipeline how much we consumed
reader.AdvanceTo(consumed, examined);
}
if (result.IsCompleted)
{
break;
}
}
}
Flow diagram: <pre> βββββββββββββββββββββββββββββββββββββββββββββββ β PipeReader Workflow β βββββββββββββββββββββββββββββββββββββββββββββββ
ββββββββββββββββ
β ReadAsync() β
ββββββββ¬ββββββββ
β
βΌ
ββββββββββββββββββββββββ
β Get ReadOnlySequence β
β from ReadResult β
ββββββββ¬ββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββ
β Parse messages β
β Track positions β
ββββββββ¬ββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββ
β AdvanceTo(consumed) β
β (releases memory) β
ββββββββ¬ββββββββββββββββ
β
ββββββββ (repeat)
</pre>
Key Takeaways π―
Zero-Copy Philosophy:
ReadOnlySequence<T>enables processing discontiguous memory without copyingβessential for high-performance scenarios.Always Check IsSingleSegment: This simple check unlocks fast-path optimizations for contiguous data.
SequencePosition is Opaque: Treat positions as bookmarksβdon't inspect internals, don't reuse across sequences.
Use SequenceReader<T>: For complex parsing,
SequenceReader<T>handles segment boundaries automatically.Avoid ToArray(): This defeats the purpose. Use iteration or
stackallocfor temporary copies instead.Pipelines Integration:
ReadOnlySequence<T>is designed to work seamlessly withSystem.IO.Pipelinesfor efficient I/O.Memory Pooling: Combine with
ArrayPool<T>andMemoryPool<T>for complete zero-allocation processing.
<div style="border: 2px solid #4fd1c5; border-radius: 8px; padding: 16px; margin: 16px 0; background: rgba(79, 209, 197, 0.1);"> <h4>π Quick Reference Card</h4> <table> <tr> <td><strong>Check before processing</strong></td> <td><code>if (sequence.IsSingleSegment)</code></td> </tr> <tr> <td><strong>Get first segment</strong></td> <td><code>ReadOnlyMemory<T> mem = sequence.First;</code></td> </tr> <tr> <td><strong>Iterate all segments</strong></td> <td><code>foreach (var seg in sequence)</code></td> </tr> <tr> <td><strong>Slice by length</strong></td> <td><code>sequence.Slice(start, length)</code></td> </tr> <tr> <td><strong>Find byte</strong></td> <td><code>sequence.PositionOf(byte)</code></td> </tr> <tr> <td><strong>Get position at offset</strong></td> <td><code>sequence.GetPosition(offset)</code></td> </tr> <tr> <td><strong>Copy to contiguous</strong></td> <td><code>sequence.CopyTo(span)</code></td> </tr> <tr> <td><strong>Complex parsing</strong></td> <td><code>new SequenceReader<T>(sequence)</code></td> </tr> <tr> <td><strong>Avoid</strong></td> <td><code>sequence.ToArray()</code> β</td> </tr> </table> </div>