[DotNetBook] IDisposable: самое полное описание

в 15:06, , рубрики: .net, C#, clr, DotNetBook, idisposable

С этой статьей я начинаю публиковать целую серию статей, результатом которой будет книга по работе .NET CLR, и .NET в целом. Тема IDisposable была выбрана в качестве разгона, пробы пера. Вся книга будет доступна на GitHub: DotNetBook. Так что Issues и Pull Requests приветствуются :)

Disposing (Disposable Design Principle)

Сейчас, наверное, практически любой программист, который разрабатывает на платформе .NET скажет что ничего проще этого паттерна нет. Что это известный из известнейших шаблонов, которые применяются на платформе. Однако даже в самой простой и известнейшей проблемной области всегда найдется второе дно, а за ним еще ряд скрытых кармашков, в которые вы никогда не заглядывали. Однако, как для тех кто смотрит тему впервые, так и для всех прочих (просто для того чтобы каждый из вас вспомнил основы (не пропускайте эти абзацы (я слежу!))) — опишем все от самого начала и до самого конца.

IDisposable

Если спросить, что такое IDisposable, вы наверняка ответите что это.

public interface IDisposable 
{
    void Dispose();
} 

Для чего же создан интерфейс? Ведь если у нас есть умный Garbage Collector, который за нас чистит всю память, делает так чтобы мы вообще не задумывались о том, как чистить память, то становится не совсем понятно зачем ее вообще чистить. Однако есть нюансы. Существует некоторое заблуждение что IDisposable сделан для того чтобы освобождать неуправляемые ресурсы. И это только часть правды. Чтобы единомоментно понять что это не так, достаточно вспомнить примеры неуправляемых ресурсов. Является ли неуправляемым класс File? Нет. Может быть DbContext? Опять же — нет. Неуправляемый ресурс — это то, что не входит в систему типов .NET. То, что не было создано платформой и находящееся вне ее скоупа. Простой пример — это дескриптор открытого файла в операционной системе. Дескриптор — это некоторое число, которое однозначно идентифицирует открытый операционной системой файл. Не вами, а именно операционной системой. Т.е. все управляющие структуры (такие как координаты файла на файловой системе, его фрагменты в случае фрагментации и прочая служебная информация, номера циллиндра, головки, сектора — в случае магнитного HDD) находятся не внутри платформы .NET а внутри ОС. И единственный неуправляемый ресурс, который уходит в платформу .NET является IntPtr число. Это число в свою очередь оборачивается FileSafeHandle, который в свою очередь оборачивается классом File. Т.е. класс File сам по себе неуправляемым ресурсом не является но аккумулирует в себе через дополнительную прослойку неуправляемый ресурс — дескриптор открытого файла — IntPtr. Как происходит чтение из такого файла? Через ряд методов WinAPI или ОС Linux.

Вторым примером неуправляемых ресурсов являются примитивы синхронизации в многопоточных и мультипроцессных программах. Такие как мьютексы, семафоры. Или же массивы данных, которые передаются через p/invoke.

Хорошо. С неуправляемыми ресурсами разобрались. Зачем же IDisposable в этих случаях? Затем что .NET Framework понятия не имеет о том, что происходит там, где его нет. Если вы открываете файл при помощи функций ОС, .NET ничего об этом не узнает. Если вы выделите участок памяти под собственные нужды (например, при помощи VirtualAlloc), .NET также ничего об этом не узнает. А если он ничего об этом не знает, он не освободит память, которая была занята вызовом VirtualAlloc. Или не закроет файл, открытый напрямую через вызов API ОС. Последствия этого могут быть совершенно разными и непредсказуемыми. Вы можете получть OutOfMemory если навыделяете слишком много памяти и не будете ее освобождать (а, например, по старой памяти будете просто обнулять указатель) либо заблокируете на долгое время файл на файловой шаре если он был открыт через средства ОС но не был закрыт. Пример с файловыми шарами особенно хорош потому что блокировка останется даже после закрытия приложения: открытость файла регулирет та сторона, на которой он находится. А удаленная сторона не получит сигнала закрытия файла если вы его не закрыли самостоятельно.

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

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

