Что такое Span<T> в C# и когда он действительно ускоряет ваш код?
Span<T> -- это ref struct, живущий только в стеке и указывающий на память, которой вы уже владеете, поэтому у него нет собственной аллокации. Он ускоряет код ровно в трёх ситуациях: замена буфера в куче на stackalloc, нарезка без копирования и плотные циклы, где JIT убирает проверки границ. В остальных случаях он ничего не меняет, а через await он не компилируется.
Span<T> — это ref struct, живущий только в стеке и представляющий непрерывную область памяти, которой вы уже владеете: массив, его срез, буфер stackalloc, фрагмент строки или неуправляемую память. Это управляемая ссылка плюс длина, не более того. Он не выделяет память, не копирует и не может расти. Это весь тип целиком. Причина, по которой к нему прибегают, — производительность, но он ускоряет код лишь в трёх конкретных ситуациях: когда он позволяет заменить аллокацию в куче на stackalloc, когда он позволяет нарезать буфер без копирования, и когда он превращает цикл в форму, из которой JIT может убрать проверки границ. Вне этих случаев span — это инструмент ясности, а не производительности, и втискивать его в код, который не делает ни одного из трёх, не даёт вам ничего. Эта статья ориентирована на .NET 11 и C# 14, хотя сам Span<T> есть в BCL начиная с .NET Core 2.1 и в языке начиная с C# 7.2.
Ловушка в том, что “используйте Span<T>, это быстрее” повторяют без второй половины фразы. Так что вот вам вторая половина: что этот тип представляет собой на самом деле, точные механизмы, которыми он экономит такты, и не менее важный список случаев, где вставка span меняет сгенерированный код приблизительно на ноль.
Представление над памятью, а не контейнер
Ментальная модель, которая устраняет большую часть путаницы: Span<T> — это не коллекция. Это окно. List<T> или T[] владеют своим хранилищем, живут в куче, и сборщик мусора их отслеживает. Span<T> не владеет ничем. Он держит ссылку на начало некоторой памяти и счётчик того, сколько элементов действительны. Создайте его — и никакой аллокации не происходит, потому что выделять нечего: байты уже существуют где-то, а span лишь именует их участок.
// .NET 11, C# 14
int[] numbers = { 10, 20, 30, 40, 50 };
Span<int> all = numbers; // a view over the whole array, no copy
Span<int> middle = all.Slice(1, 3); // {20, 30, 40}, still the same backing memory
middle[0] = 99; // writes THROUGH to numbers[1]
Console.WriteLine(numbers[1]); // 99
middle не копировал три целых числа. Это ссылка на numbers[1] плюс длина 3. Запись через него пишет в исходный массив, потому что массив только один. Этот алиасинг и есть весь смысл: span — это дешёвый, типизированный дескриптор с проверкой границ на память, которая живёт в другом месте.
Поскольку среда выполнения гарантирует, что ref struct может жить только в стеке, span безопасно указывает на память стека (буфер stackalloc) без опасностей времени жизни, которые создала бы ссылка из кучи на стек. Эта же гарантия — источник всех ограничений типа, к которым мы перейдём. Сначала — то, ради чего вы пришли.
Откуда на самом деле берётся скорость
Span ускоряет код тремя различными механизмами. Они независимы: данный фрагмент кода может задействовать один, два или ни одного. Если он не задействует ни одного, span не делает ничего для вашего времени выполнения.
Механизм 1: он позволяет вообще не выделять память
Это главный, и на самом деле работу делает не span. Span — это безопасный дескриптор, который делает stackalloc пригодным к использованию. Небольшой временный буфер (форматирование числа, построение ключа поиска, хеширование нескольких байт) традиционно означал new byte[n] или new char[n] в куче, который затем должен собрать GC. С stackalloc буфер живёт в кадре стека и исчезает бесплатно, когда метод возвращается. Span<T> — это то, как вы безопасно читаете и пишете эту память стека.
// .NET 11, C# 14 -- format an int to text with zero heap allocation
public static string ToHex(int value)
{
Span<char> buffer = stackalloc char[8]; // on the stack, not the heap
value.TryFormat(buffer, out int written, "X");
return new string(buffer[..written]); // the only allocation is the final string
}
Выигрыш измеряется в давлении на GC, а не в чистой скорости цикла. Выделяйте миллион крошечных одноразовых буферов в секунду — и вы порождаете миллион объектов, которые сборщик gen-0 должен обойти. Перенесите их в stackalloc — и это давление падает до нуля. На горячем пути устранение аллокаций часто даёт больший сквозной выигрыш, чем сокращение инструкций в цикле, потому что паузы GC затрагивают весь процесс, а не только ваш метод. Это тот же инстинкт, что стоит за params ReadOnlySpan
Механизм 2: он позволяет нарезать без копирования
Второй механизм — Slice. У string взятие подстроки через Substring выделяет совершенно новую string и копирует символы. У массива GetRange или Skip/Take из LINQ, материализующиеся в новую коллекцию, тоже копируют. Slice у span не делает ни того, ни другого: он возвращает другой span, указывающий на ту же память, со скорректированными смещением и длиной. Ноль копий, ноль аллокаций.
// .NET 11, C# 14 -- parse "2026-06-20" with no substring allocations
public static (int Year, int Month, int Day) ParseIsoDate(ReadOnlySpan<char> date)
{
int year = int.Parse(date.Slice(0, 4)); // no new string
int month = int.Parse(date.Slice(5, 2));
int day = int.Parse(date.Slice(8, 2));
return (year, month, day);
}
var parsed = ParseIsoDate("2026-06-20"); // string converts to ReadOnlySpan<char> implicitly
Каждый int.Parse здесь читает прямо из среза исходной строки. Старая версия с date.Substring(0, 4) выделяла бы три недолговечные строки на вызов. В парсере, проходящем по миллионам строк, это миллионы избегнутых аллокаций. Span-перегрузки int.Parse, DateTime.Parse, Guid.Parse и им подобных существуют именно для того, чтобы вы могли разбирать срезы, никогда не материализуя подстроку. Это основа быстрого разбора CSV и логов, поэтому чтение большого CSV без нехватки памяти опирается на нарезку span, чтобы проходить каждую строку на месте.
Механизм 3: JIT убирает проверки границ в плотных циклах
Третий механизм — самый тонкий и тот, который чаще всего призывают, не понимая. Когда вы итерируете span циклом for, ограниченным span.Length, JIT может доказать, что каждый индекс находится в диапазоне, и полностью убрать проверку границ для каждого элемента. Он распознаёт шаблон for (int i = 0; i < span.Length; i++) и знает, что span[i] не может выйти за диапазон, поэтому отбрасывает сравнение и ветвление, которые иначе защищали бы каждый доступ. Команда JIT в Microsoft потратила годы, обучая RyuJIT распознавать проверки границ span так же, как проверки границ массива, и .NET 10 сделал лежащий в основе анализ утверждений менее зависимым от порядка, чтобы больше форм цикла подходили, как описано в статье Performance Improvements in .NET 10.
Сравните это с итерацией List<T> через её перечислитель. List<T>.Enumerator.MoveNext выполняет проверку версии на каждом шаге (механизм, который бросает InvalidOperationException, если вы изменяете список в середине итерации) плюс проверку границ. Эта проверка версии — функция корректности, а не лишняя трата, но она стоит тактов, которые span никогда не платит.
// .NET 11, C# 14, BenchmarkDotNet 0.14.x -- dotnet run -c Release
[MemoryDiagnoser]
public class SumBench
{
private List<int> _list = null!;
[GlobalSetup]
public void Setup() => _list = new List<int>(Enumerable.Range(0, 10_000));
[Benchmark(Baseline = true)]
public long ListForeach()
{
long sum = 0;
foreach (int x in _list) sum += x; // version + bounds check per step
return sum;
}
[Benchmark]
public long SpanForeach()
{
long sum = 0;
Span<int> span = CollectionsMarshal.AsSpan(_list); // a view, no copy
foreach (int x in span) sum += x; // bounds checks elided
return sum;
}
}
Репрезентативные результаты на сборке Ryzen 7 / Windows 11 / .NET 11, x64 RyuJIT:
| Method | Mean | Ratio | Allocated |
|---|---|---|---|
ListForeach | 6.1 us | 1.00 | 0 B |
SpanForeach | 2.4 us | 0.39 | 0 B |
Примерно в 2,5 раза быстрее, без аллокаций в обоих случаях (список уже существует; CollectionsMarshal.AsSpan отдаёт span над его внутренним массивом без копирования). Точное соотношение меняется с типом элемента и CPU, но направление стабильно. Однако обратите внимание на единицу измерения: это микросекунды на 10 000 элементов. Это число — целиком причина для следующего раздела.
Когда Span не делает для вас ничего
Вот часть, которую культ-карго версия этого совета опускает. Span помогает только тогда, когда задействован один из трёх механизмов. Вставьте его в код, который не запускает ни одного, — и вы написали более ограниченный код с тем же временем выполнения. Хуже того, вы могли сделать его медленнее или сломать компиляцию.
Вы преобразуете в span и сразу копируете наружу. Если ваша “оптимизация” — это array.AsSpan().ToArray() или нарезка span только ради .ToArray() результата, вы всё равно выделили память. Копия — это и есть цена; span перед ней не дал ничего. Выигрыш от механизма 2 существует лишь пока вы продолжаете читать через представление.
Цикл не горячий. Механизм 3 сэкономил 3,7 микросекунды на 10 000 элементов. Если этот цикл выполняется раз на веб-запрос или несколько сотен раз в сумме, вы никогда не измерите разницу на фоне сетевой и серверной задержки, которая превосходит её на пять порядков. Корёжить читаемый код, чтобы срезать микросекунды с холодного пути, — чистый убыток: вы платите ясностью и ограничениями за ускорение, которое никто не может наблюдать. Span зарабатывают своё место в парсерах, сериализаторах и внутренних циклах, выполняемых миллионы раз, а не в эпизодическом обходе коллекции.
У вас уже был массив, и вы лишь читаете его последовательно. Обычный foreach по T[] уже получает устранение проверок границ от JIT; массивы — это исходный случай, для которого эта оптимизация создавалась. Обернуть массив в span сначала не делает цикл быстрее, потому что цикл по массиву уже был быстрым. Span помогает, когда источник — List<T> (чей перечислитель несёт проверку версии) или когда вам нужно нарезать, а не когда у вас уже есть массив и вы проходите его от начала до конца.
Вы навязываете слишком большой stackalloc. Механизм 1 выигрывает только на маленьких буферах. stackalloc большого или задаваемого вызывающим размера рискует переполнением стека, а это сбой, а не медленный путь. Обычная рекомендация — ограничить stackalloc небольшой константой (как правило, от нескольких сотен байт до ~1 КБ) и переходить на пулинговый или кучный массив сверх этого. Span над слишком большим stackalloc не быстрее — это латентное StackOverflowException.
Честный тест перед тем, как тянуться к span: какой из трёх механизмов я покупаю? Если вы не можете назвать ни одного, вы тянетесь к типу по привычке. Руководство по выбору List vs Span vs ReadOnlySpan проходит полную ось владения и времени жизни, если вы выбираете между ними для конкретного поля или возвращаемого значения.
Ограничения и почему они существуют
Каждое ограничение Span<T> следует из одного факта: это ref struct, поэтому среда выполнения заставляет его жить только в стеке. Именно это делает безопасным указывание на память stackalloc, и это не обсуждается.
Он не может пересечь await или yield. Когда метод выполняет await, компилятор поднимает каждую локальную переменную, пережившую await, в выделенную в куче машину состояний. Тип, живущий только в стеке, нельзя поднять, поэтому компилятор отвергает локальную переменную Span<T>, охватывающую await. Это ограничение, на которое натыкаются первым. Если вам нужен буфер, пересекающий асинхронную границу, используйте Memory<T> или ReadOnlyMemory<T>, дружественных к куче родственников; преобразование массива в ReadOnlyMemory
Он не может быть полем класса, упакован (boxed) или захвачен в лямбде. Вы не можете написать class C { Span<int> _buf; }, не можете присвоить span к object и не можете замкнуть его в замыкании. Каждое из этого позволило бы span сбежать из своего кадра стека, что тип запрещает. В момент, когда ваш дизайн требует, чтобы представление пережило текущий метод, ответ — List<T>, T[] или дескриптор Memory<T>.
Обобщённое использование требует allows ref struct. До C# 13 вы вообще не могли использовать Span<T> как аргумент обобщённого типа. Анти-ограничение allows ref struct из C# 13 сняло это, но только для обобщённых методов и типов, которые явно включают его через where T : allows ref struct. Более старое обобщённое API, которое его не включило, по-прежнему не может принять span.
Представление CollectionsMarshal.AsSpan действительно только до тех пор, пока список не изменит размер. Этот span указывает на текущий внутренний массив списка. Сделайте Add достаточно, чтобы вызвать перевыделение, и список выделит новый массив, оставив ваш span указывающим на старый, теперь осиротевший. Используйте такой span немедленно и отбросьте; никогда не держите его через изменяющий вызов списка.
Ещё одно удобство пришло в C# 14: массивы теперь неявно преобразуются в span, так что вы пишете ReadOnlySpan<char> s = "GET"u8 и передаёте myArray туда, где ожидается span, без видимого .AsSpan(). Статья неявные преобразования Span в C# 14 охватывает в точности, какие преобразования компилятор теперь делает за вас.
Короткая версия
Span<T> — это представление без аллокаций, живущее только в стеке, над памятью, которой вы уже владеете. Он ускоряет код тремя конкретными способами: позволяет заменить буферы в куче на stackalloc, позволяет нарезать строки и массивы без копирования и даёт JIT форму цикла, из которой он может убрать проверки границ. Эти выигрыши реальны и велики в парсерах, сериализаторах и горячих внутренних циклах, выполняемых миллионы раз. Они невидимы на холодных путях и полностью испаряются, если вы копируете из span наружу, если ваш источник — уже массив, который вы проходите последовательно, или если измеримого горячего цикла нет вовсе. И поскольку это ref struct, он останавливается на первом await, поле или замыкании в вашем дизайне. Тянитесь к нему, когда можете назвать, какой из трёх механизмов вы покупаете. Если не можете — вы добавляете ограничения ради ускорения, которого там нет.
Связанное
- List
vs Span — руководство по выбору, когда вы решаете между тремя для конкретного поля или возврата.vs ReadOnlySpan в C#: когда что выбирать - Как преобразовать T[] в ReadOnlyMemory
в C# — безопасный при await путь, когда span не может пересечьawait. - Как правильно использовать SearchValues
в .NET 11 строится наReadOnlySpan<T>для SIMD-ускоренного поиска по множеству образцов. - Как читать большой CSV в .NET 11 без нехватки памяти разбирает каждую строку на месте с нарезкой span.
- Неявные преобразования Span в C# 14 охватывают преобразования, позволяющие вызывающим опустить
.AsSpan().
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.