eFusion 11 октября 2020

♻ Как правильно управлять ресурсами в .NET Core

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

Перевод публикуется с сокращениями, автор оригинальной статьи Daniel Glucksman.

***

Даже если вы сами не используете неуправляемые ресурсы напрямую или не знаете об их существовании, многие встроенные классы .NET используют их из коробки: сетевое взаимодействие (System.Net), потоки и обработка файлов (System.IO), обработка изображений (System.Drawing), криптография. Полный список доступен на сайте.

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

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

  • это произойдет не сразу, и приложение будет продолжать их потреблять до тех пор, пока GC не решит провести очистку в фоновом режиме, вызвав финализатор (Finalizer);
  • сборщик мусора будет влиять на производительность во время процесса очистки.

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

Вы спросите, как нам правильно распоряжаться ресурсами в .NET Core? Чтобы ответить на этот вопрос, позвольте мне познакомить вас с IDisposable.

Что такое IDisposable?

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

        public class MyClass : IDisposable
{ 
  public void Dispose() 
  {
    // Тут чистим ресурсы
  }
}
    

В C# 8 появился асинхронный способ утилизации ресурсов с помощью IAsyncDisposable и DisposeAsync.

        using System.Threading.Tasks;
public class MyClass : IAsyncDisposable
{
  public async ValueTask DisposeAsync() 
  {
    // Тут чистим ресурсы
  }
}
    

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

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

Правильный способ реализации IDisposable

Рекомендуемая практика заключается в реализации Dispose таким образом, чтобы независимо от количества вызовов метода очищался он только один раз. Прежде, чем пытаться освободить ресурсы, необходимо проверить, был ли объект уже удален:

        public class MyClass : IDisposable

{

   private bool isDisposed = false;   

   public void Dispose()         

   {             

      if(this.isDisposed)

        return;      

      // Избавляемся от управляемых ресурсов   
      
      // Избавляемся от неуправляемых ресурсов
         
      isDisposed = true;   
   }
}
    

Если вы планируете наследовать от класса, необходимо сделать метод Dispose виртуальным, как показано ниже:

        public class MyClass : IDisposable

{

   private bool disposed = false;   
 
   public virtual void Dispose()         

   {             

    //...

   }
}
    

Виртуальный метод дает наследуемому классу возможность переопределить функцию и очистить ресурсы:

        public class MyInheritedClass : MyClass

{

   public override void Dispose()

   {

      //Cleanup логика, специфичная для наследуемого класса 
     
      base.Dispose(); // вызываем функцию cleanup в MyClass

   }

}
    

Предупреждение: если вы забудете вызвать метод базового класса Dispose, ресурсы не будут полностью очищены.

Finalizers

IDisposable полагается на разработчика, чтобы правильно вызвать метод Dispose. Вы можете добавить финализатор в свой класс для обеспечения автоматической очистки ресурсов: даже если вы забудете о методе Dispose, GC все сделает сам.

Финализатор определяется с помощью символа «~», за которым следует имя класса:

        public class MyClass

{

   ~MyClass()

   {

      // Тут чистим ресурсы

   }

}
    

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

При использовании финализатора стоит централизовать логику dispose в дополнительную функцию (мы назвали ее Cleanup), которая может быть вызвана как из финализатора, так и из метода Dispose.

        public class MyClass : IDisposable

{

   private bool disposed = false;   

   public void Dispose()         
   {             

     Cleanup();

   }   

   private void Cleanup()

   {
      if(this.disposed)
        return;
   
      // Избавляемся от управляемых ресурсов   
      
      // Избавляемся от неуправляемых ресурсов
   
      disposed = true;   

   }   

   ~MyClass()

   {

     Cleanup();

   }

}
    

Функция Dispose должна сообщить сборщику мусора, что ему не нужно вызывать финализатор, поскольку ресурсы уже были очищены – это поможет избежать дополнительных расходов на процесс очистки GC.

        public class MyClass : IDisposable

{

   private bool disposed = false;   

   public void Dispose()         

   {             

     Cleanup();

     GC.SuppressFinalize(this);   

   }   

   private void Cleanup()

   {

      if(this.disposed)

        return;
      
      // Избавляемся от управляемых ресурсов   
      
      // Избавляемся от неуправляемых ресурсов

      disposed = true;   

   }   

   ~MyClass()

   {

     Cleanup();

   }

}
    

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

        public class MyClass : IDisposable

{

   private bool disposed = false;   public void Dispose()         

   {             

     Cleanup(false);

     GC.SuppressFinalize(this);

   }   

   private void Cleanup(bool calledFromFinalizer)

   {

      if(this.disposed)

        return;      

      if(!calledFromFinalizer)

      {

        // Избавляемся от управляемых ресурсов

      }      

      // Избавляемся от неуправляемых ресурсов
 
      disposed = true;

   }  

   ~MyClass()

   {

     Cleanup(true);

   }

}
    

Если вы будете наследовать от приведенного выше класса, то можно пометить функцию Cleanup как виртуальную:

        protected virtual void Cleanup(bool calledFromFinalizer)
    

Унаследованный класс переопределит ее следующим образом:

        public class MyInheritedClass : MyClass

{

   protected override void Cleanup(bool calledFromFinalizer)

   {

     //Cleanup логика, специфичная для наследуемого класса       

      base.Cleanup(calledFromFinalizer);
   }
}
    

Использование IDisposable

Правило 1: утилизируйте классы, реализующие IDisposable

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

Возьмем, к примеру, класс StreamWriter. Инициализируем объект, запишем одну строку текста в файл, а затем очистим все.

        StreamWriter writer = new StreamWriter("newfile.txt");            

writer.WriteLine("Line of Text");    

writer.Dispose();
    

Если во время записи в файл возникнет исключение, то StreamWriter не будет удален должным образом.

Правильный способ это исправить – обернуть все в блок try/finally, чтобы гарантировать вызов Dispose даже если возникнет исключение.

        StreamWriter writer = new StreamWriter("newfile.txt");

try 
{
   writer.WriteLine("Line of Text");
}

finally 
{
  writer.Dispose();
}
    

Правило 2: Если ваш класс владеет объектом IDisposable, реализуйте IDisposable

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

Например, класс Logger обертывает встроенный класс StreamWriter, чтобы включить запись в файл журнала.

        public class Logger

{

  private readonly StreamWriter _streamWriter;  

  public Logger() 

  {

    _streamWriter = new StreamWriter("logfile.txt");

  }  

  public void WriteToLog(string text)

  {

    _streamWriter.WriteLine(text);

  }

}
    

Поскольку класс StreamWriter реализует IDisposable, нужно реализовать IDisposable в нашем классе Logger для очистки ресурсов StreamWriter.

        public class Logger : IDisposable 

{

  private readonly StreamWriter _streamWriter;  public Logger() 

  {

    _streamWriter = new StreamWriter("logfile.txt");

  }  

  public void WriteToLog(string text)

  {

    _streamWriter.WriteLine(text);

  }  

  public void Dispose()

  {

    _streamWriter.Dispose();

  }

}
    

Пользователь класса Logger может распорядиться ресурсами следующим образом:

        Logger logger = new Logger();try 
{

  logger.WriteToLog("Log Line");

}

finally
{

  logger.Dispose();

}
    

Правило 3: при непосредственном использовании неуправляемых ресурсов реализуйте IDisposable и финализатор

Если класс задействует какие-либо неуправляемые ресурсы напрямую, обязательно реализуйте IDisposable. GC может автоматически очищать управляемые ресурсы, но не знает, когда вы закончите работу с неуправляемыми.

        public class UnmanagedClass : IDisposable 

{

  private IntPtr pointer;

  private bool disposed = false;  

  public UnmanagedClass() 

  {

    pointer = Marshal.AllocHGlobal(1024);

  }  

  public void Dispose()

  {

    if(disposed)

      return;    


    Marshal.FreeHGlobal(pointer);

    pointer = IntPtr.Zero;

    GC.SuppressFinalize(this);

    disposed = true;

  } 

  ~UnmanagedClass()

  {

    Dispose();

  }

}
    

Правило 4: избегайте необработанных исключений

Никогда не создавайте необработанные исключения в методе Dispose. Поскольку Dispose будет действовать в блоке finally, любые необработанные исключения всплывут в приложении. Если Dispose вызывается из Finalizer, нормальная работа приложения не гарантируется.

        public class Logger : IDisposable 

{

  private readonly StreamWriter _streamWriter;  

  public Logger() 

  {

    _streamWriter = new StreamWriter("logfile.txt");

  }  

  public void WriteToLog(string text)

  {

    _streamWriter.WriteLine(text);

  }  

  public void Dispose()

  {

    _streamWriter.Dispose();

    throw new Exception();

  }

}
    

Оператор «using»

Оператор using – это конструкция, которая автоматически вызывает метод Dispose при выходе из своей области видимости, даже если внутри нее возникает исключение. Возьмем предыдущий код:

        StreamWriter writer = new StreamWriter("newfile.txt");try 

{

  writer.WriteLine("Line of Text");

}

finally

{ 

    writer.Dispose();

}
    

После реализации блока using пример будет выглядеть следующим образом:

        using(StreamWriter writer = new StreamWriter("newfile.txt"))

{

   writer.WriteLine("Line of Text");} //Тут Dispose вызовется автоматом
    

Начиная с C# 8, блок using не нуждается в фигурных или обычных скобках, поэтому код можно упростить:

        using StreamWriter writer = new StreamWriter("newfile.txt");

writer.WriteLine("Line of Text");
    

Вы можете инициализировать несколько переменных одного типа в блоке using, если не используется ключевое слово var:

        using StreamWriter writer = new StreamWriter("newfile.txt"),

      otherWriter = new StreamWriter("otherFile.txt");
    

Примечание: оператор using не всегда полезен, например, если класс StreamWriter не должен быть привязан к using или необходимо добавить оператор catch.

Для чего еще применяется using

С помощью оператора using можно не только красиво очищать ресурсы. Вот простой пример, в котором IDisposable применяется для создания тега </HTML>, не требуя от пользователя помнить об этом (тег будет закрыт, даже если внутри блока возникнет исключение).

        public class HTMLWriter : IDisposable

{

  public void Dispose()

  {      

    // Закрывающийся тег

  }

}
    

Оператор using, выглядит следующим образом:

        using(var HTML = new HTMLWriter())
{
}
    

Другой пример – использующий IDisposable класс и блок using для управления транзакциями базы данных:

        using (var scope = new TransactionScope)

{
  //Любые запросы к БД

} 
    

Заключение

Правильное распределение ресурсов – ключ к сохранению нормальной работы .NET-приложения.

При работе с управляемым кодом этот принцип сводится к вызову метода Dispose в любом IDisposable-классе, независимо от того, вызывается он напрямую через оператор using или нет.

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

Дополнительные материалы по теме

Источники

РУБРИКИ В СТАТЬЕ

МЕРОПРИЯТИЯ

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

ВАКАНСИИ

Программист С++ (Middle)
по итогам собеседования
Programmer Unity
Краснодар, по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ

BUG