[Перевод] ref locals и ref returns в C#: подводные камни производительности

В языке C# с самого начала поддерживалась передача аргументов по значению или по ссылке. Но до версии 7 компилятор C# поддерживал только один способ возврата значения из метода (или свойства) — возврат по значению. В C# 7 ситуация изменилась с введением двух новых возможностей: ref returns и ref locals. Подробнее о них и об их производительности — под катом.

gi7eddufiqnmiqtmtby4nrkxclw.jpeg

Причины


Между массивами и другими коллекциями существует множество различий с точки зрения среды CLR. Среда CLR с самого начала поддерживала массивы, и их можно рассматривать как встроенный функционал. Среда CLR и JIT-компилятор умеют работать с массивами, а также у них есть еще одна особенность: индексатор массива возвращает элементы по ссылке, а не по значению.

Чтобы продемонстрировать это, нам придется обратиться к запретному методу — воспользоваться изменяемым (mutable) типом значения:

public struct Mutable
{
    private int _x;
    public Mutable(int x) => _x = x;
 
    public int X => _x;
 
    public void IncrementX() { _x++; }
}
 
[Test]
public void CheckMutability()
{
    var ma = new[] {new Mutable(1)};
    ma[0].IncrementX();
    // X has been changed!
    Assert.That(ma[0].X, Is.EqualTo(2));
 
    var ml = new List {new Mutable(1)};
    ml[0].IncrementX();
    // X hasn't been changed!
    Assert.That(ml[0].X, Is.EqualTo(1));
}


Тестирование пройдет успешно, потому что индексатор массива значительно отличается от индексатора List.

Компилятор C# дает специальную инструкцию индексатору массивов — ldelema, которая возвращает управляемую ссылку на элемент данного массива. По сути, индексатор массива возвращает элемент по ссылке. Однако List не может вести себя таким же образом, потому что в C# было невозможно* вернуть псевдоним внутреннего состояния. Поэтому индексатор List возвращает элемент по значению, то есть возвращает копию данного элемента.

*Как мы скоро увидим, индексатор List по-прежнему не может возвращать элемент по ссылке.

Это значит, что ma[0].IncrementX () вызывает метод, изменяющий первый элемент массива, в то время как ml[0].IncrementX () вызывает метод, изменяющий копию элемента, не затрагивая исходный список.

Возвращаемые ссылочные значения и ссылочные локальные переменные: основы


Смысл этих функций очень прост: объявление возвращаемого ссылочного значения позволяет возвращать псевдоним существующей переменной, а ссылочная локальная переменная может хранить такой псевдоним.

1. Простой пример:

[Test]
public void RefLocalsAndRefReturnsBasics()
{
    int[] array = { 1, 2 };
 
    // Capture an alias to the first element into a local
    ref int first = ref array[0];
    first = 42;
    Assert.That(array[0], Is.EqualTo(42));
 
    // Local function that returns the first element by ref
    ref int GetByRef(int[] a) => ref a[0];
    // Weird syntax: the result of a function call is assignable
    GetByRef(array) = -1;
    Assert.That(array[0], Is.EqualTo(-1));
}


2. Возвращаемые ссылочные значения и модификатор readonly

Возвращаемое ссылочное значение может вернуть псевдоним поля экземпляра, а начиная с C# версии 7.2, можно возвращать псевдоним без возможности записи в соответствующий объект, используя модификатор ref readonly:

class EncapsulationWentWrong
{
    private readonly Guid _guid;
    private int _x;
 
    public EncapsulationWentWrong(int x) => _x = x;
 
    // Return an alias to the private field. No encapsulation any more.
    public ref int X => ref _x;
 
    // Return a readonly alias to the private field.
    public ref readonly Guid Guid => ref _guid;
}
 
[Test]
public void NoEncapsulation()
{
    var instance = new EncapsulationWentWrong(42);
    instance.X++;
 
    Assert.That(instance.X, Is.EqualTo(43));
 
    // Cannot assign to property 'EncapsulationWentWrong.Guid' because it is a readonly variable
    // instance.Guid = Guid.Empty;
}


  • Методы и свойства могут возвращать «псевдоним» внутреннего состояния. Для свойства в этом случае не должен быть определен метод задания.
  • Возврат по ссылке разрывает инкапсуляцию, так как клиент получает полный контроль над внутренним состоянием объекта.
  • Возврат с помощью ссылки только для чтения позволяет избежать излишнего копирования типов значений, при этом не разрешая клиенту изменять внутреннее состояние.
  • Ссылки только для чтения можно использовать для ссылочных типов, хотя это и не имеет особого смысла при нестандартных случаях.


