Прочитать на английском

Поделиться через


Работа с буферами в .NET

В этой статье описаны типы, которые помогают выполнять чтение данных, проходящих через несколько буферов. В основном они используются для поддержки объектов PipeReader.

IBufferWriter<T>

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);
}

Предыдущий метод выполняет такие действия:

  • Запрашивает буфер длиной не менее 5 байт у IBufferWriter<byte> с помощью GetSpan(5).
  • Записывает байты для строки Hello (ASCII) при получении Span<byte>.
  • Вызывает IBufferWriter<T>, чтобы указать, сколько байтов было записано в буфер.

Этот метод записи использует буфер Memory<T>/Span<T>, предоставленный IBufferWriter<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>, чье резервное хранилище представляет собой единый смежный массив.

Распространенные проблемы с IBufferWriter

  • GetSpan и GetMemory возвращают буфер по крайней мере с запрошенным объемом памяти. Не рассчитывайте на точный размер буфера.
  • Нет никакой гарантии, что последовательные вызовы будут возвращать один и тот же буфер или буфер того же размера.
  • Чтобы продолжить запись дополнительных данных, необходимо запросить новый буфер после вызова Advance. Невозможно выполнить запись в ранее полученный буфер после вызова Advance.

ReadOnlySequence<T>

ReadOnlySequence showing memory in pipe and below that sequence position of read-only memory

ReadOnlySequence<T> — это структура, которая может представлять смежную или несмежную последовательность T. Она может состоять из следующих компонентов:

  1. Выполнение команды T[]
  2. Выполнение команды ReadOnlyMemory<T>
  3. Пара узла связанного списка 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>

Обработка ReadOnlySequence<T> может оказаться сложной задачей, так как данные могут находиться в нескольких сегментах в последовательности. Для лучшей производительности разделите код на две задачи:

  • более быстрая задача для операции с одним сегментом;
  • более медленная задача для операций с данными, распределенными между несколькими сегментами.

Существует несколько способов обработки данных в последовательностях с несколькими сегментами:

  • Используйте SequenceReader<T>.
  • Анализируйте данные по сегментам. При этом следите за значением SequencePosition и индексом в проанализированном сегменте. Это позволяет избежать ненужного распределения. Но такой способ может оказаться неэффективным, особенно для небольших буферов.
  • ReadOnlySequence<T> Скопируйте в смежный массив и обработайте его как один буфер:
    • Если размер ReadOnlySequence<T> небольшой, возможно, лучше будет скопировать данные в буфер, размещенный в стеке, с помощью оператора stackalloc.
    • Скопируйте ReadOnlySequence<T> в массив в пуле с помощью ArrayPool<T>.Shared.
    • Используйте ReadOnlySequence<T>.ToArray(). Не рекомендуется использовать в критических путях, так как выделяется новый экземпляр T[] в куче.

В следующих примерах показаны некоторые распространенные сценарии обработки ReadOnlySequence<byte>.

Обработка двоичных данных

В следующем примере обрабатывается целое число (длиной 4 байта) с обратным порядком байтов с начала ReadOnlySequence<byte>.

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;
}
Обработка текстовых данных

Следующий пример:

  • Выполняется поиск первого символа новой строки (\r\n) в ReadOnlySequence<byte>, которое возвращается через выходной параметр 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:

  • Сочетание ReadOnlySequence<T>.Slice со структурой SequencePosition, указывающей на пустой сегмент, сохраняет такой сегмент.
  • ReadOnlySequence<T>.Slice с int позволяет пропустить пустой сегмент.
  • При перечислении ReadOnlySequence<T> перечисляются пустые сегменты.

Потенциальные проблемы с ReadOnlySequence<T> и SequencePosition

При использовании ReadOnlySequence<T>/SequencePosition вместо обычной структуры ReadOnlySpan<T>/ReadOnlyMemory<T>/T[]/int могут возникнуть нетипичные результаты:

  • SequencePosition — это метка позиции определенного объекта ReadOnlySequence<T>, а не абсолютная позиция. Так как эта метка связана с определенным типом ReadOnlySequence<T>, нет смысла ее использовать за пределами ReadOnlySequence<T>, где она была создана.
  • Арифметические операции с SequencePosition нельзя выполнять без ReadOnlySequence<T>. Это означает, что при выполнении простых операций, например position++, записывается position = ReadOnlySequence<T>.GetPosition(1, position).
  • GetPosition(long)не поддерживает отрицательные индексы. Таким образом, чтобы получить предпоследний символ, необходимо пройти все сегменты.
  • Два SequencePosition не могут быть сравниваться, что затрудняет:
    • Определение того, является ли значение позиции больше или меньше по отношению к другой позиции.
    • Написание некоторых алгоритмов анализа.
  • Размер последовательности ReadOnlySequence<T> больше чем у ссылки на объект, поэтому по возможности последовательность следует передавать с помощью in или ref. Передача ReadOnlySequence<T> посредством in или ref позволяет сократить количество копирований структуры.
  • Пустые сегменты:
    • Допускаются в ReadOnlySequence<T>.
    • Могут появиться при итерации с помощью метода ReadOnlySequence<T>.TryGet.
    • Могут появиться при разбиении последовательности с помощью метода ReadOnlySequence<T>.Slice() с объектами SequencePosition.

SequenceReader<T>

SequenceReader<T>:

  • Новый тип, который появился в .NET Core 3.0. Он позволяет упростить обработку ReadOnlySequence<T>.
  • Обобщает различия между 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 текущего сегмента, что аналогично операциям в методе, которые выполнялись вручную.

Использование позиции

В следующем коде приведен пример реализации FindIndexOf с использованием SequenceReader<T>:

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;
}

Обработка двоичных данных

В следующем примере обрабатывается целое число (длиной 4 байта) с обратным порядком байтов с начала ReadOnlySequence<byte>.

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;
}

Распространенные проблемы c SequenceReader<T>

  • SequenceReader<T> представляет собой изменяемую структуру, которую всегда нужно передавать с помощью ссылки.
  • SequenceReader<T> — это ссылочная структура, которую можно использовать только в синхронных методах и нельзя хранить в полях. Дополнительные сведения см. в разделе "Избегание выделения".
  • Структура SequenceReader<T> оптимизирована для использования в качестве средства чтения с последовательным доступом. Rewind предназначается для небольших резервных копий, к которым нельзя обращаться с использованием других API Read, Peek и IsNext.