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

IОдноразовый

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

public interface IDisposable
{
    void Dispose();
}

Каково назначение интерфейса? Я имею в виду, зачем нам вообще очищать память, если у нас есть умный сборщик мусора, который очищает память вместо нас, так что нам даже не приходится об этом думать. Однако есть небольшие детали.

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

Примитивы синхронизации в многопоточных или многопроцессорных программах являются вторым примером неуправляемых ресурсов. Сюда относятся массивы данных, которые передаются через P/Invoke, а также мьютексы или семафоры.

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

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

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

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

Первый способ — обернуть экземпляр в using(...){ ... }. Это означает, что вы указываете уничтожить объект после завершения блока, связанного с использованием, то есть вызвать Dispose(). Второй способ — уничтожить объект, когда его время жизни закончилось, со ссылкой на объект, который мы хотим освободить. Но в .NET нет ничего, кроме метода финализации, подразумевающего автоматическое уничтожение объекта, верно? Однако финализация совершенно не подходит, так как мы не знаем, когда она будет вызвана. При этом нам нужно освободить объект в определенное время, например сразу после того, как мы закончим работу с открытым файлом. Вот почему нам также необходимо реализовать IDisposable и вызвать 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();
        _disposed = true;
    }
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private void CheckDisposed()
    {
        if(_disposed) {
            throw new ObjectDisposedException();
        }
    }
}

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

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#). Этот блок неявно вызывает Dispose. Однако бывают случаи, когда этого не происходит. Например, ни catch, ни finally не поймают StackOverflowException. Вы всегда должны помнить об этом. Потому что если какой-то поток станет рекурсивным и в какой-то момент произойдет StackOverflowException, .NET забудет о ресурсах, которые он использовал, но не освободил. Он не знает, как освободить неуправляемые ресурсы. Они останутся в памяти до тех пор, пока ОС их не освободит, то есть при выходе из программы или даже через некоторое время после закрытия приложения.
  • Если мы вызовем Dispose() из другого Dispose(). Опять же, мы можем оказаться не в состоянии добраться до него. Это не случай рассеянного разработчика приложения, который забыл вызвать Dispose(). Это вопрос исключений. Однако это не только исключения, приводящие к сбою потока приложения. Здесь мы говорим обо всех исключениях, которые не позволят алгоритму вызвать внешний Dispose(), который вызовет наш Dispose().

Во всех этих случаях будут создаваться приостановленные неуправляемые ресурсы. Это потому, что сборщик мусора не знает, что должен их собрать. Все, что он может сделать при следующей проверке, это обнаружить, что последняя ссылка на граф объектов с нашим FileWrappertype потеряна. В этом случае память будет перераспределена под объекты со ссылками. Как мы можем предотвратить это?

Мы должны реализовать финализатор объекта. «Финализатор» назван так не случайно. Это не деструктор, как может показаться из-за схожих способов вызова финализаторов в C# и деструкторов в C++. Разница в том, что финализатор будет вызываться все равно, в отличие от деструктора (как и Dispose()). Финализатор вызывается при инициации сборки мусора (сейчас достаточно это знать, но все немного сложнее). Он используется для гарантированного освобождения ресурсов, если что-то пойдет не так. Мы должны реализовать финализатор для освобождения неуправляемых ресурсов. Опять же, поскольку финализатор вызывается при запуске сборщика мусора, мы не знаем, когда это вообще происходит.

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

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 в начале каждого общедоступного метода.

Однако этот код содержит серьезную проблему, из-за которой он не работает должным образом. Если мы вспомним, как работает сборка мусора, то заметим одну особенность. При сборке мусора сборщик мусора в первую очередь финализирует все, что унаследовано непосредственно от Object. Затем он имеет дело с объектами, которые реализуют CriticalFinalizerObject. Это становится проблемой, поскольку оба класса, которые мы разработали, наследуют Object. Мы не знаем, в каком порядке они пройдут «последнюю милю». Однако объект более высокого уровня может использовать свой финализатор для финализации объекта с неуправляемым ресурсом. Хотя, это не кажется отличной идеей. Порядок финализации был бы очень полезен здесь. Для его установки низкоуровневый тип с инкапсулированным неуправляемым ресурсом должен быть унаследован от CriticalFinalizerObject.

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

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

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

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

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

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

[SecurityCritical, SecurityPermission(SecurityAction.InheritanceDemand, UnmanagedCode=true)]
public abstract class SafeHandle : CriticalFinalizerObject, IDisposable
{
    protected IntPtr handle;        // The handle from OS
    private int _state;             // State (validity, the reference counter)
    private bool _ownsHandle;       // The flag for the possibility to release the handle. 
                                    // It may happen that we wrap somebody else’s handle 
                                    // have no right to release.
    private bool _fullyInitialized; // The initialized instance
    [ReliabilityContract(Consistency.WillNotCorruptState, Cer.MayFail)]
    protected SafeHandle(IntPtr invalidHandleValue, bool ownsHandle)
    {
    }
    // The finalizer calls Dispose(false) with a pattern
    [SecuritySafeCritical]
    ~SafeHandle()
    {
        Dispose(false);
    }
    // You can set a handle manually or automatically with p/invoke Marshal
    [ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
    protected void SetHandle(IntPtr handle)
    {
        this.handle = handle;
    }
    // This method is necessary to work with IntPtr directly. It is used to  
    // determine if a handle was created by comparing it with one of the previously
    // determined known values. Pay attention that this method is dangerous because:
    //
    //   – if a handle is marked as invalid by SetHandleasInvalid, DangerousGetHandle
    //     it will anyway return the original value of the handle.
    //   – you can reuse the returned handle at any place. This can at least
    //     mean, that it will stop work without a feedback. In the worst case if
    //     IntPtr is passed directly to another place, it can go to an unsafe code and become
    //     a vector for application attack by resource substitution in one IntPtr
    [ResourceExposure(ResourceScope.None), ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
    public IntPtr DangerousGetHandle()
    {
        return handle;
    }
    // The resource is closed (no more available for work)
    public bool IsClosed {
        [ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
        get { return (_state & 1) == 1; }
    }
    // The resource is not available for work. You can override the property by changing the logic.
    public abstract bool IsInvalid {
        [ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
        get;
    }
    // Closing the resource through Close() pattern 
    [SecurityCritical, ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
    public void Close() {
        Dispose(true);
    }
    // Closing the resource through Dispose() pattern
    [SecuritySafeCritical, ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
    public void Dispose() {
        Dispose(true);
    }
    [SecurityCritical, ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
    protected virtual void Dispose(bool disposing)
    {
        // ...
    }
    // You should call this method every time when you understand that a handle is not operational anymore.
    // If you don’t do it, you can get a leak.
    [SecurityCritical, ResourceExposure(ResourceScope.None)]
    [ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
    [MethodImplAttribute(MethodImplOptions.InternalCall)]
    public extern void SetHandleAsInvalid();
    // Override this method to point how to release
    // the resource. You should code carefully, as you cannot
    // call uncompiled methods, create new objects or produce exceptions from it.
    // A returned value shows if the resource was releases successfully.
    // If a returned value = false, SafeHandleCriticalFailure will occur
    // that will enter a breakpoint if SafeHandleCriticalFailure
    // Managed Debugger Assistant is activated.
    [ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
    protected abstract bool ReleaseHandle();
    // Working with the reference counter. To be explained further.
    [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: GC может собирать их экземпляры автоматически. Поскольку SafeHandle является управляемым, неуправляемый ресурс, в который он заключен, наследует все характеристики управляемого мира. Он также содержит внутренний счетчик внешних ссылок, недоступных для CLR. Я имею в виду ссылки из небезопасного кода. Вам вообще не нужно увеличивать или уменьшать счетчик вручную. Когда вы объявляете тип, производный от SafeHandle, в качестве параметра небезопасного метода, счетчик увеличивается при входе в этот метод или уменьшается после выхода. Причина в том, что когда вы переходите к небезопасному коду, передавая туда хэндл, вы можете получить этот SafeHandle, собранный сборщиком мусора, путем сброса ссылки на этот хэндл в другом потоке (если вы имеете дело с одним хэндлом из нескольких потоков). Со счетчиком ссылок дело обстоит еще проще: SafeHandle не будет создан, пока счетчик не будет обнулен. Поэтому вам не нужно менять счетчик вручную. Или вы должны делать это очень осторожно, возвращая его, когда это возможно.

Вторая цель счетчика ссылок — установить порядок завершения CriticalFinalizerObject, которые ссылаются друг на друга. Если один тип на основе SafeHandle ссылается на другой, то необходимо дополнительно увеличить счетчик ссылок в конструкторе ссылающегося типа и уменьшить счетчик в методе 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 (включая ваш собственный), то Marshal правильно создаст и инициализирует этот тип и установит счетчик в 1. Зная это, мы устанавливаем тип SafeFileHandle в качестве возвращаемого типа для функция ядра CreateFile. Когда мы его получим, мы будем использовать его именно для вызова ReadFile и WriteFile (поскольку значение счетчика увеличивается при вызове и уменьшается при выходе, это гарантирует, что дескриптор все еще существует во время чтения и записи в файл). Это правильно спроектированный тип, и он надежно закроет дескриптор файла, если поток будет прерван. Это означает, что нам не нужно реализовывать собственный финализатор и все, что с ним связано. Весь тип упрощен.

Выполнение финализатора, когда методы экземпляра работают

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

public void SampleMethod()
{
    var obj = new object();
    obj.ToString();
    // ...
    // If GC runs at this point, it may collect obj
    // as it is not used anymore
    // ...
    Console.ReadLine();
}

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

// The example of an absolutely incorrect implementation
void Main()
{
    var inst = new SampleClass();
    inst.ReadData();
    // inst is not used further
}
public sealed class SampleClass : CriticalFinalizerObject, IDisposable
{
    private IntPtr _handle;
    public SampleClass()
    {
        _handle = CreateFile("test.txt", 0, 0, IntPtr.Zero, 0, 0, IntPtr.Zero);
    }
    public void Dispose()
    {
        if (_handle != IntPtr.Zero)
        {
            CloseHandle(_handle);
            _handle = IntPtr.Zero;
        }
    }
    ~SampleClass()
    {
        Console.WriteLine("Finalizing instance.");
        Dispose();
    }
    public unsafe void ReadData()
    {
        Console.WriteLine("Calling GC.Collect...");
        // I redirected it to the local variable not to
        // use this after GC.Collect();
        var handle = _handle;
        // The imitation of full GC.Collect
        GC.Collect();
        GC.WaitForPendingFinalizers();
        GC.Collect();
        Console.WriteLine("Finished doing something.");
        var overlapped = new NativeOverlapped();
        // it is not important what we do
        ReadFileEx(handle, new byte[] { }, 0, ref overlapped, (a, b, c) => {;});
    }
    [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto, BestFitMapping = false)]
    static extern IntPtr CreateFile(String lpFileName, int dwDesiredAccess, int dwShareMode,
    IntPtr securityAttrs, int dwCreationDisposition, int dwFlagsAndAttributes, IntPtr hTemplateFile);
    [DllImport("kernel32.dll", SetLastError = true)]
    static extern bool ReadFileEx(IntPtr hFile, [Out] byte[] lpBuffer, uint nNumberOfBytesToRead,
    [In] ref NativeOverlapped lpOverlapped, IOCompletionCallback lpCompletionRoutine);
    [DllImport("kernel32.dll", SetLastError = true)]
    static extern bool CloseHandle(IntPtr hObject);
}

Согласитесь, этот код выглядит более-менее прилично. Во всяком случае, не похоже, что есть проблема. На самом деле есть серьезная проблема. Финализатор класса может попытаться закрыть файл во время его чтения, что почти неизбежно приводит к ошибке. Поскольку в этом случае явно возвращается ошибка (IntPtr == -1), мы этого не увидим. _handle будет установлено на ноль, следующие Dispose не смогут закрыть файл, и ресурс утечет. Чтобы решить эту проблему, вы должны использовать SafeHandle, CriticalHandle, SafeBuffer и их производные классы. Помимо того, что эти классы имеют счетчики использования в неуправляемом коде, эти счетчики также автоматически увеличиваются при переходе с параметрами методов в неуправляемый мир и уменьшаются при выходе из него.

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

Теперь поговорим о тонком льду. В предыдущих разделах об 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, проверки текущего потока и многого другого (о них мы поговорим в главе о многопоточности). Это означает, что нам придется жертвовать производительностью объекта на протяжении всей его жизни ради «последней мили» его жизни.
  • Дополнительный трафик памяти для объектов синхронизации.
  • Дополнительные шаги, которые должен предпринять сборщик мусора для прохождения графа объектов.

Теперь назовем второе и, на мой взгляд, самое главное. Мы допускаем разрушение объекта и в то же время рассчитываем снова работать с ним. На что мы надеемся в этой ситуации? что не получится? Потому что если сначала запустится Dispose, то последующее использование методов объекта обязательно приведет к ObjectDisposedException. Таким образом, вы должны делегировать синхронизацию между вызовами Dispose() и другими публичными методами типа стороне сервиса, т.е. коду, создавшему экземпляр класса FileWrapper. Это потому, что только создающая сторона знает, что она будет делать с экземпляром класса и когда его уничтожить. С другой стороны, вызов Dispose должен выдавать только критические ошибки, такие как OutOfMemoryException, но не IOException, например. Это связано с требованиями к архитектуре классов, реализующих IDisposable. Это означает, что если Dispose вызывается более чем из одного потока одновременно, уничтожение сущности может произойти из двух потоков одновременно (пропускаем проверку if(_disposed) return;). Это зависит от ситуации: если ресурс может быть освобожден несколько раз, в дополнительных проверках нет необходимости. В противном случае необходима защита:

// I don’t show the whole pattern on purpose as the example will be too long
// and will not show the essence
class Disposable : IDisposable
{
    private volatile int _disposed;
    public void Dispose()
    {
        if(Interlocked.CompareExchange(ref _disposed, 1, 0) == 0)
        {
            // dispose
        }
    }
}

Два уровня принципа одноразового дизайна

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

public class Disposable : IDisposable
{
    bool _disposed;
    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }
    protected virtual void Dispose(bool disposing)
    {
        if(disposing)
        {
            // here we release managed resources
        }
        // here we release unmanaged resources
    }
    protected void CheckDisposed()
    {
        if(_disposed)
        {
            throw new ObjectDisposedException();
        }
    }
    ~Disposable()
    {
        Dispose(false);
    }
}

Что не так с этим примером и почему мы раньше так не писали? На самом деле, это хороший шаблон, подходящий для всех ситуаций. Тем не менее, его повсеместное использование, на мой взгляд, не является хорошим стилем, поскольку на практике мы почти не имеем дело с неуправляемыми ресурсами, что делает половину шаблона бесполезным. Более того, поскольку он одновременно управляет и управляемыми, и неуправляемыми ресурсами, то нарушает принцип разделения ответственности. Я думаю, что это неправильно. Рассмотрим немного другой подход. Принцип одноразового дизайна. Вкратце, это работает следующим образом:

Утилизация делится на два уровня классов:

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

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

Другие способы использования Dispose

Идея создания IDisposable заключалась в освобождении неуправляемых ресурсов. Но, как и многие другие шаблоны, он очень полезен для других задач, например. освободить ссылки на управляемые ресурсы. Хотя освобождение управляемых ресурсов кажется не очень полезным. Я имею в виду, что они называются управляемыми специально, чтобы мы с ухмылкой расслабились в отношении разработчиков C/C++, верно? Однако это не так. Всегда может быть ситуация, когда мы теряем ссылку на объект, но при этом думаем, что все в порядке: сборщик мусора соберет мусор, в том числе и наш объект. Однако оказывается, что память растет. Заходим в программу анализа памяти и видим, что что-то еще удерживает этот объект. Дело в том, что логика неявного захвата ссылки на вашу сущность может быть как в платформе .NET, так и в архитектуре внешних классов. Поскольку захват является неявным, программист может пропустить необходимость его освобождения и получить утечку памяти.

Делегаты, мероприятия

Давайте посмотрим на этот синтетический пример:

class Secondary
{
    Action _action;
    void SaveForUseInFuture(Action action)
    {
        _action = action;
    }
    public void CallAction()
    {
        _action();
    }
}
class Primary
{
    Secondary _foo = new Secondary();
    public void PlanSayHello()
    {
        _foo.SaveForUseInFuture(Strategy);
    }
    public void SayHello()
    {
        _foo.CallAction();
    }
    void Strategy()
    {
        Console.WriteLine("Hello!");
    }
}

Какую проблему показывает этот код? Вторичный класс хранит делегата типа Action в поле _action, которое принимается в методе SaveForUseInFuture. Затем метод PlanSayHello внутри класса Primary передает указатель на метод Strategy в класс Secondary. Любопытно, но если в этом примере вы передадите куда-то статический метод или метод экземпляра, то переданный SaveForUseInFuture не изменится, но на экземпляр класса Primary будет ссылаться неявно или вообще не ссылаться . Внешне это выглядит так, как будто вы указали, какой метод вызывать. Но на самом деле делегат строится не только с помощью указателя на метод, но и с помощью указателя на экземпляр класса. Вызывающая сторона должна понимать, для какого экземпляра класса она должна вызывать метод Strategy! То есть экземпляр класса Secondary неявно принял и удерживает указатель на экземпляр класса Primary, хотя явно это не указано. Для нас это означает только то, что если мы передадим _foopointer куда-то еще и потеряем ссылку на Primary, то GC не соберет Primary объект, так как Secondary будет его удерживать. Как нам избежать таких ситуаций? Нам нужен решительный подход, чтобы выпустить ссылку на нас. Для этой цели идеально подходит механизм IDisposable.

// This is a simplified implementation
class Secondary : IDisposable
{
    Action _action;
    public event Action<Secondary> OnDisposed;
    public void SaveForUseInFuture(Action action)
    {
        _action = action;
    }
    public void CallAction()
    {
        _action?.Invoke();
    }
    void Dispose()
    {
        _action = null;
        OnDisposed?.Invoke(this);
    }
}

Теперь пример выглядит приемлемым. Если экземпляр класса будет передан третьей стороне и ссылка на _actiondelegate при этом будет потеряна, мы установим его в ноль и третья сторона будет уведомлена об уничтожении экземпляра и удалении ссылки на него. Вторая опасность кода, работающего с делегатами, — это принципы работы event. Посмотрим, к чему они приводят:

// a private field of a handler
private Action<Secondary> _event;
// add/remove methods are marked as [MethodImpl(MethodImplOptions.Synchronized)]
// that is similar to lock(this)
public event Action<Secondary> OnDisposed {
    add { lock(this) { _event += value; } }
    remove { lock(this) { _event -= value; } }
}

Обмен сообщениями C# скрывает внутренности событий и содержит все объекты, которые подписались на обновление через event. Если что-то пойдет не так, ссылка на подписанный объект останется в OnDisposed и будет содержать объект. Странная ситуация, так как с точки зрения архитектуры мы получаем понятие «источник событий», которое логически не должно содержать ничего. Но на самом деле объекты, подписанные на обновление, удерживаются неявно. Кроме того, мы не можем что-то изменить внутри этого массива делегатов, хотя сущность принадлежит нам. Единственное, что мы можем сделать, это удалить этот список, присвоив источнику событий значение null.

Второй способ — явно реализовать методы add/remove, чтобы мы могли управлять набором делегатов.

Здесь может возникнуть другая неявная ситуация. Может показаться, что если вы присвоите null источнику событий, следующая подписка на события вызовет NullReferenceException. Я думаю, это было бы более логично.

Однако это не так. Если внешний код подписывается на события после очистки источника событий, FCL создаст новый экземпляр класса Action и сохранит его в OnDisposed. Эта неявность в C# может ввести программиста в заблуждение: работа с пустыми полями должна вызывать некую бдительность, а не спокойствие. Здесь мы также демонстрируем подход, когда невнимательность программиста может привести к утечкам памяти.

Лямбды, замыкания

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

Я хотел бы коснуться синтаксического сахара в целом. Я думаю, что вы должны использовать его довольно осторожно и только в том случае, если вы точно знаете результат. Примерами лямбда-выражений являются замыкания, замыкания в выражениях и многие другие неприятности, которые вы можете причинить себе.

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

button.Clicked += () => service.SendMessageAsync(MessageType.Deploy);

Согласитесь, эта линия выглядит очень надежно. Но за этим скрывается большая проблема: теперь переменная button неявно ссылается на service и содержит ее. Даже если мы решим, что service нам больше не нужен, button все равно будет содержать ссылку, пока эта переменная жива. Один из способов решить эту проблему — использовать шаблон для создания IDisposable из любого Action(System.Reactive.Disposables):

// Here we create a delegate from a lambda
Action action = () => service.SendMessageAsync(MessageType.Deploy);
// Here we subscribe
button.Clicked += action;
// We unsubscribe
var subscription = Disposable.Create(() => button.Clicked -= action);
// where it is necessary
subscription.Dispose();

Согласитесь, это выглядит немного затянуто, и мы теряем весь смысл использования лямбда-выражений. Гораздо безопаснее и проще использовать обычные частные методы для неявного захвата переменных.

Защита от прерывания потока

Когда вы создаете библиотеку для стороннего разработчика, вы не можете предсказать ее поведение в стороннем приложении. Иногда вы можете только догадываться, что программист сделал с вашей библиотекой, что привело к тому или иному результату. Одним из примеров является работа в многопоточной среде, когда непротиворечивость очистки ресурсов может стать критической проблемой. Обратите внимание, когда мы пишем метод Dispose(), мы можем гарантировать отсутствие исключений. Однако мы не можем гарантировать, что при выполнении метода Dispose() не произойдет ThreadAbortException, который отключит наш поток выполнения. Здесь мы должны помнить, что когда происходит ThreadAbortException, все блоки catch/finally выполняются в любом случае (в конце блока catch/finally дальше происходит ThreadAbort). Итак, чтобы обеспечить выполнение определенного кода с помощью Thread.Abort, вам нужно обернуть критическую секцию в try { ... } finally { ... }, см. пример ниже:

void Dispose()
{
    if(_disposed) return;
    _someInstance.Unsubscribe(this);
    _disposed = true;
}

Это можно прервать в любой момент, используя Thread.Abort. Он частично уничтожает объект, хотя вы все еще можете работать с ним в будущем. При этом следующий код:

void Dispose()
{
    if(_disposed) return;
    // ThreadAbortException protection
    try {}
    finally
    {
        _someInstance.Unsubscribe(this);
        _disposed = true;
    }
}

защищен от такого прерывания и будет работать гладко и точно, даже если Thread.Abort появится между вызовом метода Unsubscribe и выполнением его инструкций.

Результаты

Преимущества

Что ж, мы многое узнали об этом простейшем узоре. Определим его преимущества:

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

Недостатки

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

  1. С одной стороны, любой тип, реализующий этот шаблон, сообщает другим частям, что, если они его используют, они принимают своего рода публичную оферту. Это настолько неявно, что, как и в случае с публичными предложениями, пользователь типа не всегда знает, что тип имеет этот интерфейс. Таким образом, вы должны следовать подсказкам IDE (введите точку, Dis.. и проверьте, есть ли метод в отфильтрованном списке членов класса). Если вы видите шаблон Dispose, вы должны реализовать его в своем коде. Иногда это происходит не сразу, и в этом случае следует реализовывать паттерн через систему типов, добавляющую функциональность. Хорошим примером является то, что IEnumerator<T> влечет за собой IDisposable.
  2. Обычно при проектировании интерфейса возникает необходимость вставить IDisposable в систему интерфейсов типа, когда один из интерфейсов должен наследовать IDisposable. На мой взгляд, это вредит разработанным нами интерфейсам. Я имею в виду, что когда вы проектируете интерфейс, вы сначала создаете протокол взаимодействия. Это набор действий, которые вы можете выполнять с чем-то, скрытым за интерфейсом. Dispose() — это метод уничтожения экземпляра класса. Это противоречит сути протокола взаимодействия. По сути, это детали реализации, просочившиеся в интерфейс.
  3. Несмотря на определение, Dispose() не означает прямого уничтожения объекта. Объект все еще будет существовать после его уничтожения, но в другом состоянии. Чтобы сделать его истинным, CheckDisposed() должна быть первой командой каждого общедоступного метода. Это похоже на временное решение, которое кто-то дал нам, говоря: «Идите и размножайтесь»;
  4. Также есть небольшой шанс получить тип, реализующий IDisposable посредством явной реализации. Или можно получить тип, реализующий IDisposable без возможности определить, кто его должен уничтожить: вы или сторона, которая вам его дала. В результате получился антипаттерн множественных вызовов Dispose(), позволяющий уничтожить уничтоженный объект;
  5. Полная реализация сложна, и она отличается для управляемых и неуправляемых ресурсов. Здесь попытка облегчить работу разработчиков через GC выглядит неуклюжей. Вы можете переопределить метод virtual void Dispose() и ввести некий тип DisposableObject, который реализует весь паттерн, но это не решит других проблем, связанных с паттерном;
  6. Как правило, метод Dispose() реализуется в конце файла, а ‘.ctor’ объявляется в начале. Если вы измените класс или введете новые ресурсы, легко забыть добавить для них удаление.
  7. Наконец, трудно определить порядок уничтожения в многопоточной среде, когда вы используете шаблон для графов объектов, где объекты полностью или частично реализуют этот шаблон. Я имею в виду ситуации, когда Dispose() может начинаться с разных концов графа. Здесь лучше использовать другие шаблоны, например. Образ жизни.
  8. Желание разработчиков платформы автоматизировать управление памятью совпало с реалиями: приложения очень часто взаимодействуют с неуправляемым кодом + нужно контролировать выпуск ссылок на объекты, чтобы сборщик мусора мог их собрать. Это вносит большую путаницу в понимание таких вопросов, как: «Как мы должны правильно реализовать шаблон»? «Есть ли вообще надежный образец»? Может проще позвонить delete obj; delete[] arr;?

Выгрузка домена и выход из приложения

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

Во время выгрузки домена приложения вы выгружаете как сборки, загруженные в домен приложения, так и все объекты, которые были созданы как часть выгружаемого домена. Фактически это означает очистку (сбор GC) этих объектов и вызов для них финализаторов. Если логика финализатора ожидает уничтожения финализации других объектов в правильном порядке, можно обратить внимание на свойство Environment.HasShutdownStarted, указывающее, что приложение выгружается из памяти, и на метод AppDomain.CurrentDomain.IsFinalizingForUnload(), указывающий, что этот домен выгружен, что является причиной доработка. В случае возникновения этих событий порядок финализации ресурсов, как правило, становится неважным. Мы не можем затягивать ни с выгрузкой домена, ни с приложением, так как должны сделать все максимально быстро.

Вот так решается эта задача в составе класса LoaderAllocatorScout

// Assemblies and LoaderAllocators will be cleaned up during AppDomain shutdown in
// an unmanaged code
// So it is ok to skip reregistration and cleanup for finalization during appdomain shutdown.
// We also avoid early finalization of LoaderAllocatorScout due to AD unload when the object was inside DelayedFinalizationList.
if (!Environment.HasShutdownStarted &&
    !AppDomain.CurrentDomain.IsFinalizingForUnload())
{
    // Destroy returns false if the managed LoaderAllocator is still alive.
    if (!Destroy(m_nativeLoaderAllocator))
    {
        // Somebody might have been holding a reference on us via weak handle.
        // We will keep trying. It will be hopefully released eventually.
        GC.ReRegisterForFinalize(this);
    }
}

Типичные ошибки реализации

Как я показал вам, не существует универсального шаблона для реализации IDisposable. Более того, некоторая зависимость от автоматического управления памятью вводит людей в заблуждение, и они принимают запутанные решения при реализации шаблона. Вся .NET Framework пронизана ошибками в ее реализации. Чтобы доказать мою точку зрения, давайте рассмотрим эти ошибки именно на примере .NET Framework. Все реализации доступны через: IDisposable Usages

Класс FileEntry cmsinterop.cs

Этот код написан в спешке, чтобы закрыть проблему. Очевидно, автор хотел что-то сделать, но передумал и оставил ошибочное решение

internal class FileEntry : IDisposable
{
    // Other fields
    // ...
    [MarshalAs(UnmanagedType.SysInt)] public IntPtr HashValue;
    // ...
    ~FileEntry()
    {
        Dispose(false);
    }
    // The implementation is hidden and complicates calling the *right* version of a method.
    void IDisposable.Dispose() { this.Dispose(true); }
    // Choosing a public method is a serious mistake that allows for incorrect destruction of
    // an instance of a class. Moreover, you CANNOT call this method from the outside
    public void Dispose(bool fDisposing)
    {
        if (HashValue != IntPtr.Zero)
        {
            Marshal.FreeCoTaskMem(HashValue);
            HashValue = IntPtr.Zero;
        }
        if (fDisposing)
        {
            if( MuiMapping != null)
            {
                MuiMapping.Dispose(true);
                MuiMapping = null;
            }
            System.GC.SuppressFinalize(this);
        }
    }
}

SemaphoreSlim Класс System/Threading/SemaphoreSlim.cs

Эта ошибка находится в верхней части ошибок .NET Framework в отношении IDisposable: SuppressFinalize для классов, в которых нет финализатора. Это очень распространено.

public void Dispose()
{
    Dispose(true);
    // As the class doesn’t have a finalizer, there is no need in GC.SuppressFinalize
    GC.SuppressFinalize(this);
}
// The implementation of this pattern assumes the finalizer exists. But it doesn’t.
// It was possible to do with just public virtual void Dispose()
protected virtual void Dispose(bool disposing)
{
    if (disposing)
    {
        if (m_waitHandle != null)
        {
            m_waitHandle.Close();
            m_waitHandle = null;
        }
        m_lockObj = null;
        m_asyncHead = null;
        m_asyncTail = null;
    }
}

Вызов Close+Dispose Некоторый код проекта NativeWatcher

Иногда люди вызывают и Close, и Dispose. Это неправильно, хотя и не приведет к ошибке, так как второй Dispose не генерирует исключение.

На самом деле Close — это еще один паттерн, делающий вещи более понятными для людей. Однако это сделало все более неясным.

public void Dispose()
{
    if (MainForm != null)
    {
        MainForm.Close();
        MainForm.Dispose();
    }
    MainForm = null;
}

Общие результаты

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