♻ Как правильно управлять ресурсами в .NET Core
Сборщик мусора .NET отлично справляется с очисткой управляемых ресурсов, но с неуправляемыми справиться не может. Разбираемся, что тут к чему.
Перевод публикуется с сокращениями, автор оригинальной статьи Daniel Glucksman.
Даже если вы сами не используете неуправляемые ресурсы напрямую или не знаете об их существовании, многие встроенные классы .NET используют их из коробки: сетевое взаимодействие (System.Net), потоки и обработка файлов (System.IO), обработка изображений (System.Drawing), криптография. Полный список доступен на сайте.
Что произойдет, если вы неправильно распорядитесь неуправляемыми ресурсами?
Если вы не используете неуправляемый код напрямую, ресурсы будут очищены, но:
- это произойдет не сразу, и приложение будет продолжать их потреблять до тех пор, пока GC не решит провести очистку в фоновом режиме, вызвав финализатор (Finalizer);
- сборщик мусора будет влиять на производительность во время процесса очистки.
Если вы вручную распределяете неуправляемые ресурсы, очень важно избавиться от них, поскольку они никогда не будут очищены автоматически, вызовут утечку памяти и система может рухнуть.
Вы спросите, как нам правильно распоряжаться ресурсами в .NET Core? Чтобы ответить на этот вопрос, позвольте мне познакомить вас с IDisposable.
Что такое IDisposable?
IDisposable – это встроенный .NET интерфейс. Согласно документации Microsoft, он обеспечивает механизм высвобождения неуправляемых ресурсов. Интерфейс предоставляет метод Dispose, который при реализации должен очистить все соответствующие ресурсы.
В C# 8 появился асинхронный способ утилизации ресурсов с помощью IAsyncDisposable и DisposeAsync.
Если класс реализует IDisposable, обычно (интерфейс иногда используется для других целей) это признак того, что он использует неуправляемые ресурсы прямо или косвенно и должен быть соответствующим образом утилизирован.
Необходимо помнить, что IDisposable полагается на вызов метода Dispose программистом, поскольку в рантайме он сам вызываться не будет.
Правильный способ реализации IDisposable
Рекомендуемая практика заключается в реализации Dispose таким образом, чтобы независимо от количества вызовов метода очищался он только один раз. Прежде, чем пытаться освободить ресурсы, необходимо проверить, был ли объект уже удален:
Если вы планируете наследовать от класса, необходимо сделать метод Dispose виртуальным, как показано ниже:
Виртуальный метод дает наследуемому классу возможность переопределить функцию и очистить ресурсы:
Предупреждение: если вы забудете вызвать метод базового класса Dispose, ресурсы не будут полностью очищены.
Finalizers
IDisposable полагается на разработчика, чтобы правильно вызвать метод Dispose. Вы можете добавить финализатор в свой класс для обеспечения автоматической очистки ресурсов: даже если вы забудете о методе Dispose, GC все сделает сам.
Финализатор определяется с помощью символа «~», за которым следует имя класса:
Предупреждение: использование финализатора может привести к дополнительным накладным расходам, поэтому лучше самостоятельно очищать ресурсы с помощью IDisposable. Рекомендуется создавать его, только когда вы непосредственно владеете любыми неуправляемыми ресурсами.
При использовании финализатора стоит централизовать логику dispose в дополнительную функцию (мы назвали ее Cleanup), которая может быть вызвана как из финализатора, так и из метода Dispose.
Функция Dispose должна сообщить сборщику мусора, что ему не нужно вызывать финализатор, поскольку ресурсы уже были очищены – это поможет избежать дополнительных расходов на процесс очистки GC.
Управляемый код никогда не должен очищаться, если Dispose вызывается из финализатора, потому что он мог уже быть очищен GC.
Если вы будете наследовать от приведенного выше класса, то можно пометить функцию Cleanup как виртуальную:
Унаследованный класс переопределит ее следующим образом:
Использование IDisposable
Правило 1: утилизируйте классы, реализующие IDisposable
Всякий раз, когда используете реализующий интерфейс IDisposable класс, вызывайте метод Dispose после завершения работы с ним.
Возьмем, к примеру, класс StreamWriter. Инициализируем объект, запишем одну строку текста в файл, а затем очистим все.
Если во время записи в файл возникнет исключение, то StreamWriter не будет удален должным образом.
Правильный способ это исправить – обернуть все в блок try/finally, чтобы гарантировать вызов Dispose даже если возникнет исключение.
Правило 2: Если ваш класс владеет объектом IDisposable, реализуйте IDisposable
Если ваш класс имеет переменную-член, поле или свойство, реализующее IDisposable, вы должны предусмотреть возможность избавиться от него.
Например, класс Logger обертывает встроенный класс StreamWriter, чтобы включить запись в файл журнала.
Поскольку класс StreamWriter реализует IDisposable, нужно реализовать IDisposable в нашем классе Logger для очистки ресурсов StreamWriter.
Пользователь класса Logger может распорядиться ресурсами следующим образом:
Правило 3: при непосредственном использовании неуправляемых ресурсов реализуйте IDisposable и финализатор
Если класс задействует какие-либо неуправляемые ресурсы напрямую, обязательно реализуйте IDisposable. GC может автоматически очищать управляемые ресурсы, но не знает, когда вы закончите работу с неуправляемыми.
Правило 4: избегайте необработанных исключений
Никогда не создавайте необработанные исключения в методе Dispose. Поскольку Dispose будет действовать в блоке finally, любые необработанные исключения всплывут в приложении. Если Dispose вызывается из Finalizer, нормальная работа приложения не гарантируется.
Оператор «using»
Оператор using – это конструкция, которая автоматически вызывает метод Dispose при выходе из своей области видимости, даже если внутри нее возникает исключение. Возьмем предыдущий код:
После реализации блока using пример будет выглядеть следующим образом:
Начиная с C# 8, блок using не нуждается в фигурных или обычных скобках, поэтому код можно упростить:
Вы можете инициализировать несколько переменных одного типа в блоке using, если не используется ключевое слово var:
Примечание: оператор using не всегда полезен, например, если класс StreamWriter не должен быть привязан к using или необходимо добавить оператор catch.
Для чего еще применяется using
С помощью оператора using можно
не только красиво очищать ресурсы. Вот простой пример, в котором IDisposable применяется для создания тега </HTML>
, не требуя от пользователя помнить
об этом (тег будет закрыт, даже если внутри блока возникнет исключение).
Оператор using, выглядит следующим образом:
Другой пример – использующий IDisposable класс и блок using для управления транзакциями базы данных:
Заключение
Правильное распределение ресурсов – ключ к сохранению нормальной работы .NET-приложения.
При работе с управляемым кодом этот принцип сводится к вызову метода Dispose в любом IDisposable-классе, независимо от того, вызывается он напрямую через оператор using или нет.
Работающим с неуправляемым кодом необходимо позаботиться не только о правильном способе его удаления, но и проследить за программистом, когда тот забудет о необходимости его утилизировать.
Дополнительные материалы по теме
- Язык C# и .NET: путь продолжающего в 2019 году
- 10 интересных вещей о платформе DotNet Core
- Лучший видеокурс по C# и .NET
- .NET: что почитать, посмотреть и послушать