3. Существующие ограничения. Возвращать псевдоним может быть опасно: использование псевдонима размещаемой в стеке переменной после завершения метода приведет к аварийному завершению приложения. Чтобы сделать эту функцию безопасной, компилятор C# применяет различные ограничения:

  • Невозможно вернуть ссылку на локальную переменную.
  • Невозможно вернуть ссылку на this в структурах.
  • Можно вернуть ссылку на переменную, размещенную в куче (например, на член класса).
  • Можно вернуть ссылку на параметры ref/out.


Для получения дополнительной информации рекомендуем ознакомиться с отличной публикацией Safe to return rules for ref returns («Безопасные правила возврата ссылочных значений»). Автор статьи, Владимир Садов, является создателем функции возвращаемых ссылочных значений для компилятора C#.

Теперь, когда мы получили общее представление о возвращаемых ссылочных значениях и ссылочных локальных переменных, давайте рассмотрим, как их можно использовать.

Использование возвращаемых ссылочных значений в индексаторах


Чтобы проверить влияние этих функций на производительность, мы создадим уникальную неизменяемую коллекцию по названием NaiveImmutableList и сравним ее с T[] и List для структур разного размера (4, 16, 32 и 48).

public class NaiveImmutableList
{
    private readonly int _length;
    private readonly T[] _data;
    public NaiveImmutableList(params T[] data) 
        => (_data, _length) = (data, data.Length);
 
    public ref readonly T this[int idx]
        // R# 2017.3.2 is completely confused with this syntax!
        // => ref (idx >= _length ? ref Throw() : ref _data[idx]);
        {
            get
            {
                // Extracting 'throw' statement into a different
                // method helps the jitter to inline a property access.
                if ((uint)idx >= (uint)_length)
                    ThrowIndexOutOfRangeException();
 
                return ref _data[idx];
            }
        }
 
    private static void ThrowIndexOutOfRangeException() =>
        throw new IndexOutOfRangeException();
}
 
struct LargeStruct_48
{
    public int N { get; }
    private readonly long l1, l2, l3, l4, l5;
 
    public LargeStruct_48(int n) : this()
        => N = n;
}
 
// Other structs like LargeStruct_16, LargeStruct_32 etc


Тест производительности выполняется для всех коллекций и складывает все значения свойств N для каждого элемента:

private const int elementsCount = 100_000;
private static LargeStruct_48[] CreateArray_48() => 
    Enumerable.Range(1, elementsCount).Select(v => new LargeStruct_48(v)).ToArray();
private readonly LargeStruct_48[] _array48 = CreateArray_48();
 
[BenchmarkCategory("BigStruct_48")]
[Benchmark(Baseline = true)]
public int TestArray_48()
{
    int result = 0;
    // Using elementsCound but not array.Length to force the bounds check
    // on each iteration.
    for (int i = 0; i < elementsCount; i++)
    {
        result = _array48[i].N;
    }
 
    return result;
}


Результаты таковы:

2k5t6iq-egji_4h7wp8zgizndws.png

Видимо, что-то не так! Производительность нашей коллекции NaiveImmutableList<Т> такая же, как и у List. Что же произошло?

Возвращаемые ссылочные значения с модификатором readonly: как это работает


Как можно заметить, индексатор NaiveImmutableList возвращает ссылку, доступную только для чтения, с помощью модификатора ref readonly. Это полностью оправданно, так как мы хотим ограничить возможности клиентов в плане изменения основного состояния неизменяемой коллекции. Однако используемые нами в тесте производительности структуры доступны не только для чтения.

Данный тест поможет нам понять базовое поведение:

[Test]
public void CheckMutabilityForNaiveImmutableList()
{
    var ml = new NaiveImmutableList(new Mutable(1));
    ml[0].IncrementX();
    // X has been changed, right?
    Assert.That(ml[0].X, Is.EqualTo(2));
}


