Чому вам не слід використовувати финализаторы

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



Введення
Зазвичай перше знайомство з финализаторами у .NET розробників відбувається, коли їм потрібно звільнити некерований ресурс. Виникає питання, що ж потрібно використовувати: реалізувати у своєму класі IDisposable або додати финализатор? Тоді вони йдуть, наприклад, на StackOverflow і читають відповіді на питання типу цього Finalize/Dispose pattern in C# де розповідається про класичний патерн реалізації IDisposable у поєднанні з визначенням финализатора. Той же самий патерн можна знайти і в MSDN в описі інтерфейсу IDisposable. Деякі вважають його досить складним для розуміння і пропонують свої варіанти на кшталт реалізації очищення керованих і некерованих ресурсів в окремих методах або створення класу-обгортки спеціально для звільнення некерованого ресурсу. Їх можна знайти на тій же сторінці на StackOverflow.

Більшість цих способів припускають реалізацію финализатора. Подивимося які плюси і потенційні проблеми це може принести.

Плюси і мінуси використання финализаторов
Плюси.

  1. Финализатор дозволяє провести очищення об'єкта перед тим як він буде видалений складальником сміття. Якщо розробник забув викликати у об'єкта метод Dispose(), то в финализаторе можна звільнити некеровані ресурси і таким чином уникнути їх витоку.
Мабуть, все. Це єдиний плюс, так і те спірне, про що нижче.

Мінуси.
  1. Фіналізація недетерминированна. Ви не знаєте, коли буде викликаний финализатор. Перш ніж CLR почне фіналізувати об'єкти, збирач сміття повинен помістити їх в чергу об'єктів, готових до фіналізації, коли запуститься чергова збірка сміття. А цей момент не визначений.

  2. У зв'язку з тим, що об'єкт з финализатором не видаляється складальником сміття відразу, він і весь граф пов'язаних з ним об'єктів переживають збирання сміття і потрапляють в наступне покоління. Вони будуть видалені тепер тоді, коли збирач сміття вирішить зібрати об'єкти цього покоління, що може статися дуже нескоро.

  3. Так як финализаторы виконуються в окремому потоці паралельно роботі інших потоків додатки, то може виникнути ситуація, коли нові об'єкти, що потребують фіналізації, можуть створюватися швидше, ніж будуть відпрацьовувати финализаторы старих об'єктів. Це призведе до збільшення споживаної пам'яті, зниження продуктивності і, можливо, в підсумку до падіння програми OutOfMemoryException. Причому на машині розробника ви можете ніколи і не зіткнутися з цією ситуацією, наприклад, тому що у вас менша кількість процесорів і об'єкти створюються повільніше або додаток працює не так довго, як в бойових умовах, і пам'ять не встигає закінчитися. Можна витратити дуже багато часу на те щоб зрозуміти, що причина була в финализаторах. Цей мінус, мабуть, перекриває переваги єдиного плюса.

  4. Якщо при виконанні финализатора виникне виняток, то виконання програми екстрено завершиться. Тому при реалізації финализатора потрібно бути особливо обережним: не звертатися до методів інших об'єктів, для яких вже міг бути викликаний финализатор; враховувати, що финализатор викликається в окремому потоці; перевіряти на null всі інші об'єкти, які потенційно могли приймати значення null. Останнє правило пов'язано з тим, що финализатор може бути викликаний для об'єкта у будь-якому його стані, навіть не до кінця проинициализированном. Наприклад, якщо ви завжди привласнюєте в конструкторі новий об'єкт у полі класу і потім очікуєте, що финализаторе він завжди повинен бути не дорівнює null і звертаєтеся до нього, то можна отримати NullReferenceException, якщо при створенні об'єкта в режимі конструктора базового класу виняток і до виконання вашого конструктора справа не дійшла.

  5. Финализатор може бути взагалі не виконано. При екстреному завершенні програми, наприклад, при виникненні виключення в чужому финализаторе з причин, описаних у попередньому пункті, всі інші финализаторы не будуть виконані. Якщо ви финализаторе звільняєте некеровані об'єкти операційної системи, то нічого поганого не станеться в тому сенсі що при завершенні програми система сама поверне свої ресурси. Але якщо ви скидаєте недозаписанные байти в файл, то ви втратите свої дані. Так що можливо краще не реалізовувати финализатор, а завжди допускати втраті даних у разі якщо забули викликати Dispose(), так як в цьому випадку проблему буде простіше виявити.

  6. Потрібно пам'ятати про те, що финализатор викликається тільки один раз і якщо ви воскрешаете об'єкт в финализаторе шляхом присвоювання посилання на нього в інший живий об'єкт, то можливо вам слід зареєструвати його для фіналізації заново за допомогою методу GC.ReRegisterForFinalize().

  7. Ви можете нарватися на проблеми багатопоточних додатків, наприклад, стан гонки, навіть якщо ваш додаток однопотоковий. Випадок зовсім вже екзотичний, але теоретично можливий. Припустимо у вашому об'єкті є финализатор, і на нього тримає посилання інший об'єкт, у якого теж є финализатор. Якщо обидва об'єкти стають доступними для складальника сміття, і їх финализаторы починають виконуватися і інший об'єкт воскрешается, то він і ваш об'єкт знову стають живими. Тепер можлива ситуація, коли метод вашого об'єкта буде викликаний з основного потоку і одночасно з финализатора, так як він як і раніше залишився в черзі об'єктів, готових до фіналізації. Код, який відтворює цей приклад, приведений нижче. Можна побачити як спочатку виконується финализатор об'єкта Root, потім финализатор об'єкта Nested, і після цього метод DoSomeWork() викликається відразу з двох потоків.
