Disposable ref structs в C# 8.0

Автор оригинала: Konrad Kokosa
  • Перевод

Давайте посмотрим, что об этом сказано в блоге о предстоящих изменениях в С# 8.0 (версия Visual Studio 2019 Preview 2):


«stack-only структуры появились в С# 7.2. Они чрезвычайно полезны, но при этом их использование тесно связано с ограничениями, например невозможностью реализовывать интерфейсы. Теперь ссылочные структуры можно очищать с помощью метода Dispose внутри них без использования интерфейса IDisposable».


Так и есть: stack-only ref структуры не реализуют интерфейсы, иначе возникала бы вероятность их упаковки. Следовательно, они не могут реализовывать IDisposable, и мы не можем использовать эти структуры в операторе using:


class Program
{
   static void Main(string[] args)
   {
      using (var book = new Book())
      {
         Console.WriteLine("Hello World!");
      }
   }
}

ref struct Book : IDisposable
{
   public void Dispose()
   {
   }
}

Попытка запустить этот код приведёт к ошибке компиляции:


Error CS8343 'Book': ref structs cannot implement interfaces

Однако теперь, если мы добавим публичный метод Dispose к ссылочной структуре, оператор using магическим образом примет её, и всё скомпилируется:


class Program
{
   static void Main(string[] args)
   {
      using (var book = new Book())
      {
         // ...
      }
    }
}

ref struct Book
{
   public void Dispose()
   {
   }
}

Более того, благодаря изменениям в самом операторе теперь можно использовать using в более краткой форме (так называемые объявления using):


class Program
{
   static void Main(string[] args)
   {
      using var book = new Book();
      // ...
   }
}

Но… зачем?


Это — длинная история, но в целом явная очистка (детерминированная финализация) предпочтительнее, чем неявная (недетерминированная финализация). Это понятно на интуитивном уровне. Лучше явно очистить ресурсы как можно скорее (вызвав Close, Dispose или оператор using), вместо того чтобы ждать неявной очистки, которая произойдёт «когда-нибудь» (когда сама среда запустит финализаторы).


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


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


Давайте рассмотрим иллюстративный пример обычной «обёртки для пула неуправляемой памяти». Она занимает минимально возможное место (куча не используется совсем) именно благодаря ссылочной структуре, предназначенной для людей, помешанных на производительности:


public unsafe ref struct UnmanagedArray<T> where T : unmanaged
{
   private T* data;
     public UnmanagedArray(int length)
   {
      data = // get memory from some pool
   }

   public ref T this[int index]
   {
      get { return ref data[index]; }
   }

   public void Dispose()
   {
      // return memory to the pool
   }
}

Поскольку в обёртку заключён неуправляемый ресурс, для очистки после использования мы применяем метод Dispose. Таким образом, пример выглядит как-то так:


static void Main(string[] args)
{
   var array = new UnmanagedArray<int>(10);
   Console.WriteLine(array[0]);
   array.Dispose();
}

Это неудобно, поскольку нужно помнить о вызове Dispose. Кроме того, это болезненное решение, поскольку обработка исключений должным образом здесь неприменима. Поэтому, для того чтобы Dispose мог быть вызван изнутри, ввели оператор using. Однако ранее, как уже говорилось, применять его в этой ситуации было нельзя.


Но в С# 8.0 можно использовать преимущества оператора using по полной:


static void Main(string[] args)
{
   using (var array = new UnmanagedArray<int>(10))
   {
      Console.WriteLine(array[0]);
   }
}

При этом код стал лаконичнее благодаря объявлениям:


static void Main(string[] args)
{
   using var array = new UnmanagedArray<int>(10);
   Console.WriteLine(array[0]);
}

Два других примера внизу (значительная часть кода опущена для краткости) взяты из репозитория CoreFX.


Первый пример – ссылочная структура ValueUtf8Converter, которая оборачивает массив byte[] из пула массивов:


internal ref struct ValueUtf8Converter
{
   private byte[] _arrayToReturnToPool;
   ...

   public ValueUtf8Converter(Span<byte> initialBuffer)
   {
      _arrayToReturnToPool = null;
   }

   public Span<byte> ConvertAndTerminateString(ReadOnlySpan<char> value)
   {
      ...
   }

   public void Dispose()
   {
      byte[] toReturn = _arrayToReturnToPool;
      if (toReturn != null)
      {
         _arrayToReturnToPool = null;
         ArrayPool<byte>.Shared.Return(toReturn);
      }
   }
}

