Страницы

Поиск по вопросам

воскресенье, 1 декабря 2019 г.

Почему переполнение стека в дочернем потоке убивает весь процесс?

#c_sharp #net #исключения


Отвечая на этот вопрос был удивлен, что переполнение стека в дочернем потоке убивает
процесс целиком.

Собственно, вопрос:

А почему так происходит? Ведь каждый поток имеет собственный стек, даже, на сколько
я помню, Рихтер писал об этом.

Даже StackTrace().FrameCount показывает различное кол-во фреймов в основном потоке
и дочернем.

В С++- это обрабатываемое исключение, если верить комментариям из предыдущего вопроса,
а в .NET нет, так как возможно, как я понимаю, что СLR, которой нужно что-то сделать,
может повредится из-за не хватки стека.

CLR, по идее, одна на все приложение и она точно не крутится в дочернем потоке.

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

UPD

Есть какие-то "Области с ограничением выполнения" CER, где можно указать, что метод
может поверить процесс. Это никак не оказывает влияния на CLR, что бы она подготовилась
и не умерла?
    


Ответы

Ответ 1



По-моему тут накладываются друг на друга две особенности работы CLR. Необработанные исключения в дочерних потоках убивают процесс Не только переполнение стека, а вообще любое необработанное исключение в дочернем потоке убивает весь процесс: static void Main() { var thread = new Thread(Recursive); thread.Start(); while (true) { Console.WriteLine("I will live forever!"); Thread.Sleep(1000); } } static void Recursive() { throw new Exception("RIP Unnamed Process (2018-2018"); } Об это говорится в MSDN: Starting with the .NET Framework version 2.0, the common language runtime allows most unhandled exceptions in threads to proceed naturally. In most cases this means that the unhandled exception causes the application to terminate. [Спорную] мотивацию такого поведения дает Эрик Липперт: We cannot easily tell the difference between bugs which are missing handlers for vexing/exogenous exceptions, and which are bugs that have caused a program crash because something is broken in the implementation. The safest thing to do is to assume that every unhandled exception is either a fatal exception or an unhandled boneheaded exception. In both cases, the right thing to do is to take down the process immediately. This philosophy underlies the implementation of unhandled exceptions in the CLR. Way back in the CLR v1.0 days the policy was that an unhandled exception on the "main" thread took down the process aggressively, but an unhandled exception on a "worker" thread simply killed the thread and left the main thread running. (And an exception on the finalizer thread was ignored and finalizers kept running.) This turned out to be a poor choice; the scenario it leads to is that a server assigns a buggy subsystem to do some work on a bunch of worker threads; all the worker threads go down silently, and the user is stuck with a server that is sitting there waiting patiently for results that will never come because all the threads that produce results have disappeared. It is very difficult for the user to diagnose such a problem; a server that is working furiously on a hard problem and a server that is doing nothing because all its workers are dead look pretty much the same from the outside. The policy was therefore changed in CLR v2.0 such that an unhandled exception on a worker thread also takes down the process by default. You want to be noisy about your failures, not silent. Это означает что исключения в потоках нужно обрабатывать намертво, например так: static void SafeRecursive() { try { Recursive(); } catch (Exception e) { //должная обработка, запись в логи и уведомление администраторам //ха-ха, так никто не делает, просто глотаем и забываем о потоке } } StackOverflowException нельзя поймать Но особенность StackOverflowException в том, что его нельзя поймать, ни в дочернем потоке, ни где-либо еще: static void Main() { try { Recursive(); } catch (Exception) { //не получится Console.WriteLine("Catch!"); } } О чем сказано в документации исключения: Starting with the .NET Framework 2.0, you can’t catch a StackOverflowException object with a try/catch block, and the corresponding process is terminated by default. Consequently, you should write your code to detect and prevent a stack overflow. Документация ясно дает понять, что нигде в процессе переполнение стека возникать не должно, вообще нигде. Есть вырожденные случаи, в которых StackOverflowException все же можно обработать: если Вы сами загружаете CLR, то может получится ее восстановить; если StackOverflowException выбрасывается кодом. В .Net 1.0 переполнение стека можно было отловить, с версии 2.0 разработчики заняли жесткую позицию: «Переполнение стека — проблема программиста и его кода, а не CLR». Я не смог найти прямых указаний на то, по какой причине потребовалось внести изменения. Предполагаю, что восстановление после SOE было небеспроблемно и разработчики решили не тратить на эту задачу ресурсы. Вообще, обе эти особенности CLR вполне укладываются в пуританскую философскую позицию «мертвые программы не врут», которую также освещает Эрик Липперт: I am of the philosophical school that says that sudden, catastrophic failure of a software device is, of course, unfortunate, but in many cases it is preferable that the software call attention to the problem so that it can be fixed, rather than trying to muddle along in a bad state, possibly introducing a security hole or corrupting user data along the way. Обновление: Constrained Execution Regions Вопрос в комментариях: А "Области с ограничением выполнения" CER - это что такое? Вот там можно повесить атрибут, что есть возможность того, что будет поврежден процесс. Это на что-то влияет? Влияет. CER позволяет защититься от части ошибок, которые могли бы возникнуть при исполнении кода (ошибки загрузки классов, нехватки памяти) и корректно освободить ресурсы. Тем не менее, переполнение стека с помощью CER обработать не получится. Об этом пишет Рихтер (CLR via C#, Constrained Execution Regions): Note Even if all the methods are eagerly prepared, a method call could still result in a StackOverflowException. When the CLR is not being hosted, a StackOverflowException causes the process to terminate immediately by the CLR internally calling Environment.FailFast. When hosted, the PreparedConstrainedRegions method checks the stack to see if there is approximately 48 KB of stack space remaining. If there is limited stack space, the StackOverflowException occurs before entering the try block. Внимание: Даже если все методы были явно подготовлены, вызов метода все еще может привести к StackOverflowException. Если CLR не загружена извне, то StackOverflowException завершает процесс немедленно, вызывая Environment.FailFast. Если CLR загружена извне, то метод PreparedConstrainedRegions проверяет, осталось ли в стеке примерно 48КБ свободного пространства. Если пространство стека ограничено, то StackOverflowException вызывается перед входом в блок try. Т.о. отловить переполнение стека с помощью одного CER не получится. StackOverflowException также отдельно упоминается в документации: CERs that are marked using the PrepareConstrainedRegions method do not work perfectly when a StackOverflowException is generated from the try block. For more information, see the ExecuteCodeWithGuaranteedCleanup method. Мне никак не удалось заставить метод ExecuteCodeWithGuaranteedCleanup обработать переполнение стека. Судя по обсуждению в вопросе по данному методу он также сработает только если Вы сами захостите CLR. Ссылки Catching unhandled exception on separate threads — вопрос об обработке исключений в отдельном потоке catch exception that is thrown in different thread — еще один. How do I prevent and/or handle a StackOverflowException? — вопрос о предупреждении SOE, рассматриваются разного рода костыли. When can you catch a StackOverflowException? — статья о вырожденных случаях, которая только подчеркивает, что SOE поймать нельзя. Asynchrony in C# 5, Part Eight: More Exceptions — статья Эрика Липперта об исключениях в асинхронных функциях, в которой, между делом, рассказывается об истории обработки исключений в дочерних потоках в .Net 1.0 и, в целом, об отношении авторов C# к обработке исключений.

Ответ 2



В С++- это обрабатываемое исключение, если верить комментариям из предыдущего вопроса На самом деле все немного не так. Стандартными средствами С++, разумеется, нельзя обработать переполнение стека. Однако, в Windows его можно обработать с помощью механизма SEH. И, что бы ни говорил Эрик Липперт, восстановление после переполнения стека - вполне поддерживаемый сценарий, иначе зачем бы существовали функции _resetstkoflw и SetThreadStackGuarantee? а в .NET нет, так как возможно, как я понимаю, что СLR, которой нужно что-то сделать, может повредится из-за не хватки стека В .NET StackOverflowException не обрабатывается не потому, что это технически невозможно, а потому, что так решили разработчики. В Windows переполнение стека порождает исключение SEH с кодом STATUS_STACK_OVERFLOW (0xC00000FD). CLR перехватывает SEH-исключения и, если видит этот код, принудительно убивает процесс (будучи загруженной с параметрами по умолчанию). При этом куда более опасное Access Violation .NET почему-то разрешает обрабатывать. Получается, что запуская чужой код, к исходникам которого мы не имеем доступа, хоть в отдельном потоке, хоть в отдельном домене, то мы все равно падаем и в .NET никак нельзя предотвратить это при таком типе исключения? Только средствами .NET нельзя. Однако в неуправляемом коде нужно написать, по сути, очень немного. Один из способов обойти это поведение, это создать специальную неуправляемую DLL, единственной целью которой будет обработать SEH-исключение и поменять его код на тот, который CLR "не напугает" (SEH-исключения с неизвестным кодом CLR преобразует в SEHException, которое можно обработать). В приложении на C# загрузить DLL, установить векторный обработчик исключений и увеличить размер зарезервированной области стека с помощью функции SetThreadStackGuarantee. Конечно, это не обеспечит полное восстановление стека, т.е., чтобы можно было далее в том же потоке снова словить переполнение стека и обработать его. Но если просто позволить потоку завершиться и забыть про него, это не имеет значения: вновь созданные потоки уже будут иметь корректный стек. Например, создадим DLL на С++ с таким кодом: #include #include #ifdef __cplusplus extern "C"{ #endif __declspec(dllexport) LONG WINAPI fnCrashHandler(LPEXCEPTION_POINTERS pExceptionInfo) { if(pExceptionInfo->ExceptionRecord->ExceptionCode == STATUS_STACK_OVERFLOW){ pExceptionInfo->ExceptionRecord->ExceptionCode = 0x1234; } return EXCEPTION_CONTINUE_SEARCH; } #ifdef __cplusplus } #endif Назовем ее, допустим, CrashHandler.dll, и поместим в каталог с программой. Тогда в C# можно обработать переполнение стека таким образом: using System; using System.Collections.Generic; using System.Text; using System.Threading; using System.Runtime.InteropServices; namespace ConsoleTest { class Program { [DllImport("kernel32.dll")] public static extern IntPtr AddVectoredExceptionHandler( uint FirstHandler, IntPtr VectoredHandler ); [DllImport("kernel32.dll")] public static extern int SetThreadStackGuarantee( ref uint StackSizeInBytes); [DllImport("kernel32.dll")] public static extern IntPtr LoadLibrary([MarshalAs(UnmanagedType.LPStr)]string lpFileName); [DllImport("kernel32.dll", CharSet = CharSet.Ansi, ExactSpelling = true)] public static extern IntPtr GetProcAddress(IntPtr hModule, string procName); static void Recursive() { Recursive(); } static void Test() { //увеличим размер зарезервированной области стека (30 KB должно быть достаточно) uint size = 30000; SetThreadStackGuarantee(ref size); try { Recursive(); } catch (SEHException) { Console.WriteLine("SEHException. Code: 0x" + Marshal.GetExceptionCode().ToString("X")); } } static void Main(string[] args) { //добавим обработчик исключений IntPtr h = LoadLibrary("CrashHandler.dll"); IntPtr fnAddress = GetProcAddress(h, "_fnCrashHandler@4"); //декорированное имя функции по правилам stdcall AddVectoredExceptionHandler(1, fnAddress); //запустим поток Thread thread = new Thread(Test); thread.Start(); thread.Join(); Console.WriteLine("Press any key..."); Console.ReadKey(); } } } Примечание. Целевая архитектура неуправляемой DLL и приложения должны совпадать. Для AnyCPU-приложений понадобится иметь несколько неуправляемых DLL под каждую архитектуру и загружать нужную в зависимости от текущей архитектуры приложения.

Комментариев нет:

Отправить комментарий