Как тестировать код финализатора (c#)

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


    Например, есть классс MyTemporaryFile (временный файл), который создает уникальный временный файл в конструкторе и должен удалять его в Dipose() или в финализаторе.

        public class MyTemporaryFile : IDisposable
        {
            public string FileName { private set; get; }
            public MyTemporaryFile()
            {
                FileName = Path.GetTempFileName();
            }
    
            public void Dispose()
            {
                Dispose(true);
            }
            ~MyTemporaryFile()
            {
                Dispose(false);
            }
    
            void Dispose(bool disposing)
            {
                if (disposing)
                {
                    GC.SuppressFinalize(this);
                }
                DeleteFile();
            }
            void DeleteFile()
            {
                if (FileName != null)
                {
                    File.Delete(FileName);
                    FileName = null;
                }
            }
        }
    
    


    Реализация паттерна Dispose довольно стандартная и обсуждалась на Хабре. Наверняка есть в данной реализации некоторые тонкие места, поэтому в «настоящей» программе имейте это ввиду.

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

    Понятно, что «очень наивная» реализация теста работать не будет.

            [Test]
            public void TestMyTemporaryFile_without_Dispose()
            {
                var temporaryFile = new MyTemporaryFile();
                string createdTemporaryFileName = temporaryFile.FileName;
    
                Assert.IsTrue(File.Exists(createdTemporaryFileName));
    
                temporaryFile = null;
                
                Assert.IsFalse(File.Exists(createdTemporaryFileName));
            }
    

    Дело в том, что присвоение null переменной temporaryFile не вызывает финализатор.

    Встречался совет вызывать GC.WaitForPendingFinalizers();, но почему то в данном тесте мне это не помогло.

    offtopic: Когда то давно на какой то лекции по c# рассказывали про AppDomain. Я тогда не очень понимал зачем мне это надо. Ну вы знаете, как большинство лекторов рассказывают для "некого среднего слушателя" "некие общие вещи". Я ни разу не смог понять паттерн Dispose со слов лектора. Самое смешное, что после того, как я стал его чуть чуть понимать, я с трудом стал догадываться, что лектор таки имеет ввиду.

    Так вот, оказывается, что с помощью AppDomain можно легко приготовить тест для кода финализатора:
            [Test]
            public void TestTemporaryFile_without_Dispose()
            {
                const string DOMAIN_NAME = "testDomain";
                const string FILENAME_KEY = "fileName";
    
                string testRoot = Directory.GetCurrentDirectory();
    
                AppDomainSetup info = new AppDomainSetup
                    {
                        ApplicationBase = testRoot
                    };
                AppDomain testDomain = AppDomain.CreateDomain(DOMAIN_NAME, null, info);
                testDomain.DoCallBack(delegate
                {
                    MyTemporaryFile temporaryFile = new MyTemporaryFile();
                    Assert.IsTrue(File.Exists(temporaryFile.FileName));
                    AppDomain.CurrentDomain.SetData(FILENAME_KEY, temporaryFile.FileName);
                });
                string createdTemporaryFileName = (string)testDomain.GetData(FILENAME_KEY);
                Assert.IsTrue(File.Exists(createdTemporaryFileName));   
                AppDomain.Unload(testDomain);       // выгружается код и очищается вся память (вызывается финализатор), файл удаляется
    
                Assert.IsFalse(File.Exists(createdTemporaryFileName)); 
            }
    


    Как известно, AppDomain.Unload(testDomain); выгружает код и очищает память (в том числе и вызываются финализаторы).
    Это и помогает «насильно вызвать» финализатор и, соответсвенно, протестировать его код.

    Примечания:
    1. Один из лекторов советовал в финализаторе выкидывать исключительную ситуацию (exception) со словами "вызови Dispose, идиот". Где то он может быть и прав, но если есть unmanaged ресурс, надо предусмотреть и финализатор тоже.
    2. Реализация класса MyTemporaryFile очень схематична и не рекомендуется для продакшен использования.
    3. Скорей всего реализация данного теста, тоже имеет всякие тонкие моменты, но многолетняя практика ни разу не зафиксировала ложное срабатывание этого теста.
    4. С удовольствием почитаю, как можно решить задачу тестирования финализатора другими способами или какие есть недостатки у данного подхода.

    Спасибо,
    Игорь.
    Share post

    Similar posts

    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 65

      +14
      GC.WaitForPendingFinalizers() у вас не срабатывает, потому что сам объект еще не собран сборщиком мусора.

      Правильный код может быть таким:

      temporaryFile = null;
      GC.Collect();
      GC.WaitForPendingFinalizers();
      

        +1
        точно: я забыл написать
        temporaryFile = null;
        

        рука отказалось такое писать :).

        да, в таком случае этот способо гораздо более прост.
        кстати, ведь не советуют вызывать GC.Collect(); или GC.WaitForPendingFinalizers();
        утверждают, что это все равно только просьба к сборщику мусора, а когда он на самом деле захочет делать работу неизвестно.
        или запутывают?
          +4
          Это не просьба, а повеление.
          Другое дело, что объекты, на которые есть ссылки, не будут собраны, но думаю, это и так понятно.
          По поводу не вызывать — действительно, лучше не вызывать в реальном коде. Но мы ведем речь о тестах, здесь немного более широкие рамки допустимого
            0
            Убедили. Так что теперь у меня есть два способа тестировать финализатор.
            Спасибо!
              0
              И это не панацея. Сборщик мусора может решить, что чистить память совершенно необязательно. Чтобы отучить его думать самостоятельно, надо вызвать перегруженный метод Collect с GCCollectionMode.Forced.
              Более того, даже подобный вызов у меня в 1 случае из 100, наверное, не помогает.
                0
                Смотрим описание метода:
                Collect() Forces an immediate garbage collection of all generations

                Это то же самое, что и с флагом GCCollectionMode.Forced ( по крайней мере, в текущих версиях CLR)

                То, что вызов не срабатывает — плохо, ищите проблему в своем коде.
                Финализаторы в любом случае должны отработать по завершению программы

            0
            Кстати, это только в DEBUG режиме нужно null присваивать, или в RELEASE тоже?
            Ведь если нет ссылки на объект, то вроде бы зачем присваивать null?
              0
              В любом режиме нужно, если вы хотите гарантировать, что ссылки на объект больше нет.
              Область видимости переменной temporaryFile — до конца метода, поэтому сохраненная в нем ссылка может существовать на любом промежутке от последнего присваивания до закрывающей фигурной скобки.
              Чтоб гарантировать, что ссылки больше нет, необходимо присвоить null или другое значение, таким образом потеряется старое, и сборщик мусора будет вправе удалить такой объект
                0
                А как же все эти трюки, что таймер (созданный в main) запущенный в DEBUG режиме работает до конца программы, но в RELEASE режиме умирает довольно быстро?
                  0
                  Это именно трюки, т.к. точное время сборки объекта никто не гарантирует.
                  Как я уже сказал, ссылка будет потеряна не раньше, чем ее последнее использование. В релизе производятся более агрессивные оптимизации, поэтому ссылка может быть потеряна сразу же (но гарантии никакой нет).
                  Если вы хотите продлить жизнь ссылки, посмотрите на GC.KeepAlive()
                    +1
                    При компиляции с параметром debug+ (или наличии атрибута сборки System.Diagnostics.DebuggableAttribute) JIT считает что время жизни локальной переменной — до завершения метода. В противном случае, JIT оптимизирует код, а GC, не найдя ссылок на переменную, выполнит финализацию.
                –3
                В первый вызов GC.Collect() будут собраны только те объекты, у которых нет финализатора. Затем во втором вызове вы принуждаете финализаторы отработать. И вот тут нужно еще раз вызвать GC.Collect(), чтобы сборщик собрал объекты, финализаторы которых отработали. Итого:

                temporaryFile = null;
                GC.Collect();
                GC.WaitForPendingFinalizers();
                GC.Collect();
                  +5
                  Последняя строчка не нужна — наша задача не собрать всю память, а запустить финализатор.
                    0
                    Ох да, вы правы. Не подумал.
                0
                и кстати, совет с исключением в финализаторе хороший, годный, по крайней мере в дебаге (в релизе лучше все-таки тихо вызвать Dispose)
                кажется, встречал подобный совет еще у Рихтера
                  +2
                  В финализаторе исключения игнорируются.
                  0
                  Совет был писать нечто вроде Debug.WriteLine(«AAA!»)…
                    0
                    Этот совет подходит не для всех объектов и не для всех случаев. Например, если среднее время работы программы — 100мс, то большинство объектов можно вообще не закрывать и даже не собирать — ОС все сама почистит после завершения процесса.
                      0
                      Если ваша ОС — Windows, то она в любом случае почистит все после завершения процесса.
                      Вопрос не в том, чтобы «все почистить», а в том, чтобы писать корректный код, который «убирает за собой».
                      Финализатор просто помогает найти те объекты, которые не были обернуты в using, например
                        –1
                        Зачем писать корректный код, который «убирает за собой», дублируя функциональность ОС?
                          +2
                          Можно назвать несколько причин:
                          1. Это хорошая привычка. Не всегда вы пишите программы, которые потребляют мало ресурсов и поэтому можно мусорить где угодно
                          2. Компонентная архитектура. Сегодня ваш код используется в консольном приложении, а завтра — в высоконагруженном сервисе, где ресурсы можно и нужно освобождать побыстрее
                          3. Глубокое понимание системы. По моему опыту, программисты, которые не следят за ссылками/указателями/ресурсами поступают так не потому, что знают что-то о механизмах завершения процесса, а потому что просто не способны на это
                          4. Эстетическая составляющая. У каждой открывающей скобки должна быть закрывающая, а у каждого конструктора — деструктор
                            0
                            Эти все причины — лишь рекомендации. Я все же думаю, что не стоит запрещать программисту бросать объекты без вызова Dispose в короткоживущих процессах.

                            Кстати, вот придумал только что еще одну причину не бросать исключение в деструкторе, даже в DEBUG:
                            -2. Программист может пожелать сделать из intermediate window пару вызовов следующего плана: new SomeCriticalObject(1, 2, "foo.txt").Bar(); Но синтаксис intermediate window не позволяет сделать еще и Dispose без сохранения объекта в промежуточную переменную — а удобной переменной под рукой может и не оказаться.
                              –1
                                   new SomeCriticalObject(1, 2, "foo.txt").Bar();
                              

                              это не должно быть причиной. Такой интерфейс надо поменять и получить

                                   SomeCriticalObject.StaticBar(1,2,"foo.txt")
                              


                              А внутри него все вполне можно за собой убрать.
                                0
                                Для добавления нового метода придется остановить отладку.

                                PS в прошлом сообщении была ошибка. immediate window. Но, судя, по отсутствию вопросов, все все поняли.
                            –1
                            GC не гарантирует вызов финализатора при завершении приложения.

                            stackoverflow.com/questions/9941688/arent-destructors-guaranteed-to-finish-running
                              0
                              Это еще одна причина, по которой удаление временного файла следует делать по возможности системными средствами, а не в финализаторе.
                            0
                            Временный файл, созданный на диске, не будет «почищен».
                              0
                              Речь идет не о временных файлах, а о т.н. хендлах — идентификаторах объектов ядра, и о виртуальной памяти
                                0
                                А FILE_FLAG_DELETE_ON_CLOSE на что?
                                  0
                                  А без P/Invoke это можно как то сделать?
                                    0
                                    А чем вас P/Invoke не устраивает?
                                      0
                                      Тем что он платформо зависимый.
                                0
                                К тому же процессы иногда живут долго: в моем случае месяцами. Так что приходится очищать все что можно и до завершения процесса.
                                  0
                                  Прочтите, но вы ветку читали? Здесь сейчас обсуждается вот этот случай:
                                  Например, если среднее время работы программы — 100мс
                                    0
                                    Теперь заметил.
                            +1
                            К сожалению, AppDomainы работают слишком медленно.
                              +1
                              в данном случае это не критично: таких тестов обычно не много.
                                0
                                Как сказать. Я вот однажды тоже в свою программу добавил несколько операций с доменами приложений. Предполагалось, что пользователь будет работать с программой долго, и пары секунд накладных расходов в фоновом потоке никто не заметит.

                                Но уже через 2 месяца эксплуатации выяснилось, что программа «слишком долго закрывается» — в 80% случаях ее запускали ради нажатия одной кнопки.
                                  0
                                  Спасибо. Буду иметь ввиду. Но в даннм случае, домен использовался только для теста.
                              +6
                              Тестировать в вашем случае надо Dispose, а не финализатор (потому что в финализаторе у вас тривиальный код). Тогда и проблем будет намного меньше.
                                0
                                Именно так. Финализатор вообще не является контрактом класса, в отличии от Dispose.
                                  0
                                  и тем не менее я считаю нужным его тестировать
                                  0
                                  не совсем понимаю, почему я не должен тестировать финализатор: в нем есть какой то код (пусть и тривиальный). я хочу быть уверен, что используемый ресурс будет правильно «закрываться» в финализаторе, и что другой программист его не сломает.
                                    +5
                                    Потому что количество усилий на это непропорционально результату.
                                      +1
                                      в общем случае может быть, но в моем конкретном случае, несколько раз случались проблемы из за того, что ресурс не был освобожден, а тесты этого даже и не проверяли.
                                      в любом случае идея этого поста: как тестировать финализатор, когда это надо.
                                        0
                                        А Dispose у вас при этом был покрыт тестами?
                                          0
                                          Да. Реализация Dispose покрыта тестом.
                                            0
                                            Значит нужно тестировать, что у вас явно вызывается Dispose (не из финализатора, а из кода-потребителя). Не диспозить disposable — зло.
                                              +1
                                              Согласен на все 100%. Но никто не может мне гарантировать, что Dispose всегда будет вызываться. Следовательно, небольшое усилие (финализатор и его тест), по крайне мере, минимизуруют ущерб от не вызова Dispose.
                                                0
                                                Вопрос в том, что является небольшим усилием.

                                                Загрузка и опускание аппдомена в каждом тесте — это не «небольшое усилие».
                                                  0
                                                  Согласен. Но в данном случае я имел ввиду усилия программиста приготовить такой тест.
                                                  В любом случае, таких тестов (для финализатора) не так много.
                                                    0
                                                    Ну да, а еще нарушение принципа единого места ошибки — поскольку вы не можете проверить, был ли вызван Dispose, вы проверяете, выполнил ли Dispose свою работу, что приводит к дублированию проверяющего кода.
                                                      0
                                                      Не понимаю: я проверяю, что Dispose, когда он вызывается «закрывает» ресурс. С другой стороны, я проверяю, что когда Dispose не вызывается, то финализатор «закрывает» ресурс. Где тут дублирование, с учетом, что это не совсем unit тест?
                                                        +1
                                                        Дублирование в том, что вы дважды проверяете, что ресурс закрывается (и это может быть сложная проверка).

                                                        (кстати, ничто не мешает сделать это unit-тестом, в общем-то)
                                                          0
                                                          Интересное замечание.
                                                          Надо попробовать реализовать unit тест для данного примера.
                                                          Спасибо.
                                          +1
                                          Может лучше было потратить усилия на то чтобы тестировать вызов Dispose во всех местах где он должен вызываться?
                                            +1
                                            Хорошо бы. И я пытаюсь это делать. Но как я могу быть уверен на 100%, что Dispose всегда вызывается? Что он и завтра всегда будет вызываться?
                                              +1
                                              Так вы и на 100% не можете быть увереным что финализатор вообще вызовется. Платформа вам этого не гарантирует.
                                                0
                                                По крайней мере в тех случаях, когда финализатор вызывается, я буду уверен, что он делает то, что я от него ожидаю.
                                                И как я понимаю, в подавляющем числе случаев он все таки вызывается.
                                                  0
                                                  Так если вы протестируете логику Dispose, то автоматически протестируете и логику финализатора. А то, что вы делаете, так это проверяете, что финализатор срабатывает, т.е. пишете юнит-тест не на свою бизнес-логику, а на CLR. С таким подходом имеет смысл и такие тесты писать.
                                                  int i = 5
                                                  Assert.True(i == 5)
                                                    0
                                                    Не совсем, я проверяю, что финализатор вызывает нужный мне код.
                                    0
                                    Помоему, в старых версиях финализатор еще вызывался из своего высокоприоритетного потока, поэтому у ms еще был хитрый паттерн, если хочется использовать финализатор в связке с Dispose.
                                      0
                                      А в чем заключалась его хитрость-то?
                                      –1
                                      должен удалять его в Dipose()
                                      Наверное имелось ввиду Dispose()?

                                      Only users with full accounts can post comments. Log in, please.