Span и Memory: stackalloc, ref struct

↑ 2.1 C♯: продвинутый язык · ← Предыдущая · → Следующая

Обязательно~6 мин чтенияУровень: middle

Зачем это на собесе

Span<T> — главный инструмент высокопроизводительного .NET: парсинг, работа с буферами и файлами без аллокаций. На senior-собесе спросят, что такое Span, чем он отличается от Memory и почему Span нельзя использовать в async.

Объяснение

Span<T> и ReadOnlySpan<T>

Непрерывный участок памяти любого происхождения: массив, часть массива, строка, память на стеке (stackalloc), нативная память. Это ref struct: указатель + длина, без копирования данных.

int[] arr = [1, 2, 3, 4, 5];
Span<int> middle = arr.AsSpan(1, 3);    // [2, 3, 4] — вид на тот же массив, без копии
middle[0] = 20;                          // arr[1] теперь 20
 
ReadOnlySpan<char> s = "Иванова Анна Петровна".AsSpan();
var space = s.IndexOf(' ');
ReadOnlySpan<char> lastName = s[..space];      // подстрока без аллокации (в отличие от Substring)
 
Span<byte> buffer = stackalloc byte[256];      // буфер на стеке — ноль аллокаций в куче

Парсинг без аллокаций

// разбор строки "J06.9;J20.0;I10" без Split (Split создаёт массив и строки)
static int CountCodes(ReadOnlySpan<char> input)
{
    var count = 0;
    foreach (var range in input.Split(';'))       // .NET 9: MemoryExtensions.Split по Span
        if (!input[range].Trim().IsEmpty) count++;
    return count;
}
 
int.Parse("12345".AsSpan(1, 3));               // парсинг части строки
Utf8Formatter.TryFormat(value, buffer, out var written);

Ограничения ref struct

Span живёт только на стеке:

  • нельзя хранить в поле класса, в обычной struct, в массиве;
  • нельзя боксить, приводить к object или интерфейсу;
  • нельзя использовать через await (в async-методах — с C# 13 можно в синхронных участках между await) и в итераторах yield;
  • нельзя захватить лямбдой. Причина: Span может указывать на стек текущего метода, и хранение его дольше жизни метода дало бы висячую ссылку.

Memory<T> и ReadOnlyMemory<T>

Обычная struct (не ref struct) — «Span, который можно хранить»: поле класса, передача через await. Когда нужно работать — memory.Span.

public async Task ProcessAsync(ReadOnlyMemory<byte> data, CancellationToken ct)
{
    await _stream.WriteAsync(data, ct);          // Stream API принимает Memory
    Parse(data.Span);                            // синхронная обработка — через Span
}

Где это есть в .NET

Stream.ReadAsync(Memory<byte>), System.Text.Json (Utf8JsonReader — ref struct на Span), PipeReader/PipeWriter (System.IO.Pipelines — Kestrel), ArrayPool<T> + Span, string.Create, SearchValues<T> (.NET 8) для быстрого поиска символов, CollectionsMarshal.AsSpan(list).

Связка с ArrayPool

var rented = ArrayPool<byte>.Shared.Rent(64 * 1024);          // взять буфер из пула (может быть больше запрошенного)
try
{
    int read;
    while ((read = await source.ReadAsync(rented.AsMemory(), ct)) > 0)
        await target.WriteAsync(rented.AsMemory(0, read), ct);
}
finally { ArrayPool<byte>.Shared.Return(rented); }            // обязательно вернуть

Нюансы и подводные камни

  • stackalloc большого размера → StackOverflowException. Правило: до ~1 КБ на стеке, больше — ArrayPool.
  • Span поверх арендованного массива нельзя использовать после Return — это use-after-free на уровне логики.
  • Не оптимизируйте преждевременно: Span нужен в горячих путях (парсеры, сериализация, обработка потоков), а не в каждом контроллере.
  • ToString()/ToArray() у Span возвращает копию — аллокация.

Тестирование

Производительность проверяют бенчмарком, корректность — обычными тестами:

[Theory]
[InlineData("A;B; ;C", 3)]
[InlineData("", 0)]
public void Counts_codes(string input, int expected) => CountCodes(input).Should().Be(expected);
[MemoryDiagnoser]
public class ParseBench
{
    [Benchmark(Baseline = true)] public int SplitCount() => "A;B;C".Split(';').Length;
    [Benchmark] public int SpanCount() => CountCodes("A;B;C");   // сравнить Allocated
}

Вопросы с ответами

Связанные темы