← Все статьи

C# и Span<T>: ускорение кода в 5 раз без unsafe

Если вы пишете на C# высоконагруженные приложения — микросервисы, парсеры данных, игровые серверы или финансовые алгоритмы — вы наверняка сталкивались с узким местом: управляемая память и сборщик мусора (GC). Каждое новое выделение массива, подстрока или копирование данных создает давление на GC, что в пиковые моменты может приводить к просадкам производительности и нестабильным задержкам. Долгое время единственным выходом был опасный мир указателей и ключевого слова `unsafe`. Но с приходом.NET Core и современных версий C# появился элегантный, безопасный и невероятно мощный инструмент для работы с памятью без лишних аллокаций — типы `Span<T>` и `Memory<T>`. Это не просто еще одна фича, это фундаментальный сдвиг в парадигме написания производительного кода на C#.

Давайте сразу отбросим абстрактные определения. `Span<T>` — это ref-структура, которая предоставляет типобезопасное окно в непрерывный регион памяти. Этот регион может быть частью массива (`T[]`), строки (`string`), неуправляемой памяти (выделенной, например, через `stackalloc`) или памяти из пулов (`ArrayPool`). Ключевая магия в том, что `Span<T>` позволяет читать и изменять данные в этом регионе, не создавая новых объектов в куче. Он делает это безопасно с точки зрения типов и времени жизни данных. `Memory<T>` — его более гибкий собрат для асинхронных операций, так как не является ref-структурой и может храниться в куче.

Представьте классическую задачу: вам нужно пропарсить большую строку лога, разделенную точкой с запятой, и извлечь определенные поля. Традиционный подход с использованием `string.Split()` моментально создает массив подстрок — это N новых объектов в куче плюс аллокация самого массива. При интенсивной обработке таких строк GC будет работать постоянно.

Вот как эту задачу решает `Span<T>`:

string logLine = "2025-03-20;INFO;Server_01;Время обработки запроса: 156ms;Успех";

ReadOnlySpan<char> logSpan = logLine.AsSpan(); int fieldIndex = 0; int start = 0;

for (int i = 0; i <= logSpan.Length; i++) { if (i == logSpan.Length || logSpan[i] == ';') { var currentField = logSpan.Slice(start, i - start); if (fieldIndex == 3) { // Анализируем поле "Время обработки запроса: 156ms" без создания подстрок var msIndex = currentField.LastIndexOf("ms"); if (msIndex!= -1) { // Пытаемся распарсить число из спана if (int.TryParse(currentField.Slice(msIndex - 3, 3), out int milliseconds)) { // Работаем со значением } } } start = i + 1; fieldIndex++; } }

В этом примере не создается ни одной новой строки. Методы `AsSpan()`, `Slice()`, `LastIndexOf()` работают непосредственно с памятью исходной строки через спаны. Это приводит к радикальному снижению аллокаций.

  • Обработка бинарных протоколов сетевых пакетов.
  • Высокоскоростные преобразования изображений (например, применение фильтров к пикселям).
  • Парсинг чисел из текста (`int.Parse` теперь имеет перегрузку для `ReadOnlySpan<char>`).
  • Работа с файлами через новые API типа `RandomAccess.Read`, которые возвращают результат прямо в `Memory<byte>`.
  • Нельзя сделать поле класса типа `Span<T>`.
  • Нельзя использовать его в асинхронных методах за пределами одного асинхронного контекста.
  • Нельзя поместить его в коллекции общего назначения типа List<>.

Для обхода этих ограничений существует тип `Memory<T>`. Он оборачивает буфер так же, как и `Span`, но уже является обычной структурой и может использоваться где угодно. Когда вам нужно произвести операцию над данными внутри `Memory<T>`, вы получаете из него свойство `.Span`. Типичный паттерн для асинхронного IO:

async Task ProcessFileAsync(string path) { using var fileStream = new FileStream(path, FileMode.Open); var buffer = new byte[4096]; Memory<byte> memoryBuffer = buffer;

int bytesRead; while ((bytesRead = await fileStream.ReadAsync(memoryBuffer)) > 0) { // Получаем Span для синхронной обработки считанного блока ProcessBuffer(memoryBuffer.Span.Slice(0, bytesRead)); } }

void ProcessBuffer(Span<byte> span) { // Быстрая синхронная обработка без аллокаций }

Давайте посмотрим на конкретные цифры через BenchmarkDotNet. Возьмем задачу вычисления суммы элементов большого массива целых чисел.

Традиционный способ: public int SumArray(int[] array) { int sum = 0; for (int i = 0; i < array.Length; i++) sum += array[i]; return sum; }

Способ со Span: public int SumWithSpan(Span<int> span) { int sum = 0; foreach(ref int value in span) sum += value; return sum; }

На первый взгляд разницы нет. Но если метод вызывается миллионы раз и получает всегда новый массив — первый метод никак не избежит аллокации этого массива. Метод со Span'ом может получать слайс из большего буфера, взятого из пула (`ArrayPool<int>.Shared.Rent()`), что сводит аллокации к нулю после прогрева. В реальных тестах на операциях типа парсинга сложных данных ускорение достигает 5-10 раз именно за счет исключения давления на GC.

Работая со спанами, важно помнить о потенциальных ошибках: 1. Использование спана после освобождения исходного буфера (use-after-free). Хотя CLR старается предотвратить это в безопасном контексте. 2. Попытка сохранить Span в поле класса приведет к ошибке компиляции CS8345. 3. Слайсинг за границы исходного диапазона выбросит исключение.

  • Используйте структуры вместо классов для данных внутри буфера.
  • Арендуйте массивы из ArrayPool для повторного использования буферов.
  • Применяйте ref returns и ref locals для прямого доступа к данным внутри спана при сложных манипуляциях.

Таким образом переход на использование Span<T> и Memory<T> — это не оптимизация "на последней миле", а стратегическое решение при проектировании высокопроизводительных систем на C#. Это позволяет писать код, который по скорости сопоставим с неуправляемыми языками, сохраняя при этом все преимущества безопасности управляемой среды. Начните внедрять эти типы точечно, в самых горячих участках вашего кода, и вы сразу увидите снижение аллокаций и повышение предсказуемости производительности ваших приложений

💬 Комментарии (12)
👤
dmitry.ivanov1985
21.03.2026 18:01
Автор, планируете ли выпустить продолжение с более сложными примерами, например, работа с файлами или сетевыми потоками?
👤
sergey.sidorov
21.03.2026 20:27
Наконец-то дождались нормальных средств для low-level оптимизаций без необходимости маркировать проект как unsafe.
👤
dmitry.volkov77
21.03.2026 21:00
Спасибо за наглядное объяснение. Всегда боялся unsafe-кода, а тут такая безопасная альтернатива от Microsoft.
👤
nina.fedorova1990
23.03.2026 16:14
Для высоконагруженных финансовых алгоритмов — просто must have. Снижение латентности критически важно.
👤
tech.department
25.03.2026 09:37
Интересно, а насколько сложно переписать существующий код под Span? Не сломает ли это обратную совместимость?
👤
olga.nikolaeva
25.03.2026 13:10
Пробовал использовать в микросервисе для обработки сообщений. Падение аллокаций просто колоссальное, GC стал работать спокойнее.
👤
maria.kuznetsova
29.03.2026 03:09
Не совсем понял момент с stackalloc. Это же тоже создает span? И как контролировать размер стека, чтобы не получить переполнение?
👤
pavel.novikov99
31.03.2026 04:12
Статья хорошая, но не хватает сравнения с Memory<T>. В каких случаях что лучше использовать?
👤
sergey.sidorov
31.03.2026 06:20
Отличная статья! Как раз оптимизирую парсер логов, и Span<T> помог убрать аллокации. Производительность реально выросла.
👤
lisa.martinez-work
01.04.2026 03:55
А есть ли какие-то подводные камни при использовании Span в старых версиях .NET Framework или только Core/5+?
👤
nikolay.sidorov77
01.04.2026 07:27
Пять раз — звучит впечатляюще. У себя в тестах над CSV-парсером получил ускорение примерно в 3 раза. Все равно отлично!
👤
dmitry.volkov77
02.04.2026 15:28
Везде пишут про преимущества, а на практике пришлось повозиться с рефакторингом. Но результат того стоит.