학습
.NET에서 버퍼 작업
이 문서에서는 여러 버퍼에서 실행되는 데이터를 읽는 데 도움이 되는 형식을 개략적으로 설명합니다. 이러한 형식은 주로 PipeReader 개체를 지원하는 데 사용됩니다.
System.Buffers.IBufferWriter<T>는 비동기 버퍼링 쓰기에 대한 계약입니다. 최저 수준에서 인터페이스는,
- 기본적이며 사용하기 어렵지 않습니다.
- Memory<T> 또는 Span<T>에 대한 액세스를 허용합니다.
Memory<T>
또는Span<T>
에 쓸 수 있으며 작성된T
항목의 수를 확인할 수 있습니다.
void WriteHello(IBufferWriter<byte> writer)
{
// Request at least 5 bytes.
Span<byte> span = writer.GetSpan(5);
ReadOnlySpan<char> helloSpan = "Hello".AsSpan();
int written = Encoding.ASCII.GetBytes(helloSpan, span);
// Tell the writer how many bytes were written.
writer.Advance(written);
}
위의 메서드는,
GetSpan(5)
을 사용하여IBufferWriter<byte>
에서 최소 5바이트의 버퍼를 요청합니다.- 반환된
Span<byte>
에 ASCII 문자열 "Hello"를 위한 바이트를 씁니다. - IBufferWriter<T>를 호출하여 버퍼에 쓴 바이트 수를 표시합니다.
이 쓰기 메서드는 IBufferWriter<T>
에서 제공하는 Memory<T>
/Span<T>
버퍼를 사용합니다. 또는 Write 확장 메서드를 사용하여 기존 버퍼를 IBufferWriter<T>
에 복사할 수 있습니다. Write
가 필요에 따라 GetSpan
/Advance
를 호출하는 작업을 수행하므로 쓰기 후 Advance
를 호출할 필요가 없습니다.
void WriteHello(IBufferWriter<byte> writer)
{
byte[] helloBytes = Encoding.ASCII.GetBytes("Hello");
// Write helloBytes to the writer. There's no need to call Advance here
// since Write calls Advance.
writer.Write(helloBytes);
}
ArrayBufferWriter<T>는 백업 저장소가 단일 연속 배열인 IBufferWriter<T>
의 구현입니다.
GetSpan
및GetMemory
는 적어도 요청된 양의 메모리를 포함하는 버퍼를 반환합니다. 정확한 버퍼 크기를 가정하지 마세요.- 연속 호출이 동일한 버퍼 또는 동일한 크기의 버퍼를 반환한다는 보장은 없습니다.
- 추가 데이터를 계속 작성하려면
Advance
를 호출한 후 새 버퍼를 요청해야 합니다.Advance
를 호출한 후에는 이전에 가져온 버퍼를 쓸 수 없습니다.
ReadOnlySequence<T>은 T
의 연속 또는 불연속 시퀀스를 나타낼 수 있는 구조체입니다. 다음에서 생성할 수 있습니다.
T[]
ReadOnlyMemory<T>
- 연결된 목록 노드 ReadOnlySequenceSegment<T>과 인덱스의 쌍으로, 시퀀스의 시작 및 끝 위치를 나타냅니다.
세 번째 표현은 ReadOnlySequence<T>
의 다양한 작업에 대한 성능에 영향을 미치므로 가장 흥미로운 부분입니다.
표현 | 연산 | 복잡성 |
---|---|---|
T[] /ReadOnlyMemory<T> |
Length |
O(1) |
T[] /ReadOnlyMemory<T> |
GetPosition(long) |
O(1) |
T[] /ReadOnlyMemory<T> |
Slice(int, int) |
O(1) |
T[] /ReadOnlyMemory<T> |
Slice(SequencePosition, SequencePosition) |
O(1) |
ReadOnlySequenceSegment<T> |
Length |
O(1) |
ReadOnlySequenceSegment<T> |
GetPosition(long) |
O(number of segments) |
ReadOnlySequenceSegment<T> |
Slice(int, int) |
O(number of segments) |
ReadOnlySequenceSegment<T> |
Slice(SequencePosition, SequencePosition) |
O(1) |
이 혼합된 표현으로 인해 ReadOnlySequence<T>
는 인덱스를 정수가 아닌 SequencePosition
으로 노출합니다. SequencePosition
:
- 이 메서드가 시작된
ReadOnlySequence<T>
에 대한 인덱스를 나타내는 불투명 값입니다. - 정수와 개체 두 부분으로 구성됩니다. 이들 두 값이 표현하는 내용은
ReadOnlySequence<T>
구현에 연결됩니다.
ReadOnlySequence<T>
는 데이터를 ReadOnlyMemory<T>
의 열거 가능 항목으로 노출합니다. 각 세그먼트를 열거하는 작업은 기본 foreach를 사용하여 수행할 수 있습니다.
long FindIndexOf(in ReadOnlySequence<byte> buffer, byte data)
{
long position = 0;
foreach (ReadOnlyMemory<byte> segment in buffer)
{
ReadOnlySpan<byte> span = segment.Span;
var index = span.IndexOf(data);
if (index != -1)
{
return position + index;
}
position += span.Length;
}
return -1;
}
위의 메서드는 각 세그먼트에서 특정 바이트를 검색합니다. 각 세그먼트의 SequencePosition
을 추적해야 하는 경우 ReadOnlySequence<T>.TryGet가 더 적합합니다. 다음 샘플에서는 정수 대신 SequencePosition
을 반환하도록 위의 코드를 변경합니다. SequencePosition
을 반환하면 호출자가 특정 인덱스에서 데이터를 가져오기 위해 두 번째 검색이 방지되는 이점을 얻을 수 있습니다.
SequencePosition? FindIndexOf(in ReadOnlySequence<byte> buffer, byte data)
{
SequencePosition position = buffer.Start;
SequencePosition result = position;
while (buffer.TryGet(ref position, out ReadOnlyMemory<byte> segment))
{
ReadOnlySpan<byte> span = segment.Span;
var index = span.IndexOf(data);
if (index != -1)
{
return buffer.GetPosition(index, result);
}
result = position;
}
return null;
}
SequencePosition
와 TryGet
의 조합은 열거자처럼 동작합니다. 위치 필드는 각 반복이 시작될 때 ReadOnlySequence<T>
내에서 각 세그먼트의 시작으로 수정됩니다.
위의 메서드는 ReadOnlySequence<T>
에 대한 확장 메서드로 존재합니다. PositionOf는 위의 코드를 간소화하는 데 사용할 수 있습니다.
SequencePosition? FindIndexOf(in ReadOnlySequence<byte> buffer, byte data) => buffer.PositionOf(data);
데이터가 시퀀스 내의 여러 세그먼트로 분할될 수 있으므로 ReadOnlySequence<T>
처리는 어려울 수 있습니다. 최상의 성능을 위해 코드를 다음 두 개의 경로로 분할합니다.
- 단일 세그먼트 케이스를 처리하는 빠른 경로.
- 여러 세그먼트로 분할된 데이터를 처리하는 느린 경로.
다중 분할된 시퀀스에서 데이터를 처리하는 데 사용할 수 있는 몇 가지 방법이 있습니다.
SequenceReader<T>
를 사용합니다.- 구문 분석된 세그먼트 내에서
SequencePosition
및 인덱스를 추적하여 데이터를 세그먼트 단위로 구문 분석합니다. 이렇게 하면 불필요한 할당을 피할 수 있지만 특히 버퍼가 작은 경우에는 비효율적입니다. ReadOnlySequence<T>
를 연속된 배열에 복사하고 단일 버퍼처럼 처리합니다.ReadOnlySequence<T>
크기가 작으면 stackalloc 연산자를 사용하여 스택 할당 버퍼에 데이터를 복사하는 것이 합리적입니다.- ArrayPool<T>.Shared를 사용하여
ReadOnlySequence<T>
를 풀링된 배열에 복사합니다. ReadOnlySequence<T>.ToArray()
을 사용합니다. 이는 힙에서 새T[]
를 할당하므로 실행 부하 과다 경로에는 권장되지 않습니다.
다음 예제에서는 ReadOnlySequence<byte>
를 처리하는 몇 가지 일반적인 사례를 보여 줍니다.
다음 예제에서는 ReadOnlySequence<byte>
의 시작 부분에서 4바이트 Big Endian 정수 길이를 구문 분석합니다.
bool TryParseHeaderLength(ref ReadOnlySequence<byte> buffer, out int length)
{
// If there's not enough space, the length can't be obtained.
if (buffer.Length < 4)
{
length = 0;
return false;
}
// Grab the first 4 bytes of the buffer.
var lengthSlice = buffer.Slice(buffer.Start, 4);
if (lengthSlice.IsSingleSegment)
{
// Fast path since it's a single segment.
length = BinaryPrimitives.ReadInt32BigEndian(lengthSlice.First.Span);
}
else
{
// There are 4 bytes split across multiple segments. Since it's so small, it
// can be copied to a stack allocated buffer. This avoids a heap allocation.
Span<byte> stackBuffer = stackalloc byte[4];
lengthSlice.CopyTo(stackBuffer);
length = BinaryPrimitives.ReadInt32BigEndian(stackBuffer);
}
// Move the buffer 4 bytes ahead.
buffer = buffer.Slice(lengthSlice.End);
return true;
}
다음 예제를 참조하세요.
ReadOnlySequence<byte>
에서 첫 번째 줄 바꿈(\r\n
)을 찾아 out 'line' 매개 변수를 통해 반환합니다.- 입력 버퍼에서
\r\n
을 제외하고 해당 줄을 자릅니다.
static bool TryParseLine(ref ReadOnlySequence<byte> buffer, out ReadOnlySequence<byte> line)
{
SequencePosition position = buffer.Start;
SequencePosition previous = position;
var index = -1;
line = default;
while (buffer.TryGet(ref position, out ReadOnlyMemory<byte> segment))
{
ReadOnlySpan<byte> span = segment.Span;
// Look for \r in the current segment.
index = span.IndexOf((byte)'\r');
if (index != -1)
{
// Check next segment for \n.
if (index + 1 >= span.Length)
{
var next = position;
if (!buffer.TryGet(ref next, out ReadOnlyMemory<byte> nextSegment))
{
// You're at the end of the sequence.
return false;
}
else if (nextSegment.Span[0] == (byte)'\n')
{
// A match was found.
break;
}
}
// Check the current segment of \n.
else if (span[index + 1] == (byte)'\n')
{
// It was found.
break;
}
}
previous = position;
}
if (index != -1)
{
// Get the position just before the \r\n.
var delimeter = buffer.GetPosition(index, previous);
// Slice the line (excluding \r\n).
line = buffer.Slice(buffer.Start, delimeter);
// Slice the buffer to get the remaining data after the line.
buffer = buffer.Slice(buffer.GetPosition(2, delimeter));
return true;
}
return false;
}
ReadOnlySequence<T>
안에 빈 세그먼트를 저장하는 것은 유효합니다. 세그먼트를 명시적으로 열거하는 동안 빈 세그먼트가 발생할 수 있습니다.
static void EmptySegments()
{
// This logic creates a ReadOnlySequence<byte> with 4 segments,
// two of which are empty.
var first = new BufferSegment(new byte[0]);
var last = first.Append(new byte[] { 97 })
.Append(new byte[0]).Append(new byte[] { 98 });
// Construct the ReadOnlySequence<byte> from the linked list segments.
var data = new ReadOnlySequence<byte>(first, 0, last, 1);
// Slice using numbers.
var sequence1 = data.Slice(0, 2);
// Slice using SequencePosition pointing at the empty segment.
var sequence2 = data.Slice(data.Start, 2);
Console.WriteLine($"sequence1.Length={sequence1.Length}"); // sequence1.Length=2
Console.WriteLine($"sequence2.Length={sequence2.Length}"); // sequence2.Length=2
// sequence1.FirstSpan.Length=1
Console.WriteLine($"sequence1.FirstSpan.Length={sequence1.FirstSpan.Length}");
// Slicing using SequencePosition will Slice the ReadOnlySequence<byte> directly
// on the empty segment!
// sequence2.FirstSpan.Length=0
Console.WriteLine($"sequence2.FirstSpan.Length={sequence2.FirstSpan.Length}");
// The following code prints 0, 1, 0, 1.
SequencePosition position = data.Start;
while (data.TryGet(ref position, out ReadOnlyMemory<byte> memory))
{
Console.WriteLine(memory.Length);
}
}
class BufferSegment : ReadOnlySequenceSegment<byte>
{
public BufferSegment(Memory<byte> memory)
{
Memory = memory;
}
public BufferSegment Append(Memory<byte> memory)
{
var segment = new BufferSegment(memory)
{
RunningIndex = RunningIndex + Memory.Length
};
Next = segment;
return segment;
}
}
위의 코드는 빈 세그먼트를 사용하여 ReadOnlySequence<byte>
를 만들고 이러한 빈 세그먼트가 다양한 API에 미치는 영향을 보여 줍니다.
SequencePosition
이 빈 세그먼트를 가리키는ReadOnlySequence<T>.Slice
는 해당 세그먼트를 유지합니다.- 정수가 포함된
ReadOnlySequence<T>.Slice
는 빈 세그먼트를 건너뜁니다. ReadOnlySequence<T>
를 열거하면 빈 세그먼트가 열거됩니다.
ReadOnlySequence<T>
/SequencePosition
을 처리할 때 일반 ReadOnlySpan<T>
/ReadOnlyMemory<T>
/T[]
/int
에 비해 비정상적인 결과가 몇 가지 있습니다.
SequencePosition
은 절대 위치가 아니라 특정ReadOnlySequence<T>
의 위치 표식입니다. 특정ReadOnlySequence<T>
를 기준으로 하기 때문에 이 메서드가 시작된ReadOnlySequence<T>
의 외부에서 사용하는 경우 의미가 없습니다.ReadOnlySequence<T>
없이는SequencePosition
에서 산술 연산을 수행할 수 없습니다. 즉position++
와 같은 기본 작업을 실행하면position = ReadOnlySequence<T>.GetPosition(1, position)
이 작성됩니다.GetPosition(long)
은 음수 인덱스를 지원하지 않습니다. 즉, 모든 세그먼트를 탐색하지 않고 두 번째에서 마지막까지 문자를 가져올 수 없습니다.- 두 개의
SequencePosition
를 비교할 수 없어 다음 작업을 수행하기 어렵습니다.- 한 위치가 다른 위치보다 크거나 작은 경우를 확인합니다.
- 몇 가지 구문 분석 알고리즘을 작성합니다.
ReadOnlySequence<T>
는 개체 참조보다 크고, 가능하면 in 또는 ref에 의해 전달되어야 합니다.in
또는ref
를 통해ReadOnlySequence<T>
를 전달하면 구조체 복사본이 줄어듭니다.- 빈 세그먼트:
ReadOnlySequence<T>
내에서 유효합니다.ReadOnlySequence<T>.TryGet
메서드를 사용하여 반복할 때 표시될 수 있습니다.ReadOnlySequence<T>.Slice()
메서드와SequencePosition
개체를 사용하여 시퀀스를조각화할 수 있습니다.
ReadOnlySequence<T>
처리를 간소화하기 위해 .NET Core 3.0에 도입된 새로운 형식입니다.- 단일 세그먼트
ReadOnlySequence<T>
와 다중 세그먼트ReadOnlySequence<T>
간의 차이를 통합합니다. - 세그먼트 간에 분할될 수 있거나 분할될 수 없는 이진 및 텍스트 데이터(
byte
및char
)를 읽기 위한 도우미를 제공합니다.
이진 데이터와 구분된 데이터를 모두 처리하는 기본 제공 메서드가 있습니다. 다음 섹션에서는 이러한 메서드가 SequenceReader<T>
에서 어떻게 나타나는지 보여 줍니다.
SequenceReader<T>
에는 ReadOnlySequence<T>
내에서 직접 데이터를 열거하는 메서드가 있습니다. 다음 코드는 ReadOnlySequence<byte>
를 한 번에 byte
처리하는 예제입니다.
while (reader.TryRead(out byte b))
{
Process(b);
}
CurrentSpan
는 현재 세그먼트의 Span
을 노출합니다. 이는 메서드에서 수동으로 수행된 것과 비슷합니다.
다음 코드는 SequenceReader<T>
를 사용한 FindIndexOf
구현 예제입니다.
SequencePosition? FindIndexOf(in ReadOnlySequence<byte> buffer, byte data)
{
var reader = new SequenceReader<byte>(buffer);
while (!reader.End)
{
// Search for the byte in the current span.
var index = reader.CurrentSpan.IndexOf(data);
if (index != -1)
{
// It was found, so advance to the position.
reader.Advance(index);
return reader.Position;
}
// Skip the current segment since there's nothing in it.
reader.Advance(reader.CurrentSpan.Length);
}
return null;
}
다음 예제에서는 ReadOnlySequence<byte>
의 시작 부분에서 4바이트 Big Endian 정수 길이를 구문 분석합니다.
bool TryParseHeaderLength(ref ReadOnlySequence<byte> buffer, out int length)
{
var reader = new SequenceReader<byte>(buffer);
return reader.TryReadBigEndian(out length);
}
static ReadOnlySpan<byte> NewLine => new byte[] { (byte)'\r', (byte)'\n' };
static bool TryParseLine(ref ReadOnlySequence<byte> buffer,
out ReadOnlySequence<byte> line)
{
var reader = new SequenceReader<byte>(buffer);
if (reader.TryReadTo(out line, NewLine))
{
buffer = buffer.Slice(reader.Position);
return true;
}
line = default;
return false;
}
SequenceReader<T>
는 변경 가능한 구조체이므로 항상 reference로 전달되어야 합니다.SequenceReader<T>
는 ref struct이므로 동기 메서드에만 사용할 수 있고 필드에 저장할 수 없습니다. 자세한 내용은 할당 방지를 참조하세요.SequenceReader<T>
는 정방향 전용 판독기로 사용하도록 최적화되어 있습니다.Rewind
는 다른Read
,Peek
및IsNext
API를 활용하여 해결할 수 없는 작은 백업에 사용됩니다.
.NET 피드백
.NET은(는) 오픈 소스 프로젝트입니다. 다음 링크를 선택하여 피드백을 제공해 주세요.
추가 리소스
설명서
-
.NET에서 I/O 파이프라인을 효율적으로 사용하고 코드에서 문제를 방지하는 방법에 대해 알아봅니다.
-
System.IO.Pipelines를 사용하는 고성능 IO
System.IO.Pipelines는 .NET Core 팀이 .NET에서 고성능 IO를 보다 쉽게 수행할 수 있도록 하기 위해 수행한 작업에서 탄생했습니다.이 에피소드에서 Pavel Krymets(@pakrym)와 데이비드 파울러(@davidfowl)는 파이프라인 프로그래밍 모델의 작동 방식에 대한 개요를 제공하고 API를 사용하는 방법에 대한 몇 가지 데모를 보여 줍니다.[00:26] - System.IO.Pipelines의 근거는 무엇인가요?[02:10] - 파이프와 스트림 간의 성능 비교[04:17] - Stream 작업 관련 문제[09:42] - 파이프로 이동[13:45] - 클라이언트 서버 데모[22:16] - 파이프가 C# 8 IAsyncEnumerable에서 어떻게 작동할 수 있나요?[26:04] - 할당 감소[28:46] - 파이프라인 시작유용한 링크NuGet의 System.IO.PipelinesSystem.IO.Pipelines: .NET의 고성능 IOTCP Echo 샘플ValueTask
-
.NET의 비동기 파일 I/O에 대해 알아봅니다. ReadAsync, WriteAsync 등의 비동기 작업을 간소화하는 비동기 메서드를 알아봅니다.