Пара історій для відмінності Release від Debug

Всі розробники знають, що виконання релізної версії може відрізнятися від налагоджувальної. У цій статті я розповім кілька випадків із життя, коли такі відмінності призводили до помилкового виконання програми. Приклади не відрізняються великою складністю, але можуть уберегти від наступу на граблі.

Власне, почалося все з того, що прийшов баг про те, що при деяких операціях додаток вилітає. Це часто буває. Баг не захотів відтворюватись у Debug-версії. Це часом буває. Оскільки у додатку частина бібліотек була написана на C++, то першою думкою було щось на кшталт «десь забули змінну проініціалізувати чи щось у цьому дусі». Але насправді суть бага крилася в керованому коді, хоча без некерованого теж не обійшлося.

А код виявився приблизно наступним:

public Wrapper() this .Obj = CreateUnmObject(); >

Wrapper() this .Dispose( false ); >

protected virtual void Dispose( bool disposing) if (disposing) >

this .ReleaseUnmObject( this .Obj); це .Obj = IntPtr .Zero; >

public void Dispose() this .Dispose( true ); GC.SuppressFinalize( this ); > >

* Цей source code був highlighted with Source Code Highlighter.

У принципі, практично канонічна реалізація шаблону IDisposable («практично» — тому, що немає змінної disposed, замість неї обнулення покажчика), цілком стандартний клас-обгортання некерованого ресурсу.

Використовувався клас приблизно так:

* Цей source code був 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-версії, і то запущеної не з-під студії (якщо приєднати відладчик у процесі виконання, то повторюватиметься помилка)? Тому що в іншому випадку для всіх локальних посилальних змінних додаються якоря, щоб вони доживали до кінця поточного методу, зроблено це задля зручності налагодження.

Жив-був проект, де для доступу до ресурсів використовувався менеджер, який за рядковим ключем діставав із заданого збирання різного виду ресурси. З метою полегшення написання коду було написано такий спосіб:

public string GetResource( string key) Assembly assembly = Assembly .GetCallingAssembly(); return this .GetResource(assembly, key); >

* Цей source code was highlighted withSource 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(); > >

* Цей source code був 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++.

Замість ув'язнення

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

Хардкорна конфа за С++. Ми запрошуємо лише профі.