У каждого, кто писал обработку текста или байтовых буферов, есть привычка, которая не ощущается как проблема: достать подстроку, скопировать кусок массива, собрать временный буфер и выбросить его через миллисекунду. Строка в C# неизменяемая, поэтому Substring всегда создаёт новую, а массивы не умеют быть окном в чужие данные, и каждая операция над фрагментом оборачивается аллокацией. На маленьких объёмах это незаметно, но парсер, который обрабатывает миллионы строк в секунду, превращает нулевое поколение сборщика мусора в круглосуточную смену. Типы Span<T> и Memory<T> из .NET Core 2.1 решают именно эту задачу: они дают безопасное окно над непрерывным куском памяти без единого копирования. Разбираем, как это устроено, где спаны дают измеримый выигрыш и в какие правила упирается их сила.
Устройство Span как ref struct и правила его времени жизни
Span<T> это тонкая структура из двух полей: ссылки на первый элемент и длины. На x64 она занимает 16 байт, то есть помещается в регистры и стоит примерно ничего. Ключевой трюк в том, что Span объявлен как ref struct и живёт только на стеке. Компилятор жёстко следит за этим: спан нельзя положить в поле класса, сохранить в элемент массива, захватить лямбдой или использовать внутри метода с await: после перехода через await стекового кадра уже нет, а окно осталось бы висеть над чужими данными. Это не придирка компилятора, а гарантия безопасности: ссылка на стек или на массив, который уже собрали, была бы висячим окном в чужую память.
// так компилятор откажется компилировать
async Task<int> ReadAsync(Stream stream)
{
Span<byte> buffer = stackalloc byte[1024];
await stream.ReadAsync(buffer); // спан запрещён в async
return buffer[0];
}
Запрет суров, но с точной границей: синхронный код работает со спаном напрямую, а всё, что переживает await или попадает в поля, уезжает в Memory<T>. Граница между типами и есть главная дисциплина спанов.
Само окно умеет нарезать: метод Slice возвращает новый спан по смещению и длине, и это лишь пара арифметических операций без обращения к куче. Индексатор проверяет границы и выбрасывает ArgumentOutOfRangeException при выходе за пределы, поэтому спаны безопаснее сырых указателей при той же цене доступа. В каноническом цикле for по Span<T> JIT убирает проверку границ и векторизует тело так же, как для массива, то есть за безопасность не приходится платить скоростью.
Нарезка строк и массивов без копирования через Slice и AsSpan
Классическая боль начинается со строк. Вызов Substring(6, 5) копирует пять символов в новый объект строки, а строка в формате UTF-16 занимает два байта на символ плюс заголовок объекта. Метод AsSpan возвращает над той же строкой окно ReadOnlySpan<char>:
string line = "id=42;amount=19.5";
ReadOnlySpan<char> amount = line.AsSpan(13, 4); // "19.5" без аллокации
decimal value = decimal.Parse(amount);
Здесь "19.5" это не новая строка, а смещение и длина. Парсеры чисел научились принимать спаны начиная с .NET Core 3.0, поэтому даже разбор значения не тянет за собой промежуточных строк. Для поиска есть IndexOf, LastIndexOf, Trim и StartsWith, целый набор операций над фрагментами без превращения их в строки.
Пример из жизни, разбор строки параметров. Прямой путь через Split режет строку в массив строк: одна аллокация массива плюс по строке на каждый токен, и все они едут в мусор после одного прохода. Спан-версия ходит по той же строке окном:
ReadOnlySpan<char> text = line;
while (!text.IsEmpty)
{
int sep = text.IndexOf(';');
ReadOnlySpan<char> token = sep < 0
? text
: text[..sep];
// обработка token без единой аллокации
text = sep < 0 ? ReadOnlySpan<char>.Empty : text[(sep + 1)..];
}
На короткой строке разница микроскопическая. На парсере, который съедает гигабайтный файл по строке за раз, разница превращается в сотни мегабайт мусора против нуля на токенизацию, а сборщик мусора исчезает из самого горячего участка профайлера.
Честный масштаб показывает простой бенчмарк:
[MemoryDiagnoser]
public class ParseBenchmarks
{
private const string Line = "id=42;amount=19.5";
[Benchmark]
public decimal WithSubstring()
{
return decimal.Parse(Line.Substring(13, 4));
}
[Benchmark]
public decimal WithSpan()
{
return decimal.Parse(Line.AsSpan(13, 4));
}
}
Время у вариантов почти одинаковое, а в колонке Allocated у версии с Substring стабильно стоит несколько десятков байт на вызов против нуля у спана: новая строка из четырёх символов это заголовок объекта плюс два байта на символ. Умножьте на частоту: миллион вызовов в минуту даёт сотни мегабайт мусора в час.
Стек для маленьких буферов и пул аренды для больших массивов
Второй источник аллокаций это временные буферы. Для мелких спасает стек: начиная с C# 7.2 stackalloc работает в безопасном коде и отдаёт память прямо из стекового кадра метода.
Span<byte> scratch = stackalloc byte[256];
Fill(scratch);
int written = Encode(scratch);
Ограничение простое: стек потока измеряется мегабайтами, глубина вызовов его съедает, поэтому разумный потолок для stackalloc это несколько килобайт на один кадр. Буфер живёт ровно до выхода из метода, что и требуется временному хранилищу.
Для буферов побольше подходит аренда в ArrayPool<T>.Shared. Пул выдаёт массив с запасом до следующей степени двойки, поэтому работают с срезом нужной длины и обязательно возвращают буфер в finally, иначе следующий арендатор получит чужие значения, а экономия обернётся трудноуловимой порчей данных:
byte[] rented = ArrayPool<byte>.Shared.Rent(64 * 1024);
try
{
Span<byte> buffer = rented.AsSpan(0, 64 * 1024);
// тяжёлая работа без новых аллокаций
}
finally
{
ArrayPool<byte>.Shared.Return(rented);
}
Пул внутри устроен как набор корзин по степеням двойки: заказ 65 тысяч байт закончится массивом на 131072 элемента, из которого через AsSpan(0, 65000) берут нужный срез, остальное игнорируется.
Связка стека и пула закрывает весь спектр временных буферов, оставляя кучу только для данных с настоящим временем жизни. В замерах это выглядит как ровная колонка Allocated с нулём на участках, где раньше стояли килобайты.
Memory для асинхронного кода и граница между двумя типами
Спан не пересекает await, а реальный ввод-вывод асинхронен. Для этого существуют Memory<T> и ReadOnlyMemory<T>: они хранят ссылку на источник, смещение и длину, живут в куче, спокойно ложатся в поля и переживают любые await. Потоковые операции ещё в .NET Core 2.1 получили перегрузки ReadAsync, принимающие Memory<byte> вместо byte[]:
async Task<int> ReadChunkAsync(Stream stream, Memory<byte> memory)
{
int total = 0;
while (total < memory.Length)
{
int read = await stream.ReadAsync(memory[total..]);
if (read == 0)
{
break;
}
total += read;
}
return total;
}
Правило одно: свойство memory.Span достаёт окно в точке использования, окно отпускается до следующего await. Если владение буфером размывается между методами, на сцену выходит IMemoryOwner<T> из MemoryPool<T>.Shared: арендатор держит владельца, а не массив, и в Dispose возвращает память в пул.
Там, где данные приходят из сети кусками произвольного размера, живёт ReadOnlySequence<T> из пакета System.IO.Pipelines: PipeReader перемещается по цепочке сегментов без склеивания их в один массив, а вспомогательный SequenceReader<T> продвигается по байтам и числам. Это принцип окна над чужой памятью, доведённый до потоковых протоколов.
Разбор данных без мусора от парсеров чисел до Utf8JsonReader
Спаны изменили культуру разбора данных. Числа читаются прямо из окна через int.Parse и decimal.Parse по ReadOnlySpan<char>, а для байтовых данных есть класс Utf8Parser из System.Buffers.Text, который разбирает цифры в UTF-8 без конвертации в строку. В .NET 7 контракт формализовал интерфейс ISpanParsable<TSelf>, через который собственные типы учат парситься из спана без промежуточных объектов.
Место, где экономия особенно заметна, это JSON в байтах. Utf8JsonReader из System.Text.Json ходит по массиву байтов напрямую, а сравнение имени свойства делается методом ValueTextEquals, который не создаёт строку вообще:
var reader = new Utf8JsonReader(jsonBytes);
while (reader.Read())
{
if (reader.TokenType == JsonTokenType.PropertyName
&& reader.ValueTextEquals("price"))
{
reader.Read();
decimal price = reader.GetDecimal();
}
}
Старый путь с конвертацией в строки работает честно, но на маршруте, где JSON приходит десятками тысяч пакетов в секунду, сравнения имён и конвертация значений дают значимую долю мусора. Окно над байтами убирает её целиком, оставляя аллокации только там, где приложение само решает создать объект.
Отдельная экономия живёт в кодировках. Строка в C# хранится в UTF-16 и тратит два байта на символ, а данные из сети приходят в UTF-8, поэтому каждый GetString для конвертации байтов аллоцирует новую строку и раздувает объём. Спан над байтами UTF-8 позволяет отложить конвертацию до момента, когда она действительно нужна, а Utf8JsonReader вообще читает текст без создания строковых объектов.
Сам Utf8JsonReader объявлен как ref struct и живёт по тем же правилам, что и Span: он не пересекает await, не хранится в полях, зато целиком помещается в стековый кадр и стоит копейки. Для документов, которые не влезают в один буфер, есть JsonDocument: он арендует память через пул и требует вызвать Dispose, иначе пул не вернёт буферы в оборот.
Нативная память и межъязыковое взаимодействие через MemoryMarshal
Спаны не привязаны к управляемым массивам, окно можно открыть и над нативной памятью. Блок из неуправляемой кучи, выделенный Marshal.AllocHGlobal, работает как обычный буфер:
IntPtr block = Marshal.AllocHGlobal(4096);
try
{
unsafe
{
var span = new Span<byte>(block.ToPointer(), 4096);
span.Fill(0);
}
}
finally
{
Marshal.FreeHGlobal(block);
}
Класс MemoryMarshal добирается туда, куда обычный код не может: метод Read читает структуру прямо из байтового окна без копирования в промежуточный массив, Cast интерпретирует байты как другой примитивный тип. Взаимодействие с нативными библиотеками через P/Invoke на спанах избавляет от двойного копирования данных через Marshal.PtrToStructure: под окно память не выделяется, структура читается на месте.
Чтение заголовка протокола выглядит как одна строка:
ReadOnlySpan<byte> raw = ReadHeader();
int version = MemoryMarshal.Read<int>(raw[..4]);
Никаких промежуточных массивов и вызовов PtrToStructure, значение читается из окна напрямую. Работает это только с типами без ссылок на управляемые объекты, ограничение проверяется на компиляции.
Сила имеет цену. Нативный блок не собирается сборщиком мусора: забыли освободить и получили утечку, невидимую в отчётах по управляемой куче. Поэтому спаны над нативной памятью требуют структурных шаблонов: класс-владелец, Dispose, явные границы ответственности. Там, где границы владельца размыты, дешевле скопировать данные в управляемый массив, чем неделю искать пропавшую память.
Практические правила применения спанов без стрельбы по ногам
Собранные с боёв правила выглядят так:
- измерьте сначала: спаны лечат аллокации и копирования, а не медленные алгоритмы, если в колонке Allocated уже ноль, переделка на спаны ничего не даст;
- держите Span внутри одного синхронного вызова, всё, что пересекает await или попадает в поля, оформляйте как Memory;
- stackalloc для буферов до нескольких килобайт, для больших арендуйте в ArrayPool и возвращайте в finally;
- не храните окно дольше источника: спан над буфером, который уже вернули в пул, читает чужие данные;
- сравнивайте фрагменты прямо в памяти через SequenceEqual и ValueTextEquals вместо конвертации в строки;
- проверяйте границы через Slice и Length, исключение ArgumentOutOfRange дешевле порчи памяти, которую отладишь часами;
- не тащите спаны туда, где падает читаемость: три вызова Substring в инициализации приложения не стоят вычурного разбора.
Итог по механике простой: Span это окно с дисциплиной, Memory это окно с пропуском в асинхронность, а стек и пул это склад расходников. Читается это как лишние правила, но на горячих маршрутах, от парсеров логов до сетевых протоколов, связка снимает с кучи нагрузку, которую раньше оплачивали поколениями мусора. Копирование данных не зло само по себе, зло это копирование, за которым не стоит ни одна осмысленная причина. Спаны делают бессмысленные копии видимыми, а осмысленные оставляют на месте. Начинать стоит с одного профиля и одного горячего метода, дальше механика говорит сама за себя.