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.