Код прикладу
class Root
{
public volatile static Root StaticRoot = null;
public Nested Nested = null;

~Root()
{
Console.WriteLine("Finalization of Root");
StaticRoot = this;
}
}
class Nested
{
public void DoSomeWork()
{
Console.WriteLine(String.Format(
"Thread {0} enters DoSomeWork",
Thread.CurrentThread.ManagedThreadId));
Thread.Sleep(2000);
Console.WriteLine(String.Format(
"Thread {0} leaves DoSomeWork",
Thread.CurrentThread.ManagedThreadId));
}
~Nested()
{
Console.WriteLine("Finalization of Nested");
DoSomeWork();
}
}

class Program
{
static void CreateObjects()
{
Nested nested = new Nested();
Root root = new Root();
root.Nested = nested;
}
static void Main(string[] args)
{
CreateObjects();
GC.Collect();
while (Root.StaticRoot == null) { }
Root.StaticRoot.Nested.DoSomeWork();
Console.ReadLine();
}
}

Ось що буде виведено на екран на моїй машині:

Finalization of Root
Finalization of Nested
Thread 10 enters DoSomeWork
Thread 2 enters DoSomeWork
Thread 10 leaves DoSomeWork
Thread 2 leaves DoSomeWork

Якщо у вас финализаторы викликаються в іншому порядку, спробуйте поміняти місцями створення nested root.

Висновки
Финализаторы в .NET — це те місце, де найпростіше вистрілити собі в ногу. Перш ніж кидатися додавати финализаторы для всіх класів, що реалізують IDisposable, варто подумати, а чи справді вони так потрібні. Треба зазначити, що і самі розробники CLR застерігають від їх використання на сторінці Dispose Pattern: «Avoid making types finalizable. Carefully consider any case in which you think a finalizer is needed. There is a real cost associated with instances with finalizers, from both a performance and code complexity standpoint.»

Але якщо ви все-таки вирішили використовувати финализаторы, то PVS-Studio може допомогти вам знайти потенційні помилки. У нас є діагностика V3100, яка покаже всі місця в финализаторе, де може виникнути NullReferenceException.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.