Второй пример – RegexWriter, оборачивающий две ссылочные структуры ValueListBuilder, которые необходимо очистить явным образом (поскольку они тоже управляют массивами из пула массивов):


internal ref struct RegexWriter
{
   ...
   private ValueListBuilder<int> _emitted;
   private ValueListBuilder<int> _intStack;
   ...

   public void Dispose()
   {
      _emitted.Dispose();
      _intStack.Dispose();
   }
}

Заключение


Удаляемые ссылочные структуры можно рассматривать как занимающие мало место типы, у которых есть РЕАЛЬНЫЙ деструктор, как в C++. Он будет задействован, как только соответствующий экземпляр выйдет за пределы области оператора using (или области видимости в случае объявления using).


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


А ещё у нас есть статья про нашу конференцию:

CLRium #5
57,00
Компания
Поддержать автора
Поделиться публикацией

Комментарии 42

    +3

    Скоро С++ и С# встретятся и влюблятся :) Очень жду дитя, которое они породят :)

      +4
      C++/CLI? Оно настолько одиноко, что никому не нужно за очень редким исключением :)
        +6
        image
          +1

          Они близкие родственники. Повод задуматься.

          –1
          Лучше явно очистить ресурсы как можно скорее (вызвав Close, Dispose или оператор using), вместо того чтобы ждать неявной очистки, которая произойдёт «когда-нибудь» (когда сама среда запустит финализаторы).

          А точнее, которая может произойти никогда. MSDN намекает на то, что Dispose нужен мягко говоря для другого — не просто что-то закрыть, а освободить неуправляемые ресурсы, которые иначе освобождены никогда не будут.
            +1

            У автора правильно написано. Освобождение ресурса может быть написано в финализаторе без всякого Dispose. Паттерн нужен в первую очередь для детерминированной очистки ресурсов.

              0

              Пардон, "детерминированного освобождения ресурсов"

                0
                А если
                public struct something : IDisposable

                то в каком именно финализаторе он освободится, в несуществующем?
                  0

                  Ещё раз про статью в msdn: там описано исходное назначение паттерна — детерминированное освобождение ресурса. О том что stack-only struct поддерживает только явную очистку захваченных ресурсов у автора также указано в статье, но в msdn не об этом. А для приведённого вами примера характерно использование Dispose для работы с управляемыми ресурсами через Dispose (например, локальный захват локера на запись и его высвобождение в Dispose). В том же случае, если вы ссылаетесь на неуправляемый ресурс в структуре (обычной как в примере, не ссылочной), то это как минимум может привести к нарушению семантики владения ресурсом при копировании структуры (неявном опять таки). Да, согласен, в данном случае финализатора нет и есть хорошая возможность выстрелить в ногу из-за отсутствия финализатора и Dispose тут как бы решает проблему, но, тем ни менее, это не отменяет исходное назначение паттерна.

                    0
                    Еще раз о том, что написано у автора:
                    вместо того чтобы ждать неявной очистки, которая произойдёт «когда-нибудь»

                    И о моей поправке:
                    А точнее, которая может произойти никогда

                    Если не вызывать Dispose явно (или через using), то очистка может не произойти никогда — если это struct (кстати, любая struct). И вы получите утечку памяти/хэндла/еще каких гадостей. Мне кажется, вы совершенно проигнорировали мою исходную позицию, и вместо этого теперь пытаетесь сказать мне то же самое.
                    И дело тут, кмк, не в
                    Лучше явно очистить ресурсы как можно скорее

                    А в том, что если Dispose не вызвать — вы потенциально простреливаете себе ногу.
                      0
                      Еще раз о том, что написано у автора

                      Тут согласен — формулировка неверная, но лишь отчасти (если ресурс используемый в этой ref структуре не обернут в IDisposable объект хотя и абзац был про вообще, а не структуры в частности). Т.е. если там FileStream, то верно, а если нативный буфер, то нет.
                      Мне кажется, вы совершенно проигнорировали мою исходную позицию, и вместо этого теперь пытаетесь сказать мне то же самое

                      Я отвечал именно на эту вот фразу:
                      MSDN намекает на то, что Dispose нужен мягко говоря для другого — не просто что-то закрыть, а освободить неуправляемые ресурсы, которые иначе освобождены никогда не будут

                      C этим тезисом я не совсем согласен — в msdn о другом, т.к. если посмотреть на пример в статье, то по нему видно, что даже не вызвав Dispose мы всё равно с течением времени освободим ресурс, но недетерминировано (разумеется, при корректной реализации паттерна, приведенной в качестве примера). В msdn-статье нет ни слова о структурах, т.к. для обычных паттерн юзается не по назначению (и, вообще, зачем нужны явные umanaged в структуре(?), явный антипаттерн и источник проблем за редкими исключениями), а ref появились уже позже и тут вызов Dispose обязателен.
                      Вобщем, я думаю, вопрос исчерпан.

                      почему 'не совсем согласен'
                      Немного поясню почему «не совсем согласен»: всё-таки могут быть edge-кейсы, когда ресурс действительно может стать zombie. Если он уплыл в GC/2 и долго там не собирался мусор, а потом приложение завершается, но финализаторы не отрабатывают в установленный таймаут, а ресурсом был ком-объект через DCOM (вне процесса). Или сайт в пуле IIS хостится, а umnaged-ресурс не CFO. Но это скорее нештатные кейсы.
                        –1
                        формулировка неверная, но лишь отчасти

                        С каких пор у boolean появилось, помимо истины/лжи, третье значение «может быть»?
                        в msdn о другом

                        Читаем по ссылке:
                        Provides a mechanism for releasing unmanaged resources.
                        Перевод: Предоставляет механизм для отчистки неуправляемых ресурсов. Нету ни слова о детерменированности, вы себе ее тут придумываете.
                        что даже не вызвав Dispose мы всё равно с течением времени освободим ресурс, но недетерминировано

                        Не освободим, если это struct. Я это уже говорил, но вы решили об этом забыть.
                        и, вообще, зачем нужны явные umanaged в структуре(?), явный антипаттерн и источник проблем за редкими исключениями

                        Антипаттерн — это использовать публичный протокол, который требует явной очистки ресурсов, и потом эти ресурсы не очищать. Также антипаттерн — это надеяться на детали реализации, что кто-то за вами там что-то почистит когда-нибудь. А еще антипаттерн — это использовать классы и гонять по ссылке то, что может спокойно себе полежать на стэке:
                        public struct SomeCThing : IDisposable
                        {
                            [DllImport("c.dll")]
                            private static extern IntPtr create_c_thing(...);
                        
                            private IntPtr handle;
                            // Дальше идут обвязочные методы
                        }
                          0
                          Читаем по ссылке:

                          Угу, читаем прям вот следующий абзац
                          Use the Dispose method of this interface to explicitly release unmanaged resources in conjunction with the garbage collector

                          Не освободим, если это struct. Я это уже говорил, но вы решили об этом забыть.

                          Попробуйте прочитать весь абзац из которого вырвана эта фраза и понять к чему она относится. Не вижу смысла в очередной раз повторяться.
                          Антипаттерн — это использовать публичный протокол, который требует явной очистки ресурсов, и потом эти ресурсы не очищать. Также антипаттерн — это надеяться на детали реализации, что кто-то за вами там что-то почистит когда-нибудь.

                          А я разве где-то писал о том, что явно диспозить объект не нужно? Я говорил об исходном назначении паттерна, описанного в статье msdn.
                          Касательно структур с unmanaged: да, иногда очень надо для совсем критичных мест и писаться этот участок должен очень аккуратно, т.к. поломать стейт unmanaged ресурса очень легко, если он не весь идемпотентный и требуется хранить даже минимально мутабельный стейт внутри объекта, владеющего unamanaged ресурсом. Для общего случая работы с Unmanaged это скорее антипаттерн.

                          Пример
                          Before — был какой-то метод с кучей вызовов в Unmanaged
                          After — кто-то его порефакторил. Визуально — вполне легальный рефакторинг без претензий компилятора.
                          Т.е. использование unmanaged в структуре накладывает еще и жесткие ограничения на работу с ней через ref (о которых компилятор не парится), а не только на необходимость вызвать Dispose.
                          ref struct эту проблему тоже не решает, но хотя бы не дает навесить интерфейс и вызывать методы через боксинг (что тоже кораптит стейт), что уже лучше.

                              public unsafe struct CustomStream : IDisposable
                              {
                                  private int _size;
                                  private int _currentPos;
                                  private IntPtr _ptr;
                                  private bool _isDisposed;
                          
                                  public CustomStream(int initialSize)
                                  {
                                      _currentPos = 0;
                                      _size = initialSize;
                                      _ptr = Marshal.AllocHGlobal(initialSize);
                                      _isDisposed = false;
                                  }
                                  
                                  public void Append(byte data)
                                  {
                                      if (_currentPos < _size)
                                      {
                                          Marshal.WriteByte(_ptr, _currentPos, data);
                                          // тут вот мы изменяем наш стейт
                                          _currentPos++;
                                      }
                                  }
                          
                                  public void Dispose()
                                  {
                                      if (!_isDisposed)
                                      {
                                          Marshal.FreeHGlobal(_ptr);
                                          _isDisposed = true;
                                      }
                                  }
                              }
                          
                              public class Example
                              {
                                  public void Before()
                                  {
                                      using (CustomStream stream = new CustomStream(1024))
                                      {
                                          stream.Append(0);
                                          stream.Append(1);
                                          // всё ОК
                                      }
                                  }
                          
                                  public void After()
                                  {
                                      using (CustomStream stream = new CustomStream(1024))
                                      {
                                          WriteHeaderBlock(stream);
                                          WriteClosingBlock(stream); // вот тут уже кораптим стейт
                                      }
                                  }
                          
                                  public void WriteHeaderBlock(CustomStream stream)
                                  {
                                      stream.Append(0);
                                  }
                          
                                  public void WriteClosingBlock(CustomStream stream)
                                  {
                                      stream.Append(1);
                                  }
                              }
                          


                0
                Ну, по части освобождения ресурсов и всего что с ним связано в MSDN написано нечто очень странное, а местами — устаревшее.

                В реальности метод Dispose может использоваться для чего угодно. При желании, им можно полностью заменить любые повторяющиеся блоки finally, что стало ещё актуальнее с появлением краткой формы оператора using.
                  –1
                  Походу надежнее написать try...finally, чтобы избежать возможных сюрпризов от компилятора наподобие (IDisposable)book).Dispose();
                    0
                    ref-структура не может быть упакована, а при таком касте произойдет упаковка. Именно поэтому ref-структуры не поддерживают интерфейсы. В статье об этом написано.
                      –1
                      Я привел пример работы using со структурой, во что компилятор решает развернуть код в блоке finally:
                       book book = default(book);
                              try
                              {
                              }
                              finally
                              {
                                  ((IDisposable)book).Dispose(); //тут сюрприз, если book обычная IDisposable структура
                              }


                      Я же не говорю что он тоже самое делает с ref. Если компилятор работает лучше с ref структурой, то это только хорошо.
                        0
                        Лучше посмотрите IL-код, сгенереный для исходного варианта с using, там нет боксинга и для обычных структур.
                          0
                          Я ниже уже писал, что приведение сгенерится, а упаковки невидно. Причем если будет хоть какая логика, наподобие инициализации поля, то компилятор тупо скопирует значение структуры в новую переменную. И чем вам не понравился после этого вариант с ручным try...finally?
                            0
                            IL-код из блока finally для обоих случаев.
                            Вариант с юзингом:
                            IL_000d: ldloca.s b
                            IL_000f: constrained. C/book
                            IL_0015: callvirt instance void [System.Runtime]System.IDisposable::Dispose()

                            Вариант с ручным try-finally:
                            IL_000e: ldloc.0
                            IL_000f: box C/book
                            IL_0014: callvirt instance void [System.Runtime]System.IDisposable::Dispose()
                              –1
                              Под ручным то не надо приводить, просто вызвать Dispose. К сожалению даже так используется еще одна переменная структура… Вывод: нельзя использовать этот механизм со структурами.
                        +1
                        ref struct не может быть упакована только с точки зрения языка. MSIL ничего о ref struct не знает. Кстати упаковать ref struct не так уж сложно с помощью активатора, если очень хочется.
                        0
                        Я вас огорчу, тут упаковки не будет. Тут будет callvirt предваренный constrained опкодом. И далее зависит от JITа, если если метод определен в структуре, то упаковки не будет, будет прямой вызов метода, а если нет (для структуры это вызов методов ValueType не переопределенных в структуре) то упаковка и вызов виртуального метода.
                          0
                          все как Вы говорите, только вот выглядит так, будто JIT боксирует, судя по результатам
                          запускал и получал те же результаты на netcore2.2, netcore3.0
                            0
                            Не «боксирует», а копирует. И не JIT, а компилятор.
                              0
                              ох, да, вижу
                              тогда у меня следующие вопросы:
                              1) зачем компилятор это делает (копирует переменную)? Это не то, что я ожидаю
                              2) почему в этом случае Dispose(), который в finally, вызывается через constrained? Ведь просто вызов (не override) метода у структуры — это просто call. Если структура приводится к интерфейсу — тогда будет box, которого я не вижу в finally

                              В общем: какую магию применяет компилятор по отношению к структурам в using?
                                0
                                Смысл constrained call — в том, чтобы вызвать не просто Dispose, а IDisposable::Dispose, но без приведения типа.

                                Это важно при явной реализации метода, и не играет никакой роли при публичной реализации.
                                  0
                                  Это важно при явной реализации метода, и не играет никакой роли при публичной реализации.

                                  вот этого не понял… Что за публичная реализация? Можно пример, в котором видна разница Dispose и IDisposable::Dispose вызовов?
                                    0

                                    Если метод реализован явно (explicit), т.е. не как public void Dispose(), а как void IDisposable.Dispose() — то вызвать его можно либо через каст к интерфейсу (что для структуры будет упаковкой), либо через constrained call.


                                    А копия делается для того, чтобы не изменилось наблюдаемое поведение.

                                    0
                                    и все же: зачем компилятор делает копирование?
                                    0
                                    Компилятор делает защитную копию, потом вызывается callvirt предваренный constraited. Из-за защитной копии, все уверены, что будет упаковка, выберите на шарплабе компиляцию в C# и увидите. однако, у вас в примере в типе только булево поле оно копируется в защитную копию, однако если у вас там ссылка на неуправляемый ресурс, применение после using черевато.
                                      0
                                      кстати недавно было обсуждение этого вопроса, вот советую почитать sergeyteplyakov.blogspot.com/2019/02/c.html?m=1
                                      0
                                      del
                                    0
                                    А я и не говорю что в ref будет упаковка.

                                    upd: вы кстати пишите что если структура(не ref), то упаковки не будет. Хочу уточнить, если я обычную структуру (:IDisposable) использую вместе с using, то упаковки не будет?
                                      0

                                      Не будет. Сделали специальную оптимизацию в обход спецификации: https://stackoverflow.com/questions/2412981/if-my-struct-implements-idisposable-will-it-be-boxed-when-used-in-a-using-statem/2413844#2413844

                                        0
                                        Никакого обхода нет. Фактически оптимизация заключается в удалении преобразования (о чем написал Эрик в блоге, ссылка есть в его ответе), ну и constrained вызовы специально сделали в cil, как раз для подобного. Естественно, когда вы структуру присваиваете переменной интересного типа и потом вызываете метод, то тут и будут все прелести боксинга.
                                          0
                                          Компилятор генерит привидение к Idisposable, но вот при запуске похоже что упаковки нет
                                    +3
                                    Примечание. Не забывайте, что в случае ссылочных структур используется только явная очистка, поскольку определение финализаторов для них невозможно.

                                    Сказано так, будто у обычных структур есть финализатор
                                      0
                                      Довольно не однозначное впечатление от этой фичи языка.
                                      С одной стороны — это (ref структуры) требуется в том случае когда нужна небольшая обертка вокруг неуправляемых ресурсов и эта тонкая обертка позволяет детерминированно освобождать их НО с другой стороны очень нужно что бы компилятор как минимум предупреждал что для такой структуры не был вызван метод Dispose и/или эта структура требует использования using оператора или же студия каким либо иным способом (какой — либо анализатор кода ) давала возможность разрабочтику понять что у него потенциальная утечка ресурсов.
                                        0
                                        Я бы даже сказал, что у них возможен финализатор. Только вызываться он должен по очистке занятой зоны стека.
                                        0
                                        del

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

                                        Самое читаемое