Тест прошел неудачно! Но почему? Потому что структура «ссылок, доступных только для чтения» похожа на структуру модификаторов in и полей readonly в отношении структур: компилятор генерирует защитную копию каждый раз, когда используется элемент структуры. Это значит, что ml[0]. по-прежнему создает копию первого элемента, но это делает не индексатор: копия создается в точке вызова.

Такое поведение на самом деле имеет смысл. Компилятор C# поддерживает передачу аргументов по значению, по ссылке и по «ссылке только для чтения», используя модификатор in (подробную информацию см. в публикации The in-modifier and the readonly structs in C# («Модификатор in и структуры только для чтения в C#»)). Теперь компилятор поддерживает три разных способа возврата значения из метода: по значению, по ссылке и по ссылке только для чтения.

«Ссылки только для чтения» настолько похожи на обычные, что компилятор использует один и тот же InAttribute для различения их возвращаемых значений:

private int _n;
public ref readonly int ByReadonlyRef() => ref _n;


В этом случае метод ByReadonlyRef эффективно компилируется в:

[InAttribute]
[return: IsReadOnly]
public int* ByReadonlyRef()
{
    return ref this._n;
}


Сходство между модификатором in и ссылкой только для чтения означает, что эти функции не очень подходят для обычных структур и могут вызвать проблемы с производительностью. Рассмотрим пример:

public struct BigStruct
{
    // Other fields
    public int X { get; }
    public int Y { get; }
}
 
private BigStruct _bigStruct;
public ref readonly BigStruct GetBigStructByRef() => ref _bigStruct;
 
ref readonly var bigStruct = ref GetBigStructByRef();
int result = bigStruct.X + bigStruct.Y;


Помимо необычного синтаксиса при объявлении переменной для bigStruct, код выглядит нормально. Цель ясна: BigStruct возвращается по ссылке из соображений производительности. К сожалению, поскольку структура BigStruct доступна для записи, каждый раз при доступе к элементу создается защитная копия.

Использование возвращаемых ссылочных значений в индексаторах. Попытка № 2


Давайте опробуем тот же набор тестов в отношении структур только для чтения разных размеров:

8su5je3fe_-dhpjlf4f1bpeqoug.png

Теперь в результатах гораздо больше смысла. Время обработки по-прежнему увеличивается для больших структур, но это ожидаемо, потому что обработка более 100 тысяч структур большего размера занимает больше времени. Но теперь время работы для NaiveimmutableList<Т> очень близко ко времени T[] и значительно лучше, чем в случае с List.

Заключение


  • С возвращаемыми ссылочными значениями стоит обходиться осторожно, потому что они могут разорвать инкапсуляцию.
  • Возвращаемые ссылочные значения с модификатором readonly эффективны только для структур, доступных только для чтения. В случае с обычными структурами могут проявиться проблемы с производительностью.
  • При работе со структурами, доступными для записи, возвращаемые ссылочные значения с модификатором readonly создают защитную копию при каждом использовании переменной, что может вызвать проблемы с производительностью.


Возвращаемые ссылочные значения и ссылочные локальные переменные — полезные функции для создателей библиотек и разработчиков кода инфраструктур. Впрочем, их весьма опасно использовать в коде библиотек: чтобы использовать коллекцию, эффективно возвращающую элементы с помощью ссылки, доступной только для чтения, каждый пользователь библиотеки должен помнить: ссылка только для чтения на структуру, доступную для записи, создает защитную копию «в точке вызова». В лучшем случае это сведет на нет возможный прирост производительности, а в худшем — приведет к ее серьезному ухудшению, если одновременно осуществляется большое количество запросов к одной ссылочной локальной переменной, доступной только для чтения.

P.S. Ссылки только для чтения появятся в BCL. Методы readonly ref для доступа к элементам неизменных коллекций были представлены в следующем запросе на включение внесенных изменений в corefx repo (Implementing ItemRef API Proposal («Предложение на включение ItemRef API»)). Поэтому очень важно, чтобы все понимали особенности использования этих функций и то, как и когда их следует применять.

© Habrahabr.ru