List<T> vs Span<T> vs ReadOnlySpan<T> в C#: когда что выбирать
List<T> -- это растущая коллекция в куче; Span<T> и ReadOnlySpan<T> -- представления только на стеке поверх памяти, которой вы уже владеете. Используйте List<T> для всего, что вы храните, возвращаете из async или наращиваете; Span<T> для изменяемого представления без аллокаций в синхронном методе; ReadOnlySpan<T> для разбора только для чтения поверх строк, литералов u8 и срезов.
Используйте List<T>, когда у вас есть коллекция, которая растёт, хранится в поле, возвращается из метода или передаётся через await. Используйте Span<T>, когда вам нужно изменяемое представление без аллокаций поверх непрерывного буфера, который у вас уже есть (массив, блок stackalloc, срез), внутри одного синхронного метода. Используйте ReadOnlySpan<T> для того же представления, когда вы только читаете: срезы строк, литералы u8, разбор, поиск. Решение, которое перекрывает вкусовые предпочтения: оба span являются типами ref struct, поэтому они не могут жить в куче, не могут быть полем класса и не могут пересекать await или yield. Если вам нужно что-либо из этого, вы остаётесь с List<T> (или массивом), и точка.
Эта статья ориентирована на .NET 11 и C# 14. Span<T> и ReadOnlySpan<T> находятся в BCL начиная с .NET Core 2.1 и в языке начиная с C# 7.2, но здесь важны два недавних изменения: C# 13 (.NET 9) добавил анти-ограничение allows ref struct и params ReadOnlySpan<T>, а C# 14 (.NET 11) добавил полноценные неявные преобразования между массивами и span. Оба снижают трение при переходе между этими типами. List<T> восходит к .NET Framework 2.0.
Это не три разновидности одного и того же
Сравнение сбивает людей с толку, потому что три названия выглядят как равноправные, но это не так. Два из них являются коллекциями только по названию.
List<T> — это класс. Это растущая обёртка вокруг приватного T[], который удваивает ёмкость при заполнении. Он живёт в управляемой куче, сборка мусора отслеживает его, вы можете хранить его в поле, возвращать его, захватывать его в лямбде и передавать его в async-метод. Он владеет своим хранилищем и может расти. Это повседневная коллекция, к которой вы тянетесь не задумываясь, и в большинстве случаев этот инстинкт верен.
Span<T> — это ref struct. Он не владеет никакой памятью. Это крошечное значение (управляемая ссылка плюс длина), которое указывает на непрерывную область, выделенную кем-то другим: массив, срез массива, буфер stackalloc или неуправляемую память. Он не может расти, потому что не владеет нижележащим хранилищем. Он изменяемый: запись через Span<T> записывает в нижележащий буфер. Поскольку это ref struct, среда выполнения гарантирует, что он может жить только на стеке, что как раз и делает его безопасным для указания на память стека, но также запрещает ему быть полем, подвергаться упаковке (boxing) или переживать await.
ReadOnlySpan<T> — это то же ref struct-представление, но без возможности записи. Это то, что возвращает срез строки ("hello".AsSpan(1, 3)), то, что производит литерал UTF-8 ("GET"u8 — это ReadOnlySpan<byte>), и тип параметра, который вам следует принимать, когда вы только читаете буфер. Всё сказанное об ограничениях Span<T> на пребывание только на стеке применимо идентично.
Так что настоящий вопрос редко звучит как «какая коллекция». Он звучит так: «владею ли я буфером и наращиваю ли его (List<T>), или я смотрю на тот, что у меня уже есть, изменяемо (Span<T>) или только для чтения (ReadOnlySpan<T>)?»
Матрица решений
Поведение ниже относится к .NET 9+ / C# 13+, если не указано иное.
| Возможность | List<T> | Span<T> | ReadOnlySpan<T> |
|---|---|---|---|
| Вид | класс (куча) | ref struct (стек) | ref struct (стек) |
| Владеет своим хранилищем | да | нет (представление) | нет (представление) |
Может расти / Add | да | нет | нет |
| Изменять элементы | да | да | нет |
| Аллокация при создании | куча (нижележащий T[]) | нет | нет |
| Хранить в поле класса | да | нет | нет |
Возвращать из async-метода | да | нет | нет |
Использовать через await / yield | да | нет | нет |
| Захватывать в лямбде / замыкании | да | нет | нет |
Упаковка / присвоение object или интерфейсу | да | нет | нет |
| Использовать как аргумент обобщённого типа | да | только с allows ref struct | только с allows ref struct |
| Срез без копирования | нет (GetRange копирует) | да (Slice, без копии) | да (Slice, без копии) |
Источник из string | нет | нет | да (AsSpan) |
Источник из stackalloc | нет | да | да |
| Впервые появился | .NET Framework 2.0 | .NET Core 2.1 | .NET Core 2.1 |
Строки от «Хранить в поле» до «Упаковка» решают большинство реальных случаев. Если хотя бы одна из них для вашего сценария — это «да», span отпадают, и вы остаётесь с List<T> или массивом. Всё остальное — это вопрос производительности и эргономики.
Когда выбирать List
List<T> — это вариант по умолчанию. Прибегайте к нему всякий раз, когда время жизни коллекции дольше одного синхронного метода, или когда вы не знаете итоговый размер заранее.
- Вы строите коллекцию инкрементально. Вы читаете строки, добавляете результаты, накапливаете события.
Addимеет амортизированную сложность O(1), и список изменяет свой размер сам. Span не может расти, так что это даже не соревнование. - Коллекция — это поле или возвращаемое значение. Кеш, реестр,
List<Order>, который вы возвращаете из репозитория.ref structне может быть полем и не может быть возвращён через асинхронную границу, так что всё, что переживает кадр стека, живёт вList<T>. - Вы пересекаете
await. В момент, когда метод ожидает (await), каждая локальная переменная, переживающая await, поднимается в выделенный в куче конечный автомат.ref structнельзя поднять, так что локальная переменнаяSpan<T>не может пережить await.List<T>может.
// .NET 11, C# 14 -- List<T> is the only correct choice here:
// it grows, it is returned, and the method is async.
public async Task<List<Order>> LoadRecentAsync(DbContext db, CancellationToken ct)
{
var results = new List<Order>();
await foreach (var order in db.Orders.AsAsyncEnumerable().WithCancellation(ct))
{
if (order.Total > 100m)
results.Add(order); // grows on demand
}
return results; // escapes the stack frame
}
Если вам нужна подсказка, что вы сделали правильный выбор, спросите, должна ли коллекция существовать после того, как метод вернёт управление. Если да, то это List<T> или массив, но никогда не span.
Когда выбирать Span
Span<T> предназначен для изменяемого представления без аллокаций поверх памяти, которой вы уже управляете, используемого и отбрасываемого внутри одного синхронного метода. Классический выигрыш — это избегание промежуточной аллокации.
- Небольшой временный буфер через
stackalloc. Форматирование числа, построение небольшого ключа, хеширование нескольких байтов.stackallocпомещает буфер на стек, аSpan<T>— это безопасный дескриптор для него. НикакогоT[]в куче, никакого давления на сборку мусора. - Срез буфера на месте. Разбор сетевого кадра: взять заголовок, затем полезную нагрузку, не копируя ни то, ни другое.
Span<T>.Sliceвозвращает ещё одно представление поверх той же памяти. - Изменение области массива без каши из параметров смещения/длины. Передать
buffer.AsSpan(start, length)чище, чем протаскивать(buffer, start, length)через каждый вызов, а границы проверяются один раз на срезе.
// .NET 11, C# 14 -- a stackalloc scratch buffer, no heap allocation
public static bool TryFormatTimestamp(long unixSeconds, Span<char> destination, out int written)
{
Span<char> scratch = stackalloc char[20]; // on the stack, not the heap
if (!unixSeconds.TryFormat(scratch, out int n))
{
written = 0;
return false;
}
return scratch.Slice(0, n).TryCopyTo(destination)
? (written = n) >= 0
: Fail(out written);
static bool Fail(out int w) { w = 0; return false; }
}
Есть реальная причина для производительности помимо аллокации. JIT часто может устранить проверки границ, когда он итерирует Span<T> напрямую, потому что длина span находится прямо здесь, а форма цикла распознаваема. Итерация List<T> через его перечислитель выполняет проверку версии и проверку границ на каждом MoveNext. Мы измеряем это ниже.
Распространённый мост: если у вас уже есть List<T> и вам нужна производительность span для горячего чтения или изменения на месте, не копируйте его. Вызовите CollectionsMarshal.AsSpan(list), чтобы получить Span<T> напрямую поверх нижележащего массива списка. Это представление действительно только до следующей операции, которая изменяет размер списка, так что используйте его и отбрасывайте.
Когда выбирать ReadOnlySpan
ReadOnlySpan<T> — это правильный тип параметра для любого синхронного метода, который читает буфер и не нуждается в его изменении. Согласно рекомендациям по использованию Memory и Span от Microsoft, правило первое — «для синхронного API предпочитайте Span<T> вместо Memory<T>», а правило второе — «используйте ReadOnlySpan<T>, если буфер должен быть только для чтения». Большая часть разбора и поиска выполняется только для чтения.
- Срез строк без выделения подстрок.
"2026-05-25".AsSpan(0, 4)даёт вам год какReadOnlySpan<char>без новойstring.int.Parseи подобные имеют перегрузки для span, так что вы можете разбирать прямо из среза. - Литералы UTF-8.
"GET"u8— этоReadOnlySpan<byte>, встроенный в сборку. Сравнение входящего буфера байтов с ним выполняется без аллокаций. - Принятие любой формы буфера. Метод, который принимает
ReadOnlySpan<byte>, можно вызвать сbyte[],ArraySegment<byte>, буферомstackallocили срезом, без перегрузок. В C# 14 преобразование массива в span неявное, так что вызывающие даже не пишут.AsSpan().
// .NET 11, C# 14 -- read-only parsing with zero substring allocations
public static (int year, int month, int day) ParseIsoDate(ReadOnlySpan<char> date)
{
int year = int.Parse(date.Slice(0, 4));
int month = int.Parse(date.Slice(5, 2));
int day = int.Parse(date.Slice(8, 2));
return (year, month, day);
}
// All three callers work; none allocate a substring.
var a = ParseIsoDate("2026-05-25"); // string -> ReadOnlySpan<char>
var b = ParseIsoDate("2026-05-25".AsSpan()); // explicit
Span<char> buf = stackalloc char[10];
"2026-05-25".CopyTo(buf);
var c = ParseIsoDate(buf); // Span<char> -> ReadOnlySpan<char>
Обратите внимание, что Span<T> неявно преобразуется в ReadOnlySpan<T>, но никогда наоборот. Берите наиболее ограничивающий тип, который вашему методу действительно нужен: если вы только читаете, запрашивайте ReadOnlySpan<T>, чтобы каждый вызывающий, изменяемый или нет, мог до вас добраться. Это естественно сочетается с SearchValuesReadOnlySpan<T>.
Бенчмарк: суммирование 10 000 целых чисел
Утверждение о производительности конкретно: итерация Span<T> или ReadOnlySpan<T> быстрее, чем итерация List<T>, потому что JIT устраняет проверки границ на каждый элемент в span, а перечислитель списка — нет. Вот измерение.
// .NET 11, C# 14, BenchmarkDotNet 0.14.x
// dotnet run -c Release
[MemoryDiagnoser]
public class SumBench
{
private List<int> _list = null!;
private int[] _array = null!;
[GlobalSetup]
public void Setup()
{
_array = Enumerable.Range(0, 10_000).ToArray();
_list = new List<int>(_array);
}
[Benchmark(Baseline = true)]
public long ListForeach()
{
long sum = 0;
foreach (int x in _list) sum += x; // List<T>.Enumerator: version + bounds check
return sum;
}
[Benchmark]
public long SpanForeach()
{
long sum = 0;
Span<int> span = CollectionsMarshal.AsSpan(_list); // view, no copy
foreach (int x in span) sum += x; // bounds checks elided
return sum;
}
[Benchmark]
public long ReadOnlySpanForeach()
{
long sum = 0;
ReadOnlySpan<int> span = _array; // C# 14 implicit conversion
foreach (int x in span) sum += x;
return sum;
}
}
Репрезентативные результаты на Ryzen 7 / Windows 11 / сборке .NET 11, x64 RyuJIT:
| Метод | Среднее | Ratio | Выделено |
|---|---|---|---|
ListForeach | 6.1 us | 1.00 | 0 B |
SpanForeach | 2.4 us | 0.39 | 0 B |
ReadOnlySpanForeach | 2.4 us | 0.39 | 0 B |
Примерно в 2,5 раза быстрее для цикла span, с нулевой аллокацией во всех трёх (список уже существует; CollectionsMarshal.AsSpan не копирует). Точное соотношение меняется в зависимости от типа элемента и процессора, но направление стабильно: перечислитель span — это тонкий цикл, обходящий по ref, который JIT оптимизирует жёстко, тогда как List<T>.Enumerator несёт проверку версии, которая обнаруживает конкурентное изменение. Эта проверка версии — это функция, а не пустая трата (именно поэтому List<T> бросает InvalidOperationException, если вы изменяете его во время итерации), но она стоит тактов, которые span никогда не платит.
Честная оговорка: для суммы из 10 000 элементов это микросекунды. Если ваш цикл не горячий, не корёжьте свой код, чтобы сэкономить 4 микросекунды. Span оправдывают своё присутствие в горячих внутренних циклах, парсерах и сериализаторах, которые выполняются миллионы раз, а не при случайном обходе списка.
Подводные камни, которые решают за вас
Три ограничения полностью перекрывают вкусовые предпочтения, и все три происходят из того, что Span<T> и ReadOnlySpan<T> являются типами ref struct.
await в области видимости исключает span. Локальная переменная ref struct не может пережить await, потому что компилятору пришлось бы поднять её в выделенный в куче конечный автомат, что запрещено для типа, живущего только на стеке. Компилятор отвергает это сразу. Если ваш метод ожидает (await) и нуждается в буфере, охватывающем await, используйте Memory<T> / ReadOnlyMemory<T> (дружественные к куче родственники) или List<T> / массив. См. как преобразовать T[] в ReadOnlyMemory
Поле, возврат через async или замыкание исключают span. Вы не можете написать class C { Span<int> _buf; }. Вы не можете захватить span в лямбде. Вы не можете вернуть его из async Task<Span<int>>. В момент, когда ваш дизайн требует, чтобы буфер покинул текущий кадр стека, ответ — это List<T> или T[], возможно, с дескриптором Memory<T> для async.
Обобщённый контекст до C# 13 ограничивает span. До C# 13 вы вообще не могли использовать Span<T> как аргумент обобщённого типа. С анти-ограничением allows ref struct из C# 13 вы можете, но только если обобщённый метод или тип подключает его через where T : allows ref struct. Обобщённый API, который его не подключил, по-прежнему не может принять span. У List<T> такого ограничения нет; это обычный класс.
Есть также тонкая ловушка времени жизни у CollectionsMarshal.AsSpan. Span, который он возвращает, указывает на текущий нижележащий массив списка. Если вы затем вызовете Add достаточно, чтобы спровоцировать изменение размера, список выделит новый массив, и ваш span теперь указывает на старый, осиротевший. Считайте этот span действительным только до следующего изменяющего вызова списка.
Рекомендация, переформулированная
По умолчанию — List<T>. Это коллекция, которую вы наращиваете, храните, возвращаете, проводите через await и захватываете, и на .NET 11 она более чем достаточно быстра для всего, что не является измеренным горячим путём. Спускайтесь к Span<T>, когда вам нужно изменяемое представление без аллокаций поверх буфера, которым вы уже владеете и который вы используете и отбрасываете внутри одного синхронного метода, особенно со stackalloc или срезом на месте. Используйте ReadOnlySpan<T> как тип параметра для любого синхронного читателя и как возврат среза строк и литералов u8, чтобы разбирать и искать без выделения подстрок. Когда span был бы идеален, но на пути стоит await, поле или замыкание, прибегайте к Memory<T> / ReadOnlyMemory<T> или оставайтесь с List<T>. Самая короткая корректная версия: владеть и растить означает List<T>; смотреть и изменять означает Span<T>; смотреть и читать означает ReadOnlySpan<T>.
Связанное
- Неявные преобразования Span в C# 14: полноценная поддержка Span и ReadOnlySpan охватывает преобразования, которые позволяют вызывающим пропускать
.AsSpan(). - Как преобразовать T[] в ReadOnlyMemory
в C# — это безопасный через await аналог, когда span не может пересечьawait. - Как правильно использовать SearchValues
в .NET 11 опирается наReadOnlySpan<T>для быстрого поиска по нескольким символам. - Как прочитать большой CSV в .NET 11, не исчерпав память опирается на срез span, чтобы разбирать без копирования.
- C# 13: конец аллокаций params объясняет
params ReadOnlySpan<T>, params без аллокаций, которые делают возможными span.
Источники
- Рекомендации по использованию Memory
и Span (MS Learn) - Справочник по структуре System.Span
(MS Learn) - Справочник по структуре System.ReadOnlySpan
(MS Learn) - Анти-ограничение allows ref struct (MS Learn, предложение C# 13)
- Справочник по CollectionsMarshal.AsSpan (MS Learn)
- Справочник по классу List
(MS Learn)
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.