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


UPD: статья доработана и многие комментарии уже не актуальны. Следить за исправлениями можно тут: GitHub: Disposed Pattern: 4 opened


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()
    {
        if(_disposed) return;

        _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. Однако есть случаи, когда этого не происходит. Например, StackOverflowException, который не перехватывается ни catch ни finally. Это всегда надо учитывать. Ведь если у вас некий поток уйдет в рекурсию и в некоторой точке вылетит по StackOverflowException, то те ресурсы, которые были захвачены и не были освобождены, забудутся .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()
    {       
        if(_disposed) return;
        _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() для того, чтобы избежать мороки с вызывающим кодом, и это не правильно. Однако пользователь вашей библиотеки с оглядкой на документацию MS может так не считать и допускать множественные вызовы Dispose(). Вызов же других публичных методов в любом случае ломает целостность объекта. Если мы разрушили объект, значит с ним работать более нельзя. Это в свою очередь означает, что мы обязаны вставлять вызов CheckDisposed в начало каждого публичного метода.


Однако в этом коде существует очень серьезная проблема, которая не даст ему работать так, как задумали мы. Если мы повспоминаем, как отрабатывает процесс сборки мусора, то заметим одну деталь. При сборке мусора 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()
    {
        if(_disposed) return;
        _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 использовать именно его (т.к. при вызове счетчик опять же увеличится, а при выходе — уменьшится, что даст нам гарантию существования handle на все время чтения и записи в файл). Тип этот спроектирован корректно, а это значит, что он гарантированно закроет файловый дескриптор. Даже когда процесс аварийно завершит свою работу. А это значит, что нам не надо реализовывать свой 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)
        {
            if(_disposed) return;
            _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)
    {
        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 без возможности определить, кто его должен разрушать. Сторона, которая выдала или вы сами. Это породило антипаттерн множественного вызова Dispose(), который по сути позволяет разрешать разрушенный объект;
  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
Read the Docs: .NET Book

Комментарии (66)


  1. lair
    07.11.2017 18:43
    +1

    Скажите, а зачем вы запрещаете double dispose?


    1. sidristij Автор
      07.11.2017 19:24

      Прежде всего тем что если внешняя система пытается разрушить разрушенное — это значит что она плохо спроектирована. А если ориентироваться что кто-то снаружи плохо спроектирован — на мой взгляд не правильно и не правильному учит тех, кто будет использовать API


      1. lair
        07.11.2017 22:07
        +1

        Прежде всего тем что если внешняя система пытается разрушить разрушенное — это значит что она плохо спроектирована.

        Эээ?


        using(Stream s = GetRequestStream())
        {
            using(XmlReader r = XmlReader.Create(s))
            {
              //...
            }
        }

        Собственно, написано же: "DO allow the Dispose(bool) method to be called more than once. [...] Users expect that a call to Dispose will not raise an exception."


        1. sidristij Автор
          07.11.2017 22:53

          В этом примере XmlReader разрушает то, что создал не он сам. При этом внешний код, зная что объект разрушен ставит using, создавая дополнительный try… finally, который дополнительно замедляет приложение. Выглядит красиво, удобно, но не считаю это хорошей архитектурой.


          1. lair
            07.11.2017 23:00

            Выглядит красиво, удобно, но не считаю это хорошей архитектурой.

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


            При этом внешний код, зная что объект разрушен ставит using, создавая дополнительный try… finally, который дополнительно замедляет приложение.

            Есть конкретный бенчмарк, который показывает, насколько это медленнее?


            Понимаете, если вы нарушите это соглашение (allow for multiple disposal), вы нарушите контракт, который предлагают разработчики фреймворка, тем самым создав бесконечное количество WTF. Не надо так.


            1. petuhov_k
              08.11.2017 05:44
              -1

              Это типовое решение.

              Это не типовое решение, а решение принятое разработчиками платформы. Возможно это был выбор меньшего зла. Но это не означает, что мы теперь везде должны делать тоже самое.


              1. Cryvage
                08.11.2017 09:36

                Если это был выбор меньшего зла, то зачем же нам теперь выбирать большее зло? Да и не важно уже что было большим злом, а что меньшим. На данный момент, возможность повторного вызова Dispose — это поведение, ожидаемое большинством .Net разработчиков. Именно это определяющий фактор. Основная причина чтобы «делать то же самое».
                Впрочем, вас никто не заставляет повторно использовать Dispose в своём коде. Речь лишь о том, чтобы позволить так делать другим, когда вы проектируете IDisposable класс. И пусть это позволит кому-то писать менее оптимальный код. Ведь не заставит, а лишь позволит.


                1. petuhov_k
                  08.11.2017 11:35

                  Если это был выбор меньшего зла, то зачем же нам теперь выбирать большее зло?

                  Чтобы не было недопонимания поясню. Я вижу два вопроса: 1) должен ли посторонний код диспозить объект, который он не создавал. 2) нужно ли запрещать повторный dispose.

                  И мой комментарий относился именно к первому вопросу, поскольку веткой выше обсуждался XmlReader. Я имел ввиду, что наименьшее зло — это то, что reader|writer диспозят stream. Однако, в других ситуациях такое решение может быть не оправдано. Хотя лично я и в части стримов, такое решение не оправдываю.

                  По поводу исключений в Dispose мне нечего сказать. Да, упадём в рантайме при повторном вызове. Но, не исключено, что повторный вызов говорит, о неверной работе программы. Исключение гораздо быстрее обнаруживается и отлаживается, чем скрытые баги. Думаю, это хотел донести автор.


                  1. Cryvage
                    08.11.2017 17:40

                    должен ли посторонний код диспозить объект, который он не создавал.

                    Это извечная проблема. Нет четкого разделения между передачей во временное пользование, и передачей во владение. В первом случае диспозить нельзя, во втором — нужно. Тут не помешала бы move семантика. Что-то вроде плюсового unique_ptr.


              1. lair
                08.11.2017 11:34

                Это как раз типовое решение в том смысле, что в .net-экосистеме (в BCL, в скачиваемых пакетах, в разработках) так уже принято. Вы можете так не делать — в том смысле, что вы можете никогда не захватывать контроль над переданным вам ресурсом — но вы не можете ожидать, что никто другой не будет так делать.


                1. petuhov_k
                  08.11.2017 13:53

                  Как принято? Вызывать Dispose откуда придётся? Нет, в .net-экосистеме так не принято. Очень много методов и типов, которые так не делают. Так делают лишь упомянутый XmlReder и другие reader-ы/writer-ы. Ну может ещё пара-тройка мест.


                  1. lair
                    08.11.2017 13:58

                    Как принято?

                    Закрывать переданный тебе ресурс, если известно, что ты потребил его полностью. Early release, вот это все.


                    Равно как и все равно закрывать ресурс, даже если ты его кому-то отдал, в момент, когда он теряет актуальность (например, поток запроса при окончании обработки запроса).


                    Так делают лишь упомянутый XmlReder и другие reader-ы/writer-ы.

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


            1. mayorovp
              09.11.2017 14:29

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

              Скорее я бы назвал это типовой ошибкой. В данном случае все получилось замечательно, но добавляем одну маленькую деталь — и все валится :-)


              public Message Foo() 
              {
                  using(Stream s = GetSomeStream())
                  {
                      using(XmlReader r = XmlReader.Create(s))
                      {
                          return Message.CreateMessage(r, int.MaxValue, MessageVersion.Soap12);
                      }
                  }
              }


              1. lair
                09.11.2017 14:42

                Что характерно, если бы XmlReader не закрывал стрим внутри, ничего бы не изменилось.


                1. mayorovp
                  09.11.2017 14:44

                  Да нет, изменилось бы. Класс Message в его текущем виде просто не смог бы появиться: его реализация завязана на тот факт, что закрытие XmlReader освободит все связанные с ним ресурсы.


                  1. lair
                    09.11.2017 14:49

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


                    (в Rx вот сделали даже оператор специальный, чтобы привязывать время жизни одной последовательности к другой)


  1. kchrweb
    07.11.2017 18:44
    +2

    Например, ThreadAbortException, который не перехватывается ни catch ни finally.

    Перехватывается
    ThreadAbortException is a special exception that can be caught, but it will automatically be raised again at the end of the catch block. When this exception is raised, the runtime executes all the finally blocks before ending the thread.


    1. sidristij Автор
      07.11.2017 19:27

      Тут ошибка, согласен, пример исправлен.


  1. lair
    07.11.2017 18:45

    Хороший пример: а вы знали что IEnumerable<T> тянет за собой IDisposable?

    Не тянет (.net 4). Другое дело, что класс, реализующий IEnumerable, может одновременно реализовывать IDisposable, и foreach в C# это учтет.


    1. lair
      07.11.2017 18:58

      Необходимое уточнение: IEnumerator<T> действительно требует IDisposable (в отличие, кстати, от IEnumerator).


      1. sidristij Автор
        07.11.2017 19:20

        Да, пока доехал до дома Вы меня опередили. Опечатку поправил. referencesource.microsoft.com


  1. mayorovp
    07.11.2017 19:35

    Кажется, вы забыли упомянуть о еще одном важном достоинстве SafeHandle. Дело в том, что какой-нибудь ThreadAbortException может прилететь после вызова CreateFile — но еще до установки значения поля _handle. Без использования SafeHandle это приведет к редкой но очень неприятной утечке ресурса.


    1. sidristij Автор
      07.11.2017 19:38

      Да, спасибо, что напомнили :) Дополню в ближайшее время


  1. SanSYS
    07.11.2017 19:35

    А чего именно гитхаб, а не https://www.gitbook.com/?
    Оттуда удобно качать pdf/mobi/ebub, + автоматом формируются содержание и навигация для онлайн-версии. см. пример


    1. sidristij Автор
      07.11.2017 19:41

      Пока пробую dotnetbook.readthedocs.io/ru/latest/Disposable формат. Там тоже много возможностей. но если что, переведу на gitbook.


  1. MonkAlex
    07.11.2017 20:20

    Dispose кидает исключения, дожили.
    Он должен работать как часы в любом положении, повторный вызов — частый кейс.
    Называете статью полным описанием, но я так и не увидел хорошей рекомендации, как НАДО делать. А между тем, есть вполне себе отличный вариант на SO — ru.stackoverflow.com/a/486697/196257


    1. sidristij Автор
      07.11.2017 20:34

      Где вы нашли рекомендацию кидать исключения? Таких ситуаций допускать нельзя. Их два: оба описаны. Как надо, описано. Для Managed шаблон готовый, для unmanaged — тоже в целом описано. SafeHandle использовать вместо велосипедов. Либо делать свой, но наследовать от CriticalFinalizerObject + Dispose + finalizer. Вся схема описана в разделе Два уровня Disposable Design Principle.

      Но согласен, стоит выделить итоги отдельно.


      1. mayorovp
        07.11.2017 21:33

        Где вы нашли рекомендацию кидать исключения?

        А что делает ваш CheckDispose который вы вызываете внутрях Dispose?


        1. sidristij Автор
          07.11.2017 21:54
          -2

          Это единственное и правильное исключение. Оно говорит о том что тот кто пытается разрушить объект не совсем в курсе что сам делает и пытается освободить освобожденные ресурсы. Я в курсе что MS много где разрешает. Это не корректно. То что MS что-то сделала не значит что это — панацея.


      1. MonkAlex
        07.11.2017 21:35
        +1

        Второй вызов у вас стабильно кидает исключение, я вроде довольно понятно это написал. Абсолютно весь коробочный dotNet позволяет делать повторный вызов, ваша реализация — нет. Извините, но это глупость. Вести себя надо как коробка, просто потому что коробку используют все.

        Отдали вы в сторонюю либу свою реализацию IDisposable, а она падать начала на вашем исключении. Будете либу патчить? Удачи, время потрачено зря.

        ПС: «Два уровня Disposable Design Principle» неудобный паттерн, т.к. смешивать управляемые и неуправляемые — неудобно и смешивает обычно уровни абстракций. Да, он популярный, нет, его не надо вставлять в статью, ибо он выглядит рекомендацией.


        1. sidristij Автор
          07.11.2017 22:10
          -1

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


          Отдали вы в сторонюю либу свою реализацию IDisposable, а она падать начала на вашем исключении. Будете либу патчить? Удачи, время потрачено зря.

          Значит я криворук и отдал баг на продакшн, а QA фигово оттестировали.


          Назовите мне, пожалуйста, реальный кейс где без повторного вызова — ну никак? :)


          «Два уровня Disposable Design Principle» неудобный паттерн, т.к. смешивать управляемые и неуправляемые — неудобно и смешивает обычно уровни абстракций

          Паттерн ничего не смешивает, а наоборот — разделяет ответственность. Level 0 = это, например, SafeHandle. Он заведует только IntPtr и все. Никаких управляемых ресурсов. Либо какой-то свой класс, унаследованный от CriticalFinalizerObject и инкапсулирующий, например IntPtr и другой SafeHandle. Тогда делается икремент счетчика ссылок чтобы порядок финализации задать. Level 1 = разрушение только управляемых ресурсов. Полное разделение ответственности


          1. sidristij Автор
            07.11.2017 22:49
            -1

            Т.е. реальный кейс никто придумать не может? :)


            1. lair
              07.11.2017 23:00

              Реальный кейс я вам выше показал.


            1. MonkAlex
              07.11.2017 23:05

              Кроме кейса от lair — есть ещё куча либ, которые в конструктор принимают стрим… и всё. Т.е. они не сообщают никак, закроют его за собой или нет.

              Что должен делать в такой ситуации разработчик? Я предпочитаю закрывать стрим, потому как он мной создан, мной и закрыт. А потом оказывается, что либа считает принятый в конструктор стрим — своим, закрывает сама. Только с вашим вариантом об этом узнать придётся в рантайме, поймав исключение, а с коробочным вариантом — не придётся даже заморачиваться. Будет просто работать. Как собственно и должен делать хороший код.


              1. sidristij Автор
                07.11.2017 23:32

                + lair Хорошо, убедили. Мне, видимо, повезло со сторонним кодом ). Но это я скорее запишу в минусы шаблона чем в плюсы. Все должно быть однозначно на каждом этапе работы приложения.


              1. sidristij Автор
                08.11.2017 09:53

                + lair done. Добавил.


    1. sidristij Автор
      07.11.2017 21:21

      Я бы не сказал что пример по ссылке полностью корректен. Например, KeepAlive(this) в ctor — зачем? ). Множественный вызов Dispose() — зачем? Является ли множественный запрос SQL к серверу на всякий случай нормой и если нет, почему множественные попытки разрушить объект — норма? :)


  1. SanSYS
    07.11.2017 20:20

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

    Мне кажется, что тут в принципе стоит заметить, как себя ведёт приложение при различных исключениях:
    • StackOverflowException — приложение падает в нуль и без кетчей. Чтобы избежать такого — можно чекать стек через RuntimeHelpers.EnsureSufficientExecutionStack
    • ThreadAbortException — ловится в catch, но пробрасывается выше
    • OutOfMemoryException — ловится в кетч, если по факту памяти уже достаточно, то приложение продолжает работу
    • ExecutionEngineException — что-то не так в самом CLR пошло

    Подробнее см. Exceptional Exceptions in .NET


    1. sidristij Автор
      07.11.2017 20:35

      Спасибо за уточнение, тип исключения был выбран в качестве примера. Дополню всеми типами.


    1. sidristij Автор
      07.11.2017 21:46

      Посмотрел повнимательнее, мой пример корректен, т.к. речь именно о нехватке памяти )


  1. dmitry_dvm
    07.11.2017 22:50
    +1

    Очень хорошая статья, написанная легким языком. Надеюсь допилите книгу.


    1. DrFdooch
      08.11.2017 10:09
      +1

      поддерживаю, и добавлю: надеюсь, допилите книгу и учтете комментарии, все-таки при таком амбициозном подходе важно сохранить объективность (которая не только в «как надо» но и в «как делают»).


      1. sidristij Автор
        08.11.2017 10:09

        Мало того, я еще и добавил в конец раздела всех кто внес вклад :)


        1. Hydro
          08.11.2017 18:47

          Может быть Вам нужны еще руки / головы для столь доблестного труда?


  1. denismaster
    08.11.2017 12:17

    Отличная статья, ждем следующей части!)


  1. MockBeard
    08.11.2017 12:17

    Очень доходчиво написано, спасибо


  1. LeonThundeR
    09.11.2017 09:21

    Спасибо автору за статью и всем за коменты, они не менее интересны.


    1. LeonThundeR
      09.11.2017 09:40

      Хотел еще добавить. Вряд ли мне придется применить на практике знания полученные из этой статьи и ее обсуждения в ближайшее время, но все это очень познавательно и для себя я пришел к выводу, что оба варианта имеют права на жизнь.
      Если делать API для узкого круга лиц и/или для проекта в котором критична скорость освобождения ресурсов, то вариант автора намного более предпочтителен.
      Если же делать API «для всех», то однозначно только вариант допускающий множественные Dispose, т.к. это уже давно устоявшаяся практика для «всех» и в 1-ую все должно быть надежно и интуитивно понятно.
      Но с другой стороны C# ведь в принципе не предназначен для систем реального времени и возможно концепция автора просто не имеет смысла, а в случаях когда критична скорость освобождения ресурсов следует изначально выбирать C++ и т.п. и полностью контролировать ситуацию. Буду рад объективной критике.


      1. sidristij Автор
        09.11.2017 09:51

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


      1. lair
        09.11.2017 12:27

        Если делать API для узкого круга лиц и/или для проекта в котором критична скорость освобождения ресурсов, то вариант автора намного более предпочтителен.

        Почему?


        1. LeonThundeR
          09.11.2017 12:34

          Что именно почему? Все ведь расписано в моем посте. Если в каком-то проекте критична скорость освобождения ресурсов, то вариант автора оказывается более предпочтителен (в теории). А на практике, я считаю, реальной пользы от этого не будет, т.к. сборщик мусора приезжает когда захочет и прироста производительности от освобождения ресурсов в «правильном» порядке без дублирования не будет заметно.


          1. lair
            09.11.2017 12:41

            Именно что почему "вариант автора оказывается более предпочтителен, если критична скорость освобождения ресурсов". Какая именно особенность решения в посте повышает скорость освобождения?


          1. lair
            09.11.2017 12:46

            Для начала, что такое "скорость освобождения ресурсов"? Время, затраченное на вызов Dispose? Или время от создания объекта до момента, когда его ресурсы освобождены (успешно вызван Dispose)?


            1. LeonThundeR
              09.11.2017 13:56
              -1

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

              Для меня важно лишь определиться имеет ли смысл использовать данный паттерн.
              В общих случаях, я думаю всем очевидно, что нет. Т.к. страдает простота, красота, читабельность и т.д. кода, увеличивается объем инфраструктурного кода, может возникнуть куча проблем с проектированием и рефакторингом из ничего и т.д. И в итоге можно получить проектирование ради проектирования…

              Как я писал выше возможно это может иметь смысл в каких-то частных случаях, как вариант оптимизации, но лично я не вижу каких-то явных плюсов в том, что ВОЗМОЖНО сократится количество вызовов Dispose такой ценой.

              Я просто не могу представить задачу или ситуацию когда использование этого паттерна в контексте .NET будет оправдано. Автор в статье упоминал про граф объектов… В частном случае, если классы этих объектов описывают относительно примитивные сущности, целесообразным будет использование структур, а не классов со всеми вытекающими плюсами типов значений над ссылочными и это даст ощутимую оптимизацию быстродействия и использования памяти, если выделять память сразу для больших групп таких объектов.

              Возможно не совсем верная аналогия, но это можно сравнить с авто с АКПП и МКПП. И использование этого паттерна это попытка ехать на АКПП как на МКПП. Но если критичны возможности МКПП, не лучше ли изначально выбрать МКПП.
              Ведь основные преимущества C# это скорость разработки и высокая сложность отстрелить себе ноги, а в данном случае мы получается пытаемся идти против этих основных концепций.

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


              1. lair
                09.11.2017 14:01

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

                Так оно не зависит от того, что внутри Dispose.


                Для меня важно лишь определиться имеет ли смысл использовать данный паттерн.

                Какой именно?


                1. LeonThundeR
                  09.11.2017 14:09

                  Так оно не зависит от того, что внутри Dispose.

                  А я разве утверждал обратное? В том то собственно и вопрос, что мы по сути пытаемся оптимизировать количество вызовов методов которые по сути ничего не делают и сразу вызывают return.
                  Какой именно?

                  Вариант автора статьи. Я ведь очень подробно изложил свои выводы.


                  1. lair
                    09.11.2017 14:13

                    А я разве утверждал обратное?

                    Вы утверждали, что "Если делать API для узкого круга лиц и/или для проекта в котором критична скорость освобождения ресурсов, то вариант автора намного более предпочтителен.". Хотя в реальности вариант автора статьи не окажет влияния на скорость освобождения ресурсов.


                    Вариант автора статьи.

                    В том-то и дело, что вариант автора статьи — это не паттерн.


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


                    1. LeonThundeR
                      09.11.2017 14:20

                      В том-то и дело, что вариант автора статьи — это не паттерн.

                      Паттерн или не паттерн, или реализация паттерна это вопрос скорее философский и/или риторический.
                      Проще говоря, вариант автора статьи предпочтителен тогда и только тогда, когда кто-то зачем-то хочет заэнфорсить правило «строго один Dispose», причем из-за архитектурных соображений (потому что практической разницы нет).

                      Полностью согласен. Но для чего и зачем это в принципе может быть нужно?


                      1. lair
                        09.11.2017 14:42

                        Но для чего и зачем это в принципе может быть нужно?

                        Beats me. Это вопрос к тем, кто утверждает, что многократный Dispose — признак плохой архитектуры.


  1. Pro100Oleh
    09.11.2017 09:37
    +1

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

    Я думаю в этом кроется ваше желание кидать эксепшн при повторном вызове Dispose. У меня другое понимание: IDisposable — способ узнать, что конкретный тип требует освобождения захваченных ресурсов. Разрушение — это только одно из следствий вызова метода (на самое главное). Много где по тексту я бы заменил разрушение на освобождение.


    1. LeonThundeR
      09.11.2017 09:44

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


    1. sidristij Автор
      09.11.2017 09:48

      Вы меня навели на правильную мысль. Lingvo для Disposable подсказывает среди прочего вариант: выбрасываемый (после употребления). В русскоязычном IT сленге, наверное, «освобожденный» тут мало подходит. Выброшенный, использованный — не говорят и любого закидают тухлыми яйцами если так кто-то скажет :) Тут скорее уже стоит подбирать по смыслу. Кстати если переводить Dispose() как «прекратить использовать», то невыброс AlreadyDisposedException() — правильно и ставит все на свои места. Но перевод не привычен для нашего языка и «разрушение», как мне кажется, меньшее из зол.


    1. LeonThundeR
      09.11.2017 10:29

      Думаю, что называть это некомпетентностью все же не верно. Ведь освобождение ресурсов в «правильном» порядке без повторов по сути ничего не дает. А отсутствие необходимости думать об этом дает довольно много. В 1-ую очередь повышает скорость разработки и надежность.


  1. Bonart
    09.11.2017 12:04

    Очень хорошая фундаментальная статья.
    Есть пара-тройка замечаний:


    1. Поведение при вызове уже освобожденного (разрушенного) объекта логично считать неопределенным.
      Это полный аналог проверок на null аргументов и результатов методов. Они полезны при отладке, но в релизе могут быть удалены по соображениям производительности: все равно к удаленному объекту без ошибок в коде никто не обращается. Добавление таких проверок вручную нецелесообразно (они чисто шаблонные), тут подходящие условия для применения АОП, например, специального плагина для Fody.
    2. Поведение при двойном Dispose также логично считать непределенным из тех же соображений. Только тут есть два нюанса: передача IDisposable тому, кто не является владельцем (включая наследование интерфейсов от IDisposable) — плохой дизайн и этот плохой дизайн, к сожалению, широко распространен. В результате на практике повторный вызов Dispose лучше всего игнорировать (писать в лог в отладочной версии).
    3. Жесткий порядок освобождения и различие level-1 и level-0 для их владельца скорее вредно чем бесполезно. При отсутсвии ошибок дизайна (у каждого IDisposable ровно один владелец) все и так сработает хорошо, при наличии — все и так будет плохо.
    4. Критика паттерна на мой взгляд излишне суровая — возможно, лучше сосредоточиться на антипаттернах, связанных с неверным пониманием низкоуровневой природы IDisposable. Классический пример — требование передачи стримов и ридеров, реализующих IDisposable. Это сразу создает двух владельцев со всеми вытекающими.
    5. Главу про lifetime логично объединить с этой или поставить в книге сразу за ней.

    Еще раз спасибо за глубину и точность сведений в статье.


    1. mayorovp
      09.11.2017 12:47

      (комментарий был удален)


  1. Bonart
    09.11.2017 12:15

    Возможно, для реализации level-1 пригодится следующий паттерн:
    https://habrahabr.ru/post/270929/