Пара историй про отличия Release от Debug

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

    История 1



    Собственно, началось все с того, что пришел баг о том что при некоторых операциях приложение вылетает. Это бывает часто. Баг не захотел воспроизводиться в Debug-версии. Это порой бывает. Поскольку в приложении часть библиотек была написано на C++, то первой мыслью было что-то вроде «где-то забыли переменную проинициализировать или что-то в этом духе». Но на деле суть бага крылась в управляемом коде, хотя без неуправляемого тоже не обошлось.

    А код оказался примерно следующим:

    class Wrapper : IDisposable
      {
        public IntPtr Obj {get; private set;};

        public Wrapper()
        {
          this.Obj = CreateUnmObject();
        }

        ~Wrapper()
        {
          this.Dispose(false);
        }

        protected virtual void Dispose(bool disposing)
        {
          if (disposing)
          {
          }

          this.ReleaseUnmObject(this.Obj);
          this.Obj = IntPtr.Zero;
        }

        public void Dispose()
        {
          this.Dispose(true);
          GC.SuppressFinalize(this);
        }
      }


    * This source code was highlighted with Source Code Highlighter.

    В принципе, практически каноническая реализация шаблона IDisposable («практически» — потому, что нет переменной disposed, вместо нее обнуление указателя), вполне стандартный класс-обертка неуправляемого ресурса.

    Использовался же класс примерно следующим образом:
    {
      Wrapper wr = new Wrapper();
      calc.DoCalculations(wr.Obj);
    }


    * This source code was highlighted with Source Code Highlighter.

    Естественно, что внимательный читатель сразу обратит внимание, что объекта wr надо вызвать Dispose, то есть обернуть все конструкцией using. Но на первый взгляд, на причину падения это не должно повлиять, так как разница будет в том детерминировано ли очистится ресурс или нет.

    Но на самом деле разница есть и именно в релизной сборке. Дело в том, что объект wr становится доступным сборщику мусора сразу после начала выполнения метода DoCalculations, ведь больше нет ни одного «живого» объекта, кто на него ссылался бы. А значит wr вполне может(а так оно и происходило) быть уничтожен во время выполнения DoCalculations и указатель, переданный в этот метод становится невалидным.

    Если обернуть вызов DoCalculations в using (Wrapper wr = new Wrapper()){...}, то это решит проблему, поскольку вызов Dispose в блоке finally, не даст жадному сборщику мусора «съесть» объект раньше времени. Если же по какой-то причине мы не можем или не хотим вызывать Dispose (к примеру WPF этот шаблон совсем не жалует), то придется вставлять GC.KeepAlive(wr) после вызова DoCalculations.

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

    Почему же ошибка проявлялась только в Release-версии, и то запущенной не из-под студии(если присоединить отладчик в процессе выполнения, то ошибка будет повторяться)? Потому что в противном случае для всех локальных ссылочных переменных добавляются якоря, чтобы они доживали до конца текущего метода, сделано это явно ради удобства отладки.

    История 2



    Жил-был проект, где для доступа к ресурсам использовался менеджер, который по строковому ключу доставал из заданной сборки различного вида ресурсы. С целью облегчения написания кода был написан следующего вида метод:
    public string GetResource(string key)
        {
          Assembly assembly = Assembly.GetCallingAssembly();
          return this.GetResource(assembly, key);
        }


    * This source code was highlighted with Source Code Highlighter.


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

    Чтобы «почувствовать разницу» предлагается следующий пример:

      dll1:
      public class Class1
      {
        public void Method1()
        {
          Console.WriteLine(new StackTrace());
        }
      }

      dll2:
      public class Class2
      {
        public void Method21()
        {
          this.Method22();
        }

        public void Method22()
        {
          (new Class1()).Method1();
        }
      }

      dll3:
      class Program
      {
        static void Main(string[] args)
        {
          (new Class3()).Method3();
        }
      }
      class Class3
      {
        public void Method3()
        {
          (new Class2()).Method21();
        }
      }


    * This source code was highlighted with Source Code Highlighter.


    Если скомпилировать в дебажной конфигурации(или если запускать процесс из-под студии) то получим честный стек вызовов:
    в ClassLibrary1.Class1.Method1()
    в ClassLibrary2.Class2.Method22()
    в ClassLibrary2.Class2.Method21()
    в ConsoleApplication1.Class3.Method3()
    в ConsoleApplication1.Program.Main(String[] args)

    Если собрать под .Net версии до 3.5 включительно в релизе:
    в ClassLibrary1.Class1.Method1()
    в ClassLibrary2.Class2.Method21()
    в ConsoleApplication1.Program.Main(String[] args)

    А под .Net 4 в релизной конфигурации то и вовсе получим:
    в ConsoleApplication1.Program.Main(String[] args)

    Мораль здесь проста — не стоит привязывать логику к стеку вызовов, равно как и удивляться необычному стеку в исключениях в логе релизной версии. В частности, если вы пытаетесь найти причину исключения исключительно по его стеку вызовов, то стоит учитывать, что если стек заканчивается на методе Method1, то в коде оно(исключение) могло быть сгенерировано в одном из небольших методов, которые вызываются в теле Method1.

    Так же на всякий случай стоит помнить, что можно запретить компилятору встраивать метод пометив его атрибутом [MethodImpl(MethodImplOptions.NoInlining)], эдакий аналог __declspec(noinline) в VC++.

    Вместо заключения



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

    Similar posts

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

    More

    Comments 27

      +1
      Круто, спасибо, что поделились опытом!

      В первом примере не совсем понятно, почему wr становится доступным сборщику мусора сразу же после вызова calc.DoCalculations(). DoCalculations() вызывает unmanaged код, да еще и асинхронно? В таком случае, стоило бы об этом упомянуть.
      Или, возможно, я чего-то недопонял :)
        0
        calc.DoCalculations() действительно вызывает неуправляемый код (а зачем ему еще указатель?), но все происходит синхронно. А wr попадает к сборщику мусора потому что грубо говоря больше нигде не используется, это не C++, где область видимости переменной ограничена парой скобок. В коде
        0{
        1 Obj obj= new Obj();
        2 obj.Method();
        3 this.Method()
        4}

        объект obj может попасть к сборщику мусора уже после выполнения второй строчки.
          0
          Но появляется новая ссылка на объект внутри метода?
            0
            Внутрь метода передается только объект типа IntPtr, у него нет ссылок на исходный объект wr.
            0
            Почитал хорошую статью, посвященную именно этой проблеме. Разобрался.
            Дело в том, что объект wr становится доступным сборщику мусора сразу после начала выполнения метода DoCalculations, ведь больше нет ни одного «живого» объекта, кто на него ссылался бы.
            Тут я бы посоветовал написать, что wr становится доступным для сборки мусора сразу после вызова unmanaged кода, и только при условии, что дальше wr нигде не используется внутри DoCalculations().
            Вообще говоря, пример был бы намного понятнее, если бы DoCalculations() было как раз вызовом unmanaged кода, а не методом, содержащим такие вызовы.
              0
              Вот с этим соглашусь, все выглядит лаконично и верно!
                +1
                В приведенной Вами статье описывается несколько иной (хотя и тоже интересный) случай — когда уничтожается объект, у которого вызывается метод. Здесь же на самом деле не суть как важно, что метод содержит неуправляемый код, главное что ему просто нужен валидный указатель.

                >то wr становится доступным для сборки мусора сразу после вызова unmanaged кода, и только при условии, что дальше wr нигде не используется внутри DoCalculations()

                Так DoCalculations и не имеет ссылки на wr, ему передается wr.Obj только.
                  0
                  Понятно, спасибо. Показалось, что параметром подавался весь wr. Меня ввело в заблуждение название класса: Wrapper. Кстати, непонятно, зачем создавать объект-обертку, если в метод подается только его проперти obj :)
                  Между прочим, не означает ли это, что obj может использоваться отдельно от Wrapper, и, следовательно, не принадлежит ему? Возможно, здесь проблема вообще из-за ошибки проектирования и Wrapper не должен очищать obj, так как связан с ним не композитивно, а аггрегативно.
                    0
                    Wrapper очищает Obj, потому как является его владельцем, он создает его, он его же и удаляет. Собственно Wrapper по сути дела является аналогом smart pointer, объект который просто хранит ресурс, и уничтожаясь сам уничтожает и ресурс. Поэтому тут как раз все нормально, просто программист, который писал код не подумал о том, что wr может быть собран сборщиком мусора до конца метода.
                    По поводу того, почему передается не обертка, а сам IntPtr — просто в коде реального приложения был метод принимающий именно IntPtr, и протянуть туда обертку простыми средствами было невозможно
              0
              +100500, по примеру не понятно, как такое может произойти!
              +4
              Спасибо, познавательно.

              А как решили проблему во второй истории?
                0
                Пришлось сделать дженериковый метод, в котором в качестве дженерикового параметра передавать тип из сборки, где лежат ресурсы. А по типу уже можно однозначно и правильно достать сборку.
                +5
                вот все бы так писали примеры: коротко, ясно и всё по делу. Без лишнего кода. Спасибо
                  +1
                  Первый пример уже практически классика. Он описан у Рихтера. А вот за второй пример — большое спасибо.

                  У меня, кстати, есть «околоплодный» вопрос, на который сам никак не найду ответ. А как всё-таки правильно поставить процесс разработки, чтобы отслеживать ошибки такой природы? Понимаю, что вопрос несколько нубский, но тем не менее. Мы на нашем проекте, к моему стыду, просто не выпускаем продукта в Release-варианте. Я знаю, что это как минимум «не очень хорошо», но раз такая возможность у нас есть, мы решили не усложнять себе жизнь с Release/Debug версиями.
                    +3
                    Мне кажется, что кроме как «думать головой», делать ничего не остается :) Можно еще привлекать дополнительные головы посредством (обязательного) ревью кода.
                      0
                      Не воспринимайте лично, но это какой-то нехороший ответ. То есть даже не так. Я сам себе не позволяю так отвечать и регулярно думаю про описанную проблему. Дело в том, что «думать головой», конечно надо всегда (в этом я убеждён), просто нет возможности это обеспечить с гарантиями. Кроме того, даже если «думать головой постоянно», ошибок всё равно не избежать. В конце концов, я — человек и только и делаю, что ошибаюсь (ну, в перерывах между правильными решениями :) ).

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

                      Надеюсь, мой юношеский максимализм в стремлении к надёжному коду не сильно прёт из меня. :)
                        +3
                        Если говорить о практиках выявления ошибок, то целенаправленно тестируют обычно только на наличие самых распространенных ошибок. Остальные могут выявиться хорошо поставленным процессом blackbox-тестирования.
                        Что до предотвращения ошибок, то здесь рулит опыт разработчиков.

                        Между прочим, еще можно попробовать использовать анализаторы кода навроде FxCop.
                      0
                      К сожалению, не существует «серебряной пули». И если число некоторого вида ошибок можно уменьшить соблюдая некоторые правила, то тут помочь может разве что опыт и внимательность программиста. Ну и стоит заметить, что совсем уж частыми вышеприведенные случаи назвать сложно.
                      0
                      Я так понимаю, что если обернуть Wrapper wr в using, то тем самым мы гарантируем существование объекта до конца блока using (т.к. в конце блока автоматически вызывается Dispose, вызов которого требует наличие объекта). То есть до конца блока функции не гарантируется, что объект не будет финализирован, а внутри блока using гарантируется, что объект не будет финализирован до конца блока. То есть конструкция using { x = new x();} разворачивается не в конструкцию
                      try { x = new x(); } finally { x.Dispose(); }
                      а в конструкцию
                      try { x = new x(); } finally { x.Dispose(); GC.KeepAlive(x); }

                      Это верно?
                        +2
                        Нет, это не так. Using в анном случае даст следующий код:
                        try { x = new x(); } finally {if (x != null) ((IDisposable) x).Dispose(); }

                        То, что к объекту есть обращение в блоке finally автоматически сохраняет его в графе достижимых объектов до конца выполнения try-блока, поэтому вызов GC.KeepAlive(x) не нужен.
                          0
                          Любое использование объекта делает недоступным его для сборщика мусора.
                          Т.е. x.Dispose() и вообще x.Something() не даст уничтожить объект до этого метода.
                            +1
                            В релизной версии в общем случае это может быть не совсем правдой. Компилятор может заинлайнить вызов метода x.Something(), и если внутри нет использования this (то есть метод по сути статический), то объект может быть уничтожен до вызова этого метода, если других якорей нет.
                              0
                              В этом коменте рекомендуется статья, которая говорит, что это не совсем так. Насколько я из нее понял, объект может быть финализирован даже во время выполнения его же метода, если в этом методе осуществляется обращение к неуправляемому коду.
                            0
                            Первый пример канонический, из Рихтера. Я не понимаю, как можно вообще заниматься разработкой, не зная эту книгу как Отче Наш!
                              0
                              Из какого издания? Я читал только по второму фреймворку и года три назад, такого примера там не помню.
                                0
                                Точно там есть, там где про корни. Нет второго издания под рукой, точнее не могу сказать. Но точно есть.
                                  0
                                  Значит я успел позабыть с тех пор (:

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