[DotNetBook] Реализация IDisposable: правильное использование
Play

[DotNetBook] Реализация IDisposable: правильное использование

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

Disposing (Disposable Design Principle)

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

IDisposable

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

Для чего же создан интерфейс? Ведь если у нас есть умный Garbage Collector, который за нас чистит всю память, делает так, чтобы мы вообще не задумывались о том, как чистить память, то становится не совсем понятно, зачем ее вообще чистить. Однако есть нюансы.

Примечание

Глава, опубликованная на Хабре не обновляется и возможно, уже несколько устарела. А потому, прошу обратиться за более свежим текстом к оригиналу:

  • CLR Book: GitHub, оглавление
  • CLR Book: GitHub, глава
  • Релиз 0.5.2 книги, PDF: GitHub Release

Существует некоторое заблуждение, что 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:

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

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

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

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

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

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

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

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

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

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

Теперь наш пример реализации типа, инкапсулирующего неуправляемый ресурс, выглядит законченным. Повторный 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 и который призван обернуть дескрипторы операционной системы максимально безопасно и удобно.

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

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

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

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

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

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

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

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

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

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

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