Чому вам не слід використовуватифіналізатори
Нещодавно ми працювали над діагностикою, пов'язаною з перевіркою фіналізатора, і у нас з колегою виникла суперечка з приводу деталей роботи збирача сміття та фіналізації об'єктів. І хоча я і він займаємося розробкою на C# більше 5 років, на загальну думку ми не прийшли, і я вирішив вивчити це питання докладніше.

Зазвичай, перше знайомство з фіналізаторами у .NET розробників відбувається, коли їм потрібно звільнити некерований ресурс. Виникає питання що потрібно використовувати: реалізувати у своєму класі IDisposable чи додати фіналізатор? Тоді вони йдуть, наприклад, на StackOverflow і читають відповіді на питання типу Finalize/Dispose pattern in C# де розповідається про класичний патерн реалізації IDisposable в поєднанні з визначенням фіналізатора. Той самий патерн можна знайти і в MSDN в описі інтерфейсу IDisposable. Деякі вважають його досить складним для розуміння та пропонують свої варіанти на кшталт реалізації очищення керованих та некерованих ресурсів в окремих методах або створення класу-обгортки спеціально для звільнення некерованого ресурсу. Їх можна знайти на тій самій сторінці на StackOverflow.
Більшість цих способів передбачає реалізацію фіналізатора. Подивимося які плюси та потенційні проблеми це може принести.
Плюси та мінуси використання фіналізаторів
- Фіналізатор дозволяє провести очищення об'єкта перед тим, як він буде видалений збирачем сміття. Якщо розробник забув викликати у об'єкта метод Dispose() , то у фіналізаторі можна звільнити некеровані ресурси і таким чином уникнути їхнього витоку.
Мабуть, все. Це єдиний плюс, та й то спірне, про що нижче.
- Фіналізація недетермінована. Ви не знаєте, коли буде викликано фіналізатора. Перш ніж CLRпочне фіналізувати об'єкти, збирач сміття повинен помістити їх у чергу об'єктів, готових до фіналізації, коли запуститься чергове складання сміття. А цей момент не визначено.
- У зв'язку з тим, що об'єкт із фіналізатором не видаляється збирачем сміття відразу, він і весь граф пов'язаних з ним об'єктів переживають складання сміття та потрапляють до наступного покоління. Видалені вони будуть тоді, коли збирач сміття вирішить зібрати об'єкти цього покоління, що може статися дуже нескоро.
- Так як фіналізатори виконуються в окремому потоці паралельно роботі інших потоків програми, то може виникнути ситуація, коли нові об'єкти, що вимагають фіналізації, можуть створюватися швидше, ніж відпрацьовувати фіналізатори старих об'єктів. Це призведе до збільшення пам'яті, що споживається, зниження продуктивності і, можливо, в результаті до падіння програми з OutOfMemoryException . Причому на машині розробника ви можете ніколи і не зіткнутися з цією ситуацією, наприклад, тому що у вас менша кількість процесорів та об'єкти створюються повільніше або програма працює не так довго, як у бойових умовах, і пам'ять не встигає закінчитися. Можна витратити дуже багато часу, щоб зрозуміти, що причина була у фіналізаторах. Цей мінус, напевно, перекриває переваги єдиного плюсу.
- Якщо при виконанні фіналізатора виникне виняток, виконання програми екстрено завершиться. Тому при реалізації фіналізатора потрібно бути особливо акуратним: не звертатися до методів інших об'єктів, для яких міг бути викликаний фіналізатор; враховувати, що фіналізатор викликається окремому потоці; перевіряти на null всі інші об'єкти, які потенційно могли набувати значення null . Останнє правило пов'язане з тим, що фіналізатор може бути викликанийоб'єкта у будь-якому його стані, навіть не до кінця проініціалізованому. Наприклад, якщо ви завжди привласнюєте в конструкторі новий об'єкт у полі класу і потім очікуєте, що у фіналізаторі він завжди повинен бути не дорівнює null і звертаєтесь до нього, то можна отримати NullReferenceException, якщо при створенні об'єкта в конструкторі базового класу виник виняток і до виконання вашого конструктора справа не дійшла.
- Фіналізатор може бути взагалі виконаний. При екстреному завершенні програми, наприклад, при виникненні виключення в чужому фіналізаторі з причин, описаних у попередньому пункті, решта фіналізаторів не буде виконана. Якщо ви у фіналізаторі звільняєте некеровані об'єкти операційної системи, то нічого поганого не станеться в тому сенсі, що при завершенні програми система сама поверне свої ресурси. Але якщо ви скидаєте недозаписані байти у файл, ви втратите свої дані. Так що можливо краще не реалізовувати фіналізатор, а завжди допускати втрату даних, якщо забули викликати Dispose() , тому що в цьому випадку проблему буде простіше виявити.
- Потрібно пам'ятати про те, що фіналізатор викликається лише один раз і якщо ви воскрешаєте об'єкт у фіналізаторі шляхом привласнення посилання на нього в інший живий об'єкт, то можливо, вам слід зареєструвати його для фіналізації заново за допомогою методу GC . ReRegisterForFinalize() .
- Ви можете нарватися на проблеми багатопотокових додатків, наприклад, стан гонки, навіть якщо ваш додаток однопотоковий. Випадок зовсім екзотичний, але теоретично можливий. Допустимо у вашому об'єкті є фіналізатор, і на нього тримає посилання інший об'єкт, у якого теж є фіналізатор. Якщо обидва об'єкти стають доступними для збирача сміття, їх фіналізатори починаютьвиконуватися та інший об'єкт воскрешається, то він і ваш об'єкт знову стають живими. Тепер можлива ситуація, коли метод вашого об'єкта буде викликаний з основного потоку та одночасно з фіналізатора, оскільки він, як і раніше, залишився в черзі об'єктів, готових до фіналізації. Код, який відтворює цей приклад, наведено нижче. Можна побачити як спочатку виконується фіналізатор об'єкта Root, потім фіналізатор об'єкта Nested, і після цього метод DoSomeWork() викликається відразу з двох потоків.
Ось що буде виведено на екран на моїй машині:
Якщо у вас фіналізатори викликаються в іншому порядку, спробуйте поміняти місцями створення nested і root.
Фіналізатори в .NET - це те місце, де найпростіше вистрілити собі в ногу. Перш ніж кидатися додавати фіналізатори для всіх класів, що реалізують IDisposable, варто подумати, а чи дійсно вони так потрібні. Слід зазначити, що й самі розробники CLR застерігають від їх використання на сторінці Dispose Pattern: "Avoid making types finalizable. Carefully consider any case in which you think a finalizer is needed. performance and code complexity standpoint."
Але якщо ви все-таки вирішили використати фіналізатори, то PVS-Studio може допомогти вам знайти потенційні помилки. У нас є діагностика V3100, яка покаже всі місця у фіналізаторі, де може виникнути NullReferenceException.

Знайдіть помилки у своєму C, C++, C# та Java коді
Пропонуємо перевірити код вашого проекту за допомогою аналізатора коду PVS-Studio. Одна знайдена у ньому помилка скаже вам про користь методології статичного аналізу коду більше десятка статей.