
C# — гибкий язык. На нём можно писать мобильные и десктопные приложения, игры, веб-сайты, сервисы и API. Можно писать на нём, как на Java, со всеми абстракциями и AbstractionFactoryClassProvider
. Но, в отличие от Java, на нём также можно писать низкоуровневый и небезопасный код. И когда я говорю о низкоуровневом, то имею в виду отсутствие сборщика мусора и сырые указатели.
Низкоуровневый код обычно требуется для высокой производительности или взаимодействия с операционной системой/библиотеками C. Низкоуровневый код помогает повысить производительность благодаря тому, что с его помощью можно избавиться от проверок доступа к памяти в среде исполнения.
Для безопасности доступ к элементам массивов выполняется в C# с проверкой границ. Но из-за этого страдает производительность, если только, конечно, компилятор не сможет избавиться от такой операции проверки. Логика устранения проверок границ должна гарантировать, что проверка границ индекса массива уже выполнялась раньше или что во время компиляции индекс точно будет находиться в границах. Для примера возьмём простую функцию:
int sum(int[] array)
{
int sum = 0;
for (int i = 0; i < array.Length; i++)
{
sum += array[i];
}
return sum;
}
Это идеальная ситуация для избавления от проверки границ, потому что переменная индекса i
создана с известными границами и зависит от длины массива. Срок жизни переменной индекса короче, чем срок жизни массива, и она гарантированно на протяжении всей функции будет содержать валидные значения. Нативный код, сгенерированный для sum
, не содержит проверок границ:
L0000 xor eax, eax
L0002 xor edx, edx
L0004 mov r8d, [rcx+8] ; считываем длину
L0008 test r8d, r8d ; она пустая?
L000b jle short L001c ; пропускаем цикл
L000d mov r10d, edx
L0010 add eax, [rcx+r10*4+0x10] ; sum += array[i];
L0015 inc edx ; i++
L0017 cmp r8d, edx ; сравниваем длину с i
L001a jg short L000d ; продолжаем цикл, если она всё ещё больше
L001c ret
Но что, если сигнатура функции будет немного иной?
int sum(int[] array, int startIndex, int endIndex)
{
int sum = 0;
for (int i = startIndex; i <= endIndex; i++)
{
sum += array[i];
}
return sum;
}
Теперь компилятор C# никак не может узнать, находятся ли передаваемые значения startIndex
и endIndex
внутри границ array
, потому что их сроки жизни различаются. Поэтому сгенерированный нативный ассемблерный код становится гораздо сложнее, в нём появляются операции проверки границ:
L0000 sub rsp, 0x28
L0004 xor eax, eax ; sum = 0
L0006 cmp edx, r8d ; startIndex > endIndex?
L0009 jg short L0045 ; тогда пропускаем всю функцию
L000b test rcx, rcx ; массив null?
L000e je short L0031 ; тогда вызываем NullReferenceException
L0010 mov r10d, edx
L0013 or r10d, r8d
L0016 jl short L0031
L0018 cmp [rcx+8], r8d ; array.Length <= endIndex ?
L001c jle short L0031 ; тогда выполняем проверку границ
L001e xchg ax, ax ; NOP выравнивания
L0020 mov r10d, edx
L0023 add eax, [rcx+r10*4+0x10] ; sum += array[i]
L0028 inc edx ; проверяем i + 1
L002a cmp edx, r8d ; i > endIndex?
L002d jle short L0020 ; если нет, продолжаем
L002f jmp short L0045 ; возврат
L0031 cmp edx, [rcx+8] ; i > array.Length?
L0034 jae short L004a ; неудачная проверка границ, переход ------+
L0036 mov r10d, edx |
L0039 add eax, [rcx+r10*4+0x10] ; sum += array[i] |
L003e inc edx ; i++ |
L0040 cmp edx, r8d ; i <= endIndex ? |
L0043 jle short L0031 ; продолжаем цикл for |
L0045 add rsp, 0x28 |
L0049 ret ; возвращаем sum |
L004a call 0x00007ff857ec6200 ; выбрасываем IndexOutOfRangeException <---+
В C# можно использовать небезопасные функции и указатели (да, C# поддерживает сырые указатели!), чтобы полностью избежать проверки границ:
unsafe int sum(int* ptr, int length)
{
int* end = ptr + length;
int sum = 0;
for (; ptr < end; ptr++)
{
sum += *ptr;
}
return sum;
}
Кроме того, при этом создаётся очень оптимизированный код, поддерживающий передачу части массива:
L0000 movsxd rax, edx
L0003 lea rax, [rcx+rax*4] ; end = ptr + length
L0007 xor edx, edx ; sum = 0
L0009 cmp rcx, rax ; ptr >= end ?
L000c jae short L0019 ; тогда выполняем возврат
L000e add edx, [rcx] ; sum += *ptr
L0010 add rcx, 4 ; ptr += sizeof(int)
L0014 cmp rcx, rax ; ptr < end?
L0017 jb short L000e ; тогда продолжаем цикл
L0019 mov eax, edx
L001b ret ; возвращаем sum
Как мы видим, небезопасный код и арифметика указателей могут быть очень высокопроизводительными. Однако проблема в том, что они слишком опасны. При неправильных значениях длины мы не получим IndexOutOfRangeException
; вместо этого приложение или вылетит, или вернёт некорректные результаты. Если код изменяет область памяти, а не просто читает её, то в приложении ещё и может возникнуть удобная точка входа для уязвимости переполнения буфера. Не говоря уже о том, что у всех сторон, вызывающих эту функцию, тоже будут небезопасные блоки.
Однако в C# можно решить эту задачу безопасно и быстро, не прибегая при этом к подобным эзотерическим ритуалам. Во-первых, как решить проблему того, что индексы описывают представление части массива, а реальные границы массива отделены друг от друга? Можно создать новый неизменяемый тип, хранящий эти значения вместе. Такой тип в C# называется span. В других языках программирования он может называться slice. Объявление типа Span
примерно выглядит так (на самом деле, не совсем так, но я хочу, чтобы вы сначала поняли концепцию):
readonly struct Span<T>
{
readonly T* _ptr;
readonly int _len;
}
По сути, это неизменяемый указатель с длиной. Удобство такого типа заключается в том, что компилятор может гарантировать, что после инициализации неизменяемого Span с правильными границами доступ к нему всегда будет безопасным без проверок границ. Это значит, что можно безопасно и быстро передавать части массивов или даже другие span без оверхеда производительности.
Но как обеспечивается его безопасность? Что, если сборщик мусора (GC) решит избавиться от структуры, на которую указывает ptr
? На этом моменте в C# вступают в игру ref type.
Ref type — это тип, который не может покинуть стек и сбежать в кучу, поэтому тип T
гарантированно переживёт срок жизни экземпляра Span<T>
. Именно поэтому настоящее объявление Span<T>
выглядит так:
readonly ref struct Span<T> // обратите внимание на "ref"
{
readonly ref T _ptr; // обратите внимание на "ref"
readonly int _len;
}
Так как ref type может находиться только в стеке, он не может быть членом класса и ему нельзя присвоить нессылочную переменную. Ref type может содержаться только внутри другого ref type.
Версия нашей функции sum
со span может избавиться от проверки границ, несмотря на то, что теперь она может обладать множеством сверхспособностей. Первая из них — мы можем получать представление части массива с конкретными индексами:
int sum(Span<int> span)
{
int sum = 0;
for (int i = 0; i < span.Length; i++)
{
sum += span[i];
}
return sum;
}
Например, можно вызывать эту функцию как sum(array)
или с представлением части массива sum(array[startIndex..endIndex])
. Это не приведёт к новым операциям проверок границ, если только вы не будете получать срез массива при помощи оператора диапазона. Посмотрите, как снова оптимизируется сгенерированный для sum
ассемблерный код:
L0000 mov rax, [rcx]
L0003 mov ecx, [rcx+8]
L0006 xor edx, edx ; sum = 0
L0008 xor r8d, r8d ; i = 0
L000b test ecx, ecx ; span.Length == 0?
L000d jle short L001e
L000f mov r10d, r8d
L0012 add edx, [rax+r10*4] ; sum += span[i]
L0016 inc r8d ; i++
L0019 cmp r8d, ecx ; i < Length?
L001c jl short L000f ; тогда продолжаем цикл
L001e mov eax, edx
L0020 ret ; возвращаем sum
Ещё одна сверхспособность заключается в возможности объявления получаемой структуры данных «неизменяемой» в сигнатуре функции, чтобы функция гарантированно не трогала её, а баги мгновенно выявлялись. Для этого достаточно лишь заменить Span<T>
на ReadOnlySpan<T>
. При попытке изменения содержимого span немедленно возникнет ошибка компилятора. Этого невозможно добиться при работе с обычными массивами, даже если объявить их readonly
. Директива readonly
защищает от изменения только ссылку, но не содержимое структуры данных.
Как это связано с zero-copy?
Передача меньшей части крупной структуры данных в соответствующие API раньше требовала либо копирования данных, либо передачи вместе со структурой данных смещения нужного участка и значений длины. Для этого было необходимо, чтобы API поддерживал вызовы с крупными структурами со смещениями. Гарантировать безопасность таких API невозможно, поскольку компилятор или среда исполнения не может установить взаимосвязь между параметрами.
Реализация операций zero-copy при помощи span проста и выразительна. Рассмотрим для примера реализацию Quicksort; обычно в ней есть подобная функция для работы с частями массива:
int partition(int[] array, int low, int high)
{
int midpoint = (high + low) / 2;
int mid = array[midpoint];
// Обмен местами кортежей на C#! ("..^1" означает "Length - 1")
(array[midpoint], array[^1]) = (array[^1], array[midpoint]);
int pivotIndex = 0;
for (int i = low; i < high - 1; i++)
{
if (array[i] < mid)
{
(array[i], array[pivotIndex]) = (array[pivotIndex], array[i]);
pivotIndex += 1;
}
}
(array[midpoint], array[^1]) = (array[^1], array[midpoint]);
return pivotIndex;
}
Эта функция получает массив и смещения, обозначающие его часть, а затем переставляет элементы на основании выбранного значения в ней. Значения меньше сдвигаются влево, значения больше — вправо.
Для mid = array[midpoint]
необходимо выполнять проверку границ, потому что компилятор не знает, находится ли индекс внутри границ этого массива. Цикл for
тоже выполняет проверку границ для доступа к массиву в цикле; от некоторых из них можно избавиться, но это не полностью гарантировано.
Также существует ошибка переполнения, потому что мы передаём интервалы массива при помощи значений high
и low
: при очень больших массивах (high+low)
может привести к переполнению, и результат этого будет катастрофическим, могут даже произойти исключения переполнения буфера.
Ниже функция partition
многократно рекурсивно вызывается функцией Quicksort
, то есть проверка границ может стать помехой производительности.
void Quicksort(int[] array, int low, int high)
{
if (array.Length <= 1)
{
return;
}
int pivot = partition(array, low, high);
Quicksort(span, low, pivot - 1);
Quicksort(span, pivot + 1, high);
}
При использовании span та же самая функция Quicksort выглядит так:
void Quicksort(Span<int> span)
{
if (span.Length <= 1)
{
return;
}
int pivot = partition(span);
Quicksort(span[..pivot]);
Quicksort(span[(pivot + 1)..]);
}
Видите, насколько выразительны span, и в особенности с синтаксисом диапазонов? Они позволяют получать новый span из существующего span или массив при помощи двух точек (..
). И функция partition
тоже начинает выглядеть лучше:
int partition(Span<int> span)
{
int midpoint = span.Length / 2; // теперь никаких переполнений буфера!
int mid = span[midpoint];
(span[midpoint], span[^1]) = (span[^1], span[midpoint]);
int pivotIndex = 0;
for (int i = 0; i < span.Length - 1; i++)
{
if (span[i] < mid)
{
(span[i], span[pivotIndex]) = (span[pivotIndex], span[i]);
pivotIndex += 1;
}
}
(span[midpoint], span[^1]) = (span[^1], span[midpoint]);
return pivotIndex;
}
Так как span в C# отсчитываются от нуля, с ними сложнее получить проблемы переполнения буфера, вызываемые формулами наподобие (low + high) / 2
. Теперь реализация столь же быстра, как небезопасная реализация с сырыми указателями, но по-прежнему чрезвычайно безопасна.
Новые операции zero-copy в среде исполнения .NET
Выше для демонстрации того, как части большой структуры данных могут передаваться другой функции, я использовал пример с рекурсией, но span можно использовать практически везде, а теперь среда исполнения .NET поддерживает альтернативы zero-copy популярным функциям.
Возьмём для примера String.Split
. Теперь можно делить строку, не создавая при этом новые копии каждой отделённой части строки. Можно разделить строку CSV на части следующим образом:
string csvLine = // .. чтение строки CSV
string[] parts = csvLine.Split(',');
foreach (string part in parts)
{
Console.WriteLine(part);
}
Проблема такого подхода в том, что теперь мы имеем дело с пятью новыми буферами переменной длины. .NET распределяет для них новую память, а GC отслеживает их. Это медленно и занимает много памяти. Особенно проблематично это в циклах, и при этом более активно может задействоваться GC, ещё больше замедляя приложение.
Вместо этого можно преобразовать строку CSV в ReadOnlySpan<char>
и итеративно обойти его компоненты, чтобы записать их в вывод:
string csvLine = // .. чтение строки CSV
var span = csvLine.AsSpan();
var parts = span.Split(',');
foreach (var range in parts)
{
Console.Out.WriteLine(span[range]);
}
Обратите внимание, что мы используем небольшой обходной манёвр, используя Console.Out.WriteLine
вместо Console.WriteLine
, потому что классу Console
не хватает перегрузки для вывода ReadOnlySpan<char>
, как string.
Большая выгода этого эксперимента заключается в создании нуля распределений памяти после чтения строки CSV в память. Похожая логика, повышающая производительность и эффективность использования памяти, может применяться везде, где можно получать Span<T>
/ReadOnlySpan<T>
вместо массива.
Готовимся к будущему
Структуры наподобие span и slice — это будущее безопасных по памяти операций в современных языках программирования. Осваивайте их. Кратко подведём итоги:
Используйте в объявлениях функций span вместо массивов, если только вам по какой-то причине в функции не нужен обязательно отдельный массив. Благодаря такому изменению для API откроются возможности оптимизации zero-copy, а вызов кода будет более выразительным.
Не работайте с unsafe/указателями, если можно закодировать такую же логику на основе span. При этом вы сохраните возможность выполнять низкоуровневые операции с памятью, не забредая при этом в опасные части леса. Код останется быстрым, но в то же время станет безопаснее.
Когда это возможно, используйте span, в основном readonly.
Комментарии (5)
a-tk
03.10.2025 09:42Начало - отличная статья о том, как можно оторвать ногу, лишь слегка дунув на неё.
Благо, потом немного образумились.
a-tk
03.10.2025 09:42Так как ссылочный тип может находиться только в стеке, он не может быть членом класса и ему нельзя присвоить нессылочную переменную. Ссылочный тип может содержаться только внутри другого ссылочного типа.
Здесь некорректный по смыслу перевод. Ссылочный тип - это тот, который требует объекта в куче (то есть class или interface). ref struct - это принципиально другой вид типов, при этом адекватный перевод на русский язык до сих пор не устоялся. Более того, ref struct - это тип, который не может попасть в кучу.
a-tk
03.10.2025 09:42Проблема такого подхода в том, что теперь мы имеем дело с пятью новыми буферами переменной длины. .NET распределяет для них новую память, а GC отслеживает их. Это медленно и занимает много памяти. Особенно проблематично это в циклах, и при этом более активно может задействоваться GC, ещё больше замедляя приложение.
Опять же, не надо сгущать краски: эфемерные поколения не столь дороги. А альтернативные варианты могут потребовать гораздо больше нагрузки на программиста при написании, и они более хрупкие при поддержке.
NightBlade74
https://habr.com/ru/companies/otus/articles/708210/