ЛУЧШИЕ ПРАКТИКИ

Защитная копия в .NET C#

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

Вы когда-нибудь слышали об защитном копировании в .NET C#?

Вы знаете, насколько важна эта тема?

Знаете ли вы, что из-за этого вы можете тратить память и вычислительную мощность впустую?

Позвольте мне рассказать вам об этом…



Логические

Прежде чем вдаваться в подробности, позвольте мне показать вам кое-что интересное.

Допустим, у нас есть эта структура, определенная следующим образом:

public struct Num
{
    public int Value;

    public Num(int value)
    {
        Value = value;
    }

    public void Increment()
    {
        Value++;
    }

    public override string ToString() => $"Value = {Value.ToString()}";
}

Как видите, это простая структура Num с:

  • Поле int с именем Value .
  • Конструктор.
  • Метод под названием Increment, который увеличивает поле Value на 1.
  • Переопределение метода ToString.

Теперь предположим, что у нас есть следующий код:

public class MainProgram
{
    private Num _number = new Num(1);

    public void Run()
    {
        Console.WriteLine("Before Increment: " + _number.ToString());
        _number.Increment();
        Console.WriteLine("After Increment: " + _number.ToString());
    }
}

В этом классе мы только что определили приватное поле типа Num, а в методе Run мы просто увеличили значение поля, вызвав его собственный метод Increment.

Если мы запустим этот код, мы получим такой результат:

Как и ожидалось, верно?

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

Давайте изменим код, чтобы он был следующим:

public class MainProgram
{
    private readonly Num _number = new Num(1);

    public void Run()
    {
        Console.WriteLine("Before Increment: " + _number.ToString());
        _number.Increment();
        Console.WriteLine("After Increment: " + _number.ToString());
    }
}

Как вы можете заметить, единственное изменение, которое мы применили, — это добавление ключевого слова readonly в объявление поля.

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

Когда мы запустим новый код, мы получим такой результат:

Я слышу, как ты сейчас кричишь:

Какого черта? Как это возможно?!!!

Да, это возможно, и это действительно происходило бы каждый раз, поскольку это не сбой в матрице или что-то в этом роде 😁

Позвольте мне объяснить это вам.

Заглянуть под капот

На самом деле произошло то, о чем я спрашивал вас в первых строках этой статьи; это Защитная копия.

То, что произошло, можно разбить на простые шаги:

  1. Когда мы пометили поле ключевым словом readonly, это показало наше намерение оставить это поле полностью нетронутым. Другими словами, мы не хотим, чтобы к объекту за этим полем применялись какие-либо изменения.
  2. Поэтому компилятор нас действительно выслушал и понял наши намерения. Таким образом, компилятор решил помочь нам в достижении нашей цели.
  3. Затем мы попытались увеличить поле, вызвав его собственный метод Increment.
  4. Таким образом, именно здесь компилятор решил вмешаться и защитить наш объект поля от любых изменений, даже если эти изменения запускаются изнутри него самого. Но как это сделать компилятору?
  5. Компилятор сделал бы это, сначала создав копию объекта поля, а затем применив к ней вызов Increment, а не к исходному объекту поля.
  6. Здесь стоит упомянуть, что объект поля имеет тип Num, который является структурой. Как мы знаем, копирование структуры даст совершенно новый объект.
  7. Следовательно, это в конечном итоге защитит наш объект поля от любых изменений.

Итак, простыми словами, этот код:

public class MainProgram
{
    private readonly Num _number = new Num(1);

    public void Run()
    {
        Console.WriteLine("Before Increment: " + _number.ToString());
        _number.Increment();
        Console.WriteLine("After Increment: " + _number.ToString());
    }
}

В конечном итоге будет переведено в этот код:

public class MainProgram
{
    private readonly Num _number = new Num(1);

    public void Run()
    {
        var number = _number;
        Console.WriteLine("Before Increment: " + number.ToString());

        number = _number;
        number.Increment();

        number = _number;
        Console.WriteLine("After Increment: " + number.ToString());
    }
}

Теперь вы можете спросить:

Но зачем компилятору создавать копию объекта поля перед вызовом ToString?!! Этот вызов никоим образом не изменит объект.

Да, ты прав. Не ожидается применения каких-либо изменений к объекту поля, но это не то, как думает компилятор.

Компилятор не проверяет код внутри метода и решает, будет ли он применять какие-либо изменения к объекту или нет.

Он просто предполагает, что это может произойти, и этого достаточно, чтобы компилятор проявил осторожность и применил механизм Defensive Copy.

Надеюсь, теперь вы понимаете, что произошло на самом деле. В следующем разделе мы подробнее поговорим о механизме Защитное копирование.

Защитный механизм копирования

А теперь давайте рассмотрим вопросы, которые, скорее всего, у вас сейчас возникли.

❓ Когда это происходит?

Это происходит, когда объект struct используется в контексте только для чтения и этим объектом манипулируют.

❓ Что вы подразумеваете под манипулированием?

Манипуляция означает вызов любого метода для объекта. Кроме того, вызов свойства аналогичен тому, что свойство является методом в конце. Однако вызов поля не активирует механизм.

❓ Что вы подразумеваете под контекстом только для чтения?

Это означает, что объект объявлен как один из следующих:

👉 Поле только для чтения

public class MainProgram
{
    private readonly Num _number = new Num(1);

    public void Run()
    {
        Console.WriteLine("Before Increment: " + _number.ToString());
        _number.Increment();
        Console.WriteLine("After Increment: " + _number.ToString());
    }
}

👉 ref только для чтения локальная переменная

public class MainProgram
{
    private Num _number = new Num(1);

    public void Run()
    {
        ref readonly Num number = ref _number;

        Console.WriteLine("Before Increment: " + number.ToString());
        number.Increment();
        Console.WriteLine("After Increment: " + number.ToString());
    }
}

👉 параметр in

public class MainProgram
{
    public void Run(in Num number)
    {
        Console.WriteLine("Before Increment: " + number.ToString());
        number.Increment();
        Console.WriteLine("After Increment: " + number.ToString());
    }
}

❓ Так ли это важно? Должны ли мы беспокоиться о том, срабатывает ли защитная копия или нет?

На самом деле это зависит от размера структуры и от частоты срабатывания механизма защитного копирования.

👉 Чем больше структура, тем большего влияния мы должны ожидать.

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

Позволь мне показать тебе…

Влияние на производительность

Давайте упростим структуру Num и добавим в нее больше полей, чтобы увеличить ее размер. Тогда код должен быть следующим:

public struct Num
{
    // Fields to just make the struct bigger
    private long Field1, Field2, Field3, Field4;

    public long Value { get; }

    public Num(long value) : this()
    {
        Value = value;
    }
}

Теперь давайте создадим проект эталонной оценки, чтобы сравнить производительность:

👉 Поле структуры Num.

👉 Поле только для чтения структуры Num.

[MemoryDiagnoser]
[RankColumn]
public class Benchmarker
{
    private const int Count = 1_000_000;
    private Num _number = new Num(1);
    private readonly Num _readonlyNumber = new Num(1);

    [Benchmark(Baseline = true)]
    public long UsingField()
    {
        long total = 0;

        for (var i = 0; i < Count; i++)
        {
            total += _number.Value;
        }

        return total;
    }

    [Benchmark]
    public long UsingReadonlyField()
    {
        long total = 0;

        for (var i = 0; i < Count; i++)
        {
            total += _readonlyNumber.Value;
        }

        return total;
    }
}

Запустив этот проект эталонной оценки, мы получим следующий результат:

Как видно из результата, использование поля в 4 раза быстрее, чем использование поля readonly.

Это может побудить вас задать следующий вопрос:

Хорошо, теперь я знаю, что это может иметь значение, но есть ли способ избежать этой защитной копии, когда она на самом деле не нужна??

Да, я понимаю вашу точку зрения. Иногда компилятор просто запускает механизм Defensive Copy, даже если вызовы не будут применять какие-либо изменения к объекту.

Было бы плохо, если бы мы всегда должны были платить по счетам только потому, что компилятор хочет быть осторожным. Но должны ли мы??

Решение

Нет, не должны. Мы не всегда вынуждены соглашаться оплачивать этот счет, выделяя больше памяти и тратя впустую вычислительную мощность. Есть решение.

Для исправления достаточно просто пометить struct как readonly.

Когда мы помечаем структуру как только для чтения, компилятор гарантирует, что она неизменяема. Это означает, что никто не может манипулировать объектом или изменять его состояние.

Соответственно, компилятор получает уверенность, что никакие вызовы не будут манипулировать объектом, и поэтому механизм Defensive Copy не нужен.

Поэтому, если мы изменим код следующим образом:

public readonly struct Num
{
    public readonly int Value;

    public Num(int value)
    {
        Value = value;
    }

    public Num Increment()
    {
        return new Num(Value + 1);
    }

    public override string ToString() => $"Value = {Value.ToString()}";
}

Теперь, если мы запустим тот же самый проект benchmarker, мы получим следующий результат:

Видите ли, пометка структуры как readonly убрала накладные расходы механизма Defensive Copy, сэкономив память и вычислительную мощность.

Средства обнаружения

Теперь вы можете спросить:

Есть ли какой-либо инструмент, который поможет мне определить возникновение защитной копии?

Да, пакет nuget ErrorProne.NET Structs.

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

Что вам нужно иметь в виду, так это то, что анализаторы выдают диагностику только тогда, когда размер структуры составляет ›= 16 байт.

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

error_prone.large_struct_threshold = {new threshold}

.NET ядро

Все, что мы обсуждали до этого момента, относится к .NET Framework. С .NET Core все изменилось.

Та же концепция Defensive Copy по-прежнему существует, но структура теперь стала умнее. Некоторый код, ошибочно определяемый .NET Framework как манипулятивный, не будет обнаружен .NET Core.

Последние мысли

Надеюсь, к этому моменту вы уже все поняли о механизме Защитное копирование.

Мой последний совет вам:

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

👉 Это позволяет легко помечать структуры как только для чтения.

👉 В структурах всегда старайтесь использовать поля, а не свойства. Это поможет вам избежать слишком многих проблем.

👉 Даже с .NET Core вы должны следовать передовым методам, а не полагаться только на фреймворк, который позаботится об этом.

Вот и все. Надеюсь, вам будет так же интересно читать эту статью, как мне было интересно ее писать.

Надеюсь, вы нашли этот контент полезным. Если вы хотите поддержать:

▶ Если вы еще не являетесь участником Medium, вы можете использовать мою реферальную ссылку, чтобы я мог получать часть ваших сборов от Medium. > вы ничего не платите.
▶ Подпишитесь на мою рассылку новостей, чтобы получать рекомендации, руководства, подсказки, подсказки и многое другое прямо на ваш почтовый ящик.

Другие источники

Это другие ресурсы, которые могут оказаться полезными.