Первый вариант — это когда вы оборачиваете экземпляр в using(...){ ... }. Т.е. вы прямо указываете что по окончании блока using объект должен быть уничтожен. Т.е. должен быть вызван Dispose(). Второй вариант — уничтожить его по окончании времени жизни объекта, который содержит ссылку на тот, который надо освободить. Но ведь в .NET кроме метода финализации нет ничего что намекало бы на автоматическое уничтожение объекта. Правильно? Но финализация нам совсем не подходит по той причине что она будет неизвестно когда вызвана. А нам надо освободать именно тогда, когда необходимо: сразу после того как нам более не нужен, например, открытый файл. Именно поэтому мы также должны реализовать IDisposable у себя и в методе Dispose вызвать Dispose у всех, кем мы владели чтобы освободить и их тоже. Таким образом мы соблюдаем протокол и это очень важно. Ведь если кто-то начал соблюдать некий протокол, его должны соблюдать все участники процесса: иначе будут проблемы.

Вариации реализации IDisposable

Давайте пойдем в реализациях IDisposable от простого к сложному.

Первая и самая простая реализация которая только может прийти в голову — это просто вязть и реализовать IDisposable:

public class ResourceHolder : IDisposable
{
    DisposableResource _anotherResource = new DisposableResource();

    public void Dispose()
    {
        _anotherResource.Dispose();
    }
}

Т.е. для начала мы создаем экземпляр некоторого ресурса, который должен быть освобожден и в методе Dispose() — освобождается.
Единственное чего здесь нет и что делает реализацию не консистентной, это возможность дальнейшей работы с экземпляром класса после его разрушения методом Dispose():

public class ResourceHolder : IDisposable
{
    private DisposableResource _anotherResource = new DisposableResource();
    private bool _disposed;

    public void Dispose()
    {
        CheckDisposed();
        _anotherResource.Dispose();
    }

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private void CheckDisposed() 
    {
        if(_disposed) {
            throw new ObjectDisposedException();
        }
    }
}

Вызов CheckDisposed() необходимо вызывать первым выражением во всех публичных методах класса. Однако, если для разрушения управляемого ресурса, коим является DisposableResource полученная структура класса ResourceHolder выглядит нормально, то для случай инкапсулирования неуправляемого ресурса — нет.

Давайте придумаем вариант с неуправляемым ресурсом.

public class FileWrapper : IDisposable
{
    IntPtr _handle;

    public FileWrapper(string name)
    {
        _handle = CreateFile(name, 0, 0, 0, 0, 0, IntPtr.Zero);
    }

    public void Dispose()
    {
        CloseHandle(_handle);
    }

    [DllImport("kernel32.dll", EntryPoint = "CreateFile", SetLastError = true)]
    private static extern IntPtr CreateFile(String lpFileName,
        UInt32 dwDesiredAccess, UInt32 dwShareMode,
        IntPtr lpSecurityAttributes, UInt32 dwCreationDisposition,
        UInt32 dwFlagsAndAttributes,
        IntPtr hTemplateFile);

    [DllImport("kernel32.dll", SetLastError=true)]
    private static extern bool CloseHandle(IntPtr hObject);
}

Так какая разница в поведении двух последних примеров? В первом варианте у нас описано взаимодействие управляемого ресурса с другим управляемым. Это означает что в случае корректной работы программы ресурс будет освобожден в любом случае. Ведь DisposableResource у нас — управляемый, а значит .NET CLR о нем прекрасно знает и в случае некорректного поведения — освободит из под него память. Заметьте, что я намеренно не делаю никаких предположений о том, что тип DisposableResource инкапсулирует. Там может быть какая угодно логика и структура. Она может содержать как управляемые так и неуправляемые ресурсы. Нас это волновать не должно. Нас же не просят каждый раз декомпилировать чужие библиотеки и смотреть какие типы что используют: управляемые или неуправляемые ресурсы. А если наш тип использует неуправляемый ресурс, мы не можем этого не знать. Это мы делаем в классе FileWrapper. Так что же произойдет в этом случае?

Если мы используем неуправляемые ресурсы, получается что у нас опять же два варианта: когда все хорошо и метод Dispose вызвался (тогда все хорошо :) ) и когда что-то случилось и метод Dispose отработать не смог. Сразу оговоримся, почему этого может не произойти:

  • Если мы используем using(obj) { ... }, то во внутреннем блоке кода может возникнуть исключение, которое перехватывается блоком finally, который нам не видно (это синтаксический сахар C#). В этом блоке неявно вызываетcя Dispose. Однако есть случаи, когда этого не происходит. Например, ThreadAbortException, который не перехватывается ни catch ни finally. Это всегда надо учитывать. Ведь если у вас некий поток уйдет в подвисшее состояние и по книпке с GUI либо по HeartBeat таймеру вы его убьете, то те ресурсы, которые были захвачены и не были освобождены забудутся .NET'ом. Ведь он понятия не имеет как освобождать неуправляемые ресурсы: они повиснут в памяти до тех пор пока ОС не освободит их сама (например, при выходе из вашей программы. А иногда и неопределенное время уже после завершения работы приложения).
  • Если мы вызываем Dispose() из другого Dispose(). Тогда может так получиться что опять же мы не сможем до него дойти. И тут вопрос вовсе не в забывчивости автора приложения: мол, забыл Dispose() вызвать. Нет. Опять же, вопрос в любых исключениях. Но теперь речь идет не только об исключениях, обрушающих поток приложения. Тут уже речь идет вообще о любых исключениях, которые приведут к тому что алгоритм не дойдет до вызова внешнего Dispose(), который вызовет наш.

Во всех таких случаях возникнет ситуация подвешенных в воздухе неуправляемых ресурсов. Ведь Garbage Collector понятия не имеет, что их надобно собрать. Максимум что он сделает — при очередном проходе поймет, что на граф объектов, содержащих наш объект типа FileWrapper потеряна последняя ссылка и память перетрется теми объектами, на которые ссылки есть.

Как же защититься от подобного? Для этих случаев мы обязаны реализовать финализатор объекта. Финализатор не случайно имеет именно такое название. Это вовсе не деструктор, как может показаться изначально из-за схожести объявления финализаторов в C# и деструкторов — в C++. Финализатор в отличии от деструктора вызовется гарантированно тогда как деструктор может и не вызваться (ровно как и Dispose()). Финализатор вызывается когда запускается Garbage Collection (пока этого знания достаточно, но по факту все несколько сложнее) и предназначен для гарантированного освобождения захваченных ресурсов если что-то пошло не так. И для случая освобождения неуправляемых ресурсов мы обязаны реализовывать финализатор. Также, повторюсь, из-за того что финализатор вызывается при запуске GC, в общем случае вы понятия не имеете, когда это произойдет.

Давайте расширим наш код:

public class FileWrapper : IDisposable
{
    IntPtr _handle;

    public FileWrapper(string name)
    {
        _handle = CreateFile(name, 0, 0, 0, 0, 0, IntPtr.Zero);
    }

    public void Dispose()
    {
        InternalDispose();
        GC.SuppressFinalize(this);
    }

    private void InternalDispose()
    {
        CloseHandle(_handle);
    }

    ~FileWrapper()
    {
        InternalDispose();
    }

    /// other methods
}

Мы усилили пример знаниями о процессе финализации и тем самым обезопасили приложение от потери информации о ресурсах если что-то пошло не так и Dispose() вызван не будет. Дополнительно, мы сделали вызов GC.SuppressFinalize для того чтобы отключить финализацию экземпляра типа если для него был вызван Dispose(). Нам же не надо дважды освобождать один и тот же ресурс? Также это стоит сделать по другой причине: мы снимаем нагрузку с очереди на финализацию, ускоряя случайный участок кода, в параллели с которым будет в случайном будущем отрабатывать финализация.

Теперь давайте еще усилим наш пример:

public class FileWrapper : IDisposable
{
    IntPtr _handle;
    bool _disposed;

    public FileWrapper(string name)
    {
        _handle = CreateFile(name, 0, 0, 0, 0, 0, IntPtr.Zero);
    }

    public void Dispose()
    {
        CheckDisposed();
        _disposed = true;

        InternalDispose();
        GC.SuppressFinalize(this);
    }

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private void CheckDisposed() 
    {
        if(_disposed) {
            throw new ObjectDisposedException();
        }
    }

    private void InternalDispose()
    {
        CloseHandle(_handle);
    }

    ~FileWrapper()
    {
        InternalDispose();
    }

    /// other methods
}

Теперь наш пример реализации типа, инкапсулирующего неуправляемый ресурс выглядит законченным. Повторный Dispose() ошибочен и мы не даем его вызвать. Замечу что зачастую люди допускают повторный вызов Dispose() для того чтобы избежаь мороки с вызывающим кодом. Однако это ломает целостность объекта. Если мы разрушили объект, значит с ним работать более нельзя. Это в свою очередь означает что мы обязаны вставлять вызов CheckDispose в начало каждого публичного метода.

Однако в этом коде существует очень серъезная проблема, которая не даст ему работать так как задумали мы. Если мы повспонинаем, как отрабатывает процесс сборки мусора, то заметим одну деталь. При сборке мусора GC в первую очередь финализирует все, что напрямую унаследовано от Object, после чего принимается за те объекты, которые реализуют CriticalFinalizerObject. У нас же получается что оба класса, которые мы спроектировали наследуют Object: и это проблема. Мы понятия не имеем, в каком порядке мы уйдем на "последнюю милю". Тем не менее, более высокоуровневый объект может пытаться работать с объектом, который хранит неуправляемый ресурс — в своем финализаторе (хотя это уже звучит как плохая идея). Тут нам бы сильно пригодился порядок финализации. И для того чтобы его задать — мы должны унаследовать наш тип, инкапсулирующий unmanaged ресурс от CriticalFinalizerObject.

Вторая причина имеет более глубокие корни. Представьте себе что вы позволили себе написать приложение, которое не сильно заботится о памяти. Аллоцирует в огромных количествах без кэширования и прочих премудростей. Однажды такое приложение завалится с OutOfMemoryException. А когда приложение падает с этим исключением, возникают особые условия исполнения кода: ему нельзя что-либо пытаться аллоцировать. Ведь это приведет к повторному исключению, даже если предыдущее было поймано. Это вовсе не обозначает что мы не должны создавать новые экземпляры объектов. К этому исключению может привести обычный вызов метода. Например, вызов метода финаизации. Напомню, что метды компилируются тогда, когда они вызываются в первый раз. И это обычное поведение. Как же уберечься от этой проблемы? Достаточно легко. Если вы отнаследуете объект от CriticalFinalizerObject, то все методы этого типа будут компилироваться сразу же, при загрузке типа в память. Мало того, если вы пометите методы атрибутом [PrePrepareMethod], то они также будут предварительно скомпилированны и будут безопасными с точки зрения вызова при нехватке ресурсов.

Почему это так важно? Зачем тратить так много усилий на тех, кто уйдет в мир иной? А все дело в том что неуправляемые ресурсы могут повиснуть в системе очень надолго. Даже после того как ваше приложение завершит работу. Даже после перезагрузки компьютера (если пользователь открыл в вашем приложении файл с файловой шары, тот будет заблокирован удаленным хостом и отпущен либо по таймауту либо когда вы освободите ресурс, закрыв файл. Если ваше приложение вылетит в момент открытого файла, то он не будет закрыт даже после перезагрузки. Придется ждать достаточно продолжительное время для того чтобы удаленный хост отпустил бы его). Плюс ко всему вам нельзя допускать выброса исключений в финализаторах — это приведет к ускоренной гибели CLR и окончательному выбросу из приложения: вызовы финализаторов не оборачиваются try… catch. Т.е. освобождая ресурс вам надо быть уверенными в том что он еще может быть освобожден. И последний не менее интересный факт — если CLR осуществляет аварийную выгрузку домена, финализаторы типов, производных от CriticalFinalizerObject также будут вызваны в отличии от тех, кто наследовался напрямую от Object.

SafeHandle / CriticalHandle / SafeBuffer / производные

У меня есть некоторое ощущение что я для вас сейчас открою ящик Пандоры. Давайте поговорим про специальные типы: SafeHandle, CriticalHandle и их производные. И закончим уже, наконец, наш шаблон типа, предоставляющего доступ к unmanaged ресурсу. Но перед этим давайте попробуем перечислить все что к нам обычно идет из unmanaged мира:

  • Первое и самое ожидаемое, что оттуда обычно идет — это дескрипторы (handles). Для разработчика .NET это может быть абсолютно пустым словом, но это очень важная составляющая мира операционных систем. Но по своей сути handle — это 32-х либо 64-х разрядное число, определяющее открытую сессию взаимодействия с операционной системой. Т.е., например, открываете вы файл чтобы с ним поработать, а в ответ от WinApi функции получили дескриптор. После чего, используя его, можете продолжать работать именно с ним: делаете Seek, Read, Write операции. Второй пример: открываете сокет для работы с сетью. И опять же: операционная система отдаст вам дескриптор. В мире .NET дескрипторы хранятся в типе IntPtr;
  • Второе — это массивы данных. Существует несколько путей работы с неуправляемыми массивами: либо работать с ним через unsafe код (ключевое слово unsafe), либо использовать SafeBuffer, который обернет буфер данных удобным .NET классом. Замечу что хоть первый способ быстрее (вы можете сильно оптимизировать циклы, например), то второй способ — намного безопаснее. Ведь он использует SafeHandle как основу для работы;
  • Строки. Со строками все несколько проще потому что наша задача — определить формат и кодировку строки, которую мы забираем. Далее строка копируется к нам (класс string — immutable) и мы дальше ни о че не думаем.
  • ValueTypes, которые забираются копированием и о их судьбе думать вообще нет никакой необходимости.

SafeHandle — это специальный класс .NET CLR, который наследует CriticalFinalizerObject и который призван обернуть дескрипторы операционной системы максимально безопасно и удобно.


[SecurityCritical, SecurityPermission(SecurityAction.InheritanceDemand, UnmanagedCode=true)]
public abstract class SafeHandle : CriticalFinalizerObject, IDisposable
{
    protected IntPtr handle;        // Дескриптор, пришедший от ОС
    private int _state;             // Состояние (валидность, счетчик ссылок)
    private bool _ownsHandle;       // Флаг возможности освободить handle. Может так получиться что сы оборачиваем чужой handle и освобождать его не имеем права
    private bool _fullyInitialized; // Экземпляр проинициализирован

    [ReliabilityContract(Consistency.WillNotCorruptState, Cer.MayFail)]
    protected SafeHandle(IntPtr invalidHandleValue, bool ownsHandle)
    {
    }

    // Финализатор по шаблону вызывает Dispose(false)
    [SecuritySafeCritical]
    ~SafeHandle()
    {
        Dispose(false);
    }

    // Выставление hanlde может идти как вручную, так и при помощи p/invoke Marshal - автоматически
    [ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
    protected void SetHandle(IntPtr handle)
    {
        this.handle = handle;
    }

    // Метод необходим для того чтобы с IntPtr можно было бы работать напрямую. Используется 
    // для определения того, удалось ли создать дескриптор, сравнив его с одим из ранее
    // определенных известных значений. Обратите внимание, что метод опасен по двум причинам:
    //  - Если дескриптор отмечен как недопустимый с помощью SetHandleasInvalid, DangerousGetHandle 
    //    то все равно вернет исходное значение дескриптора.
    //  - Возвращенный дескриптор может быть переиспользован в любом месте. Это может как минимум 
    //    означать что он без обратной связи перестанет работать. В худшем случае при прямой передаче 
    //    IntPtr в другое место, он может уйти в ненадежный код и стать вектором атаки на приложение 
    //    через подмену ресурса на одном IntPtr
    [ResourceExposure(ResourceScope.None), ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
    public IntPtr DangerousGetHandle()
    {
        return handle;
    }

    // Ресурс закрыт (более не доступен для работы)
    public bool IsClosed {
        [ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
        get { return (_state & 1) == 1; }
    }

    // Ресурс не является доступным для работы. Вы можете переопределить свойство, изменив логику.
    public abstract bool IsInvalid {
        [ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
        get;
    }

    // Закрытие ресурса через шаблон Close()
    [SecurityCritical, ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
    public void Close() {
        Dispose(true);
    }

    // Закрытие ресурса через шаблон Dispose()
    [SecuritySafeCritical, ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
    public void Dispose() {
        Dispose(true);
    }

    [SecurityCritical, ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
    protected virtual void Dispose(bool disposing)
    {
        // ... 
    }

    // Вы должны вызывать этот метод всякий раз когда понимаете что handle более не является рабочим.
    // Если вы этого не сделаете, можете получить утечку
    [SecurityCritical, ResourceExposure(ResourceScope.None)]
    [ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
    [MethodImplAttribute(MethodImplOptions.InternalCall)]
    public extern void SetHandleAsInvalid();

    // Переопределите данный метод чтобы указать каким образом необходимо освобождать 
    // ресурс. Необходимо быть крайне осторожным при написании кода, т.к. из него 
    // нельзя вызывать нескомпилированные методы, создавать новые объекты и бросать исключения. 
    // Возвращаемое значение - маркер успешности операции освобождения ресурсов. 
    // Причем если возвращаемое значение = false, будет брошено исключение 
    // SafeHandleCriticalFailure, которое в случае включенного SafeHandleCriticalFailure
    // Managed Debugger Assistant войдет в точку останова.
    [ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
    protected abstract bool ReleaseHandle();

    // Работа со счетчиком ссылок. Будет объяснено далее по тексту
    [SecurityCritical, ResourceExposure(ResourceScope.None)]
    [ReliabilityContract(Consistency.WillNotCorruptState, Cer.MayFail)]
    [MethodImplAttribute(MethodImplOptions.InternalCall)]
    public extern void DangerousAddRef(ref bool success);
    public extern void DangerousRelease();
}

Чтобы оценить полезность группы классов, производных от SafeHandle, достаточно вспомнить чем хороши все .NET типы: автоматизированностью уборки мусора. Т.о. оборачивая неуправляемый ресурс, SafeHandle наделяет его такими же свойствами, т.к. является управляемым. Плюс ко всему он содержит внутренний счетчик внешних ссылок, которые не могут быть учтены CLR. Т.е. ссылками из unsafe кода. Вручную увеличивать и уменьшать счетчик нет почти никакой необходимости: когда вы объявляете любой тип, производный от SafeHandle как параметр unsafe метода, то при входе в метод счетчик будет увеличен, а при выходе — уменьшен. Это свойство введено по той причине что когда вы перешли в unsafe код, передав туда дескриптор, то в другом потоке (если вы, конечно, работаете с одним дескриптором из нескольких потоков) обнулив ссылку на него, получите собранный SafeHandle. Со счетчиком же ссылок все проще: SafeHandle не будет собран пока дополнительно не обнулится счетчик. Вот почему вручную менять счетчик не стоит. Либо это надо делать очень аккуратно: возвращая его как только это становится возможным.

Второе назначение счетчика ссылок — это задание порядка финализации CriticalFinalizerObject, которые друг на друга ссылаются. Если один SafeHandle-based тип ссылается на другой SafeHandle-based тип, то в конструкторе ссылающегося необходимо дополнительно увеличить счетчик ссылок, а в методе ReleaseHandle — уменьшить. Таким образом ваш объект не будет уничтожен пока не будет уничтожен тот, на который вы сослались. Однако чтобы не путаться, стоит избегать таких ситуаций.

Давайте напишем финальный вариант нашего класса, но теперь уже с последними знаниями о SafeHandlers:

public class FileWrapper : IDisposable
{
    SafeFileHandle _handle;
    bool _disposed;

    public FileWrapper(string name)
    {
        _handle = CreateFile(name, 0, 0, 0, 0, 0, IntPtr.Zero);
    }

    public void Dispose()
    {
        CheckDisposed();
        _disposed = true;
        _handle.Dispose();
    }

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private void CheckDisposed() 
    {
        if(_disposed) {
            throw new ObjectDisposedException();
        }
    }

    [DllImport("kernel32.dll", EntryPoint = "CreateFile", SetLastError = true)]
    private static extern SafeFileHandle CreateFile(String lpFileName,
        UInt32 dwDesiredAccess, UInt32 dwShareMode,
        IntPtr lpSecurityAttributes, UInt32 dwCreationDisposition,
        UInt32 dwFlagsAndAttributes,
        IntPtr hTemplateFile);

    /// other methods
}

Что его отличает? Зная, что если в DllImport методе в качестве возвращаемого значения установить любой SafeHandle-based тип, то Marshal его корректно создаст и проинициализирует, установив счетчик использований в 1, мы ставим тим SafeFileHandle в качестве возвращаемого для функции ядра CreateFile. Получив его, мы будем при вызове ReadFile и WriteFile использовать именно его (т.к. при вызове счетчик опять же увеличится, а при выходе — уменьшится, что даст нам гарантию существования handler на все время чтения и записи в файл). Тип этот спроектирован корректно, а это значит что он гарантированно закроет файловый дескриптор. Даже когда процесс аварийно завершит свою работу. А это значит что нам не надо реализовывать свой finalizer и все что с ним связано. Наш тип значительно упрощается.

Многопоточность

Теперь поговорим про тонкий лед. В предыдущих частях рассказа об IDisposable мы проговорили одну очень важную концепцию, которая лежит не только в основе проектирования Disposable типов, но и в проектировании любого типа: концепция целостности объекта. Это значит что в любой момент времени объект находится в строго определенном состоянии и любое действие над ним переводит его состояние в одно из заранее определенных — при проектировании типа этого объекта. Другими словами — никакое действие над объектом не должно иметь возможность перевести его состояние в то, которое не было определено. Из этого вытекает проблема в спроектированных ранее типах: они не потокобезопаны. Есть потенциальная возможность вызова публичных методов этих типов в то время как идет разрушение объекта. Давайте решим эту проблему и решим стоит ли вообще ее решать

public class FileWrapper : IDisposable
{
    IntPtr _handle;
    bool _disposed;
    object _disposingSync = new object();

    public FileWrapper(string name)
    {
        _handle = CreateFile(name, 0, 0, 0, 0, 0, IntPtr.Zero);
    }

    public void Seek(int position)
    {
        lock(_disposingSync)
        {
            CheckDisposed();
            // Seek API call
        }
    }

    public void Dispose()
    {
        lock(_disposingSync)
        {
            CheckDisposed();
            _disposed = true;
        }
        InternalDispose();
        GC.SuppressFinalize(this);
    }

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private void CheckDisposed() 
    {
        lock(_disposingSync)
        {
            if(_disposed) {
                throw new ObjectDisposedException();
            }
        }
    }

    private void InternalDispose()
    {
        CloseHandle(_handle);
    }

    ~FileWrapper()
    {
        InternalDispose();
    }

    /// other methods
}

Установка критической секции на код проверки _disposed в Dispose() и по факту — установка критической секции на весь код публичных методов. Это решит нашу проблему одновременного входа в публичный метод экемпляра типа и в метод его разрушения, однако создаст таймер замедленного действия для ряда других проблем:

  • Интенсивная работа с методами экземпляра типа а также работа по созданию и разрушению объектов приведет к сильному проседанию по производительности. Все дело в том что взятие блокировки занимает некоторое время. Это время необходимо для аллокации таблиц SyncBlockIndex проверок на текущий поток и много чего еще (мы рассмотрим все это отдельно — в разделе про многопоточность). Т.е. получается что ради "последней мили" жизни объекта мы будем платить производительностью все время его жизни!
  • Дополнительный memory traffic для объектов синхронизации
  • Дополнительные шаги для обхода графа объектов при GC

Второе, и на мой взгляд, самое важное. Мы допускаем ситуацию одновременного разрушения объекта с возможностью поработать с ним еще разок. На что мы вообще должны надеяться в данном случае? Что не выстрелит? Ведь если сначала отработает Dispose, то дальнейшее обращение с методам объекта обязано привести к ObjectDisposedException. Отсюда возникает простой вывод: синхронизацию между вызовами Dispose() и остальными публичными методами типа необходимо делигировать обслуживающей стороне. Т.е. тому коду, который создал экземпляр класса FileWrapper. Ведь только создающая сторона в курсе что она собирается делать с экземпларом класса и когда она собирается его разрушать.

Два уровня Disposable Design Principle

Какой самый популярный шаблон реализации IDisposable можно встретить в книгах по .NET разработке и во Всемирной Паутине? Какой шаблон ждут от вас люди в компаниях, когда вы идете собеседоваться на потенциально новое место работы? Вероятнее всего этот:

public class Disposable : IDisposable
{
    bool _disposed;

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        CheckDisposed();
        if(disposing)
        {
            // освобождаем управляемые ресурсы
        }
        // освобождаем неуправляемые ресурсы
    }

    protected void CheckDisposed()
    {
        if(_disposed) 
        {
            throw new ObjectDisposedException();
        }
    }

    ~Disposable()
    {
        Dispose(false);
    }
}

Что здесь не так и почему мы ранее в этой книге никогда так не писали? На самом деле шаблон хороший и без лишних слов охватывает все жизненные ситуации. Но его использование повсеместно как на мой взгляд не является правилом хорошего тона: ведь реальных неуправляемых ресурсов мы в практике почти никогда не видим и в этом случае пол-шаблона работает в холостую. Мало того он нарушает принцип разделения ответственности. Ведь он одновременно управляет и управляемыми ресурсами и неуправляемыми. На мой скромный взгляд это совершенно не правильно. Давайте взглянем на несколько иной подход. Disposable Design Principle. Если коротко, то суть в следующем:

Disposing разделяется на два уровня классов:

  • Типы Level 0 напрямую инкапсулируют неуправляемые ресурсы
    • Они являются либо абстрактными либо запакованными
    • Все методы должны быть помечены:
      • PrePrepareMethod чтобы метод был скомпилирован вместе с загрузкой типа
      • SecuritySafeCritical чтобы выставить защиту на вызов из кода, работающего под ограничениями
      • ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success / MayFail)] чтобы выставить CER на метод и все его дочерние вызовы
    • Могут ссылаться на типы нулевого уровня но должны увеличивать счетчик ссылающихся объектов чтобы гарантировать порядок выхода на "последнюю милю"
  • Типы Level 1 инкапсулируют только управляемые ресурсы
    • Наследуются только от типов Level 1 либо реализуют IDisposable напрямую
    • Не имеют права наследовать типы Level 0 либо CriticalFinalizerObject
    • Могут инкапсулировать управляемые типы Level 1 либо Level 0
    • Реализуют IDisposable.Dispose путем разрушения инкапсулированных объектов в порядке: сначала типы Level 0, потом — типы Level 1
    • Т.к. они не имеют неуправляемых ресурсов — то не реализуют finalizer
    • Должно содержать protected свойство, дающее доступ к Level 0 типам.

Именно поэтому я с самого начала ввел разделение на два типа: на содержащий управляемый ресурс и содержащий неуправляемый ресурс. Они должны работать совершенно по-разному.

Итоги

Плюсы

Итак, мы узнали много нового про этот простейший шаблон. Давайте определим его плюсы:

  1. Основным плюсом шаблона является возможность детерменированного освобождения ресурсов: тогда, когда это необходимо
  2. Введение общеизвестного способа узнать что конкретный тип требует разрушения его экземпляров в конце использования
  3. При грамотной реализации шаблона работа спроектированного типа станет безопасной с точки зрения использования сторонними компонентами, а также с точки зрения выгрузки и разрушения ресурсов при обрушении процесса (например, из-за нехватки памяти)

Минусы

Минусов шаблона я вижу намного больше чем плюсов:

  1. С одной стороны получается, что любой тип, реализующий этот шаблон отдает тем самым команду всем кто его будет использовать: используя меня, вы принимаете публичную оферту. Причем так неявно это сообщает что как и в случае публичных оферт пользователь типа не всегда в курсе что у типа есть этот интерфейс. Приходится, например, следовать подсказкам IDE (ставить точку, набирать Dis… и проверять, есть ли метод в отфильтрованном списке членов класса). И если Dispose замечен, реализовывать шаблон у себя. Иногда это может случиться не сразу и тогда релизацию шаблона придется протягивать через систему типов, которая участвует в функционале. Хороший пример: а вы знали что IEnumerator<T> тянет за собой IDisposable?
  2. Зачастую, когда проектируется некий интерфейс, встает необходимость вставки IDisposable в систему интерфейсов типа: когда один из интерфейсов вынужден наследовать IDisposable. На мой взгляд это вносит "кривь" в те интерфейсы, которые мы спроектировали. Ведь когда проектируется интерфейс, вы прежде всего проектируете некий протокол взаимодействия. Тот набор действий, которые можно сделать с чем-либо, скрывающимся под интерфейсом. Метод Dispose() — метод разрушения экземпляра класса. Это входит в разрез с сущностью протокол взаимодействия. Это по сути — подробности реализации, которые просочились в интерфейс;
  3. Несмотря на детерменированность Dispose() не означает прямого разрушения объекта. Объект все еще будет существовать после его разрушения. Просто в другом состоянии. И чтобы это стало правдой, вы обязаны вызывать CheckDisposed() в начале каждого публичного метода. Это выглядит как хороший такой костыль, который отдали нам со словами: "плодите и размножайте!";
  4. Есть еще маловероятная возможность получить тип, который реализует IDisposable через explicit реализацию. Или получить тип, реализующий IDisposable без возможности определить, кто его должен разрушать. Сторона, которая выдала или вы сами;
  5. Полная реализация сложна. Причем различна для управляемых и неуправляемых ресурсов. В этом плане попытка облегчить жизнь разработчикам через GC выглядит немного нелепо. Можно, конечно, вводить некий тип DisposableObject, который реализует весь шаблон, отдав virtual void Dispose() метод для переопределения, но это не решит других проблем, связанных с шаблоном;
  6. Реализация метода Dispose() как правило идет в конце файла, тогда как сtor объявляется в начале. При модификации класса и вводе новых ресурсов можно легко ошибиться и забыть зарегистрировать disposing для них.
  7. Наконец, использование шаблона на графах объектов, которые полностью либо частично его реализуют — та еще морока в определении порядка разрушения в многопоточной среде. Я прежде всего имею ввиду ситуации, когда Dispose() может начаться с разных концов графа. И в таких ситуациях лучше всего воспользоваться другими шаблонами. Например, шаблоном Lifetime.

Общие итоги

  1. IDisposable является стандартом платформы и в зависимости от качества его реализации зависит качество всего приложения. Мало того, от этого в некоторых ситуациях зависит безопасность вашего приложения, которое может быть подвергнуто атакам через неуправляемые ресурсы;
  2. Реализация IDisposable должна быть максимально производительной. Особенно это касается секции финализации, которая работает в параллели со всем остальным кодом, нагружая Garbage Collector;
  3. При реализации IDisposable следует избегать идей синхронизации вызова Dispose() с публичными методами класса. Разрушение не может идти одновременно с использованием: это надо учитывать при проектировании типа, который будет использовать IDisposable объект
  4. Реализация оберток над неуправляемыми ресурсами должна идти отдельно от остальных типов. Т.е. если вы оборачиваете неуправляемый ресурс, на это должен быть выделен отдельный тип: с финлизацией, унаследованный от SafeHandle / CriticalHandle / CriticalFinalizerObject. Это разделение ответственности приведет к улучшенной поддержке системы типов и упрощению проектирования системы разрушения экземпляров типов через Dispose(): использующим типам не надо реализовывать финализатор.
  5. В целом шаблон не является удобным как в использовании так и в поддержке кода. Возможно, следует перейти на Inversion of Control процесса разрушения состояния объектов через шаблон Lifetime, речь о котором пойдет в следующей части.

GitHub: .NET Book

Автор: sidristij

Источник

Поделиться

* - обязательные к заполнению поля