Как обнаружено исключение StackOverflowException?

TL, TR
Когда я задал вопрос, я предположил, что StackOverflowException является механизм для предотвращения бесконечного запуска приложений. Это неверно. A StackOverflowException не обнаружен.
Он бросается, когда стек не имеет возможности выделять больше памяти.

[Оригинальный вопрос:]

Это общий вопрос, который может иметь разные ответы на каждый язык программирования.
Я не уверен, что языки, отличные от С#, обрабатывают переполнение стека.

Сегодня я переживал исключения и думал о том, как можно обнаружить StackOverflowException. Я считаю, что нельзя сказать f.e. если стек имеет глубину 1000 вызовов, а затем генерирует исключение. Потому что, возможно, в некоторых случаях правильная логика будет такой глубокой.

Какова логика обнаружения бесконечного цикла в моей программе?

StackOverflowException класс:
https://msdn.microsoft.com/de-de/library/system.stackoverflowexception%28v=vs.110%29.aspx

Перекрестная ссылка, упомянутая в документации класса StackOverflowException:
https://msdn.microsoft.com/de-de/library/system.reflection.emit.opcodes.localloc(v=vs.110).aspx

Я добавил тэг stack-overflow к этому вопросу, и в описании говорится, что он бросается, когда стек вызовов потребляет слишком много памяти. Означает ли это, что стек вызовов - это какой-то путь к текущей исполняемой позиции моей программы, и если он не может хранить больше информации о пути, то генерируется исключение?

Ответ 1

Переполнение стека

Я облегчу тебе; но это на самом деле довольно сложно... Заметьте, что я немного обобщу здесь.

Как вы знаете, большинство языков используют стек для хранения информации о вызовах. Смотрите также: https://msdn.microsoft.com/en-us/library/zkwh89ks.aspx за работу cdecl. Если вы вызываете метод, вы подталкиваете вещи в стек; если вы вернетесь, вы поместите материал из стека.

Обратите внимание, что рекурсия обычно не является "встроенной". (Примечание: я прямо говорю "рекурсия" здесь, а не "хвостовая рекурсия", последняя работает как "goto" и не увеличивает стек).

Самый простой способ обнаружить переполнение стека - проверить текущую глубину стека (например, байты) - и если он попадает на границу, сообщите об ошибке. Чтобы прояснить эту "пограничную проверку": способ выполнения этих проверок обычно осуществляется с помощью защитных страниц; это означает, что проверки границ обычно не выполняются как проверки if-then-else (хотя существуют некоторые реализации...).

В большинстве языков каждый поток имеет свой собственный стек.

Обнаружение бесконечных циклов

Хорошо, вот вопрос, который я не слышал какое-то время.: -)

В основном обнаружение всех бесконечных циклов требует решения проблемы с остановкой. Это, кстати, проблема неразрешимая. Это не делается компиляторами.

Это не означает, что вы не можете анализировать; на самом деле, вы можете сделать довольно немного анализа. Однако также обратите внимание, что иногда вы хотите, чтобы функции выполнялись неограниченно (например, основной цикл на веб-сервере).

Другие языки

Также интересно... Функциональные языки используют рекурсию, поэтому они в основном связаны стеком. (Тем не менее, функциональные языки также склонны использовать хвостовую рекурсию, которая работает более или менее как "goto" и не увеличивает стек.)

И тогда есть Логические языки.. ну, теперь я не уверен, как навсегда зацикливаться на этом - вы, вероятно, закончите что-то, что не будет оценивать вообще (решение не найдено). (Хотя, вероятно, это зависит от языка...)

Выход, асинхронный, продолжение

Интересная концепция, о которой вы думаете, называется продолжениями. Я слышал от Microsoft, что когда yield была впервые реализована, реальные продолжения рассматривались как реализация. Продолжения в основном позволяют вам "сохранить" стек, продолжить в другом месте и "восстановить" стек обратно в более поздней точке... (Опять же, детали намного сложнее, чем это, это только основная идея).

К сожалению, Microsoft не пошла на эту идею (хотя я могу себе представить, почему), но реализовала ее с помощью вспомогательного класса. Доходность и асинхронность работы С# путем добавления временного класса и интернирования всех локальных переменных в классе. Если вы вызываете метод, который выполняет "yield" или "async", вы фактически создаете вспомогательный класс (из метода, который вы вызываете и нажимаете на стек), который помещается в кучу. Класс, который нажимается на кучу, имеет функциональность (например, для yield это реализация перечисления). То, как это делается, - это использовать переменную состояния, в которой хранится местоположение (например, некоторый идентификатор состояния), где программа должна продолжаться при вызове MoveNext. Разделитель (коммутатор), использующий этот идентификатор, заботится обо всем остальном. Обратите внимание, что этот механизм не делает ничего особенного с тем, как работает сам стек; вы можете реализовать одно и то же сами, используя классы и методы (это просто включает в себя больше ввода: -)).

Решение с помощью ручного стека

Мне всегда нравится хорошая заливка. Если вы сделаете это неправильно, картинка даст вам множество рекурсивных вызовов... скажем так:

public void FloodFill(int x, int y, int color)
{
    // Wait for the crash to happen...
    if (Valid(x,y))
    {
        SetPixel(x, y, color);
        FloodFill(x - 1, y, color);
        FloodFill(x + 1, y, color);
        FloodFill(x, y - 1, color);
        FloodFill(x, y + 1, color);
    }
}

Нет ничего плохого в этом коде. Он выполняет всю работу, но наш стек мешает. Наличие ручного стека решает это, хотя реализация в основном одинакова:

public void FloodFill(int x, int y, int color)
{
    Stack<Tuple<int, int>> stack = new Stack<Tuple<int, int>>();
    stack.Push(new Tuple<int, int>(x, y));
    while (stack.Count > 0)
    {
        var current = stack.Pop();

        int x2 = current.Item1;
        int y2 = current.Item2;

        // "Recurse"
        if (Valid(x2, y2))
        {
            SetPixel(x2, y2, color);
            stack.Push(new Tuple<int, int>(x2-1, y2));
            stack.Push(new Tuple<int, int>(x2+1, y2));
            stack.Push(new Tuple<int, int>(x2, y2-1));
            stack.Push(new Tuple<int, int>(x2, y2+1));
        }
    }
}

Ответ 2

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

Я не уверен, что языки, отличные от С#, обрабатывают переполнение стека.

Ваш вопрос: "Как обнаружено переполнение стека?" Ваш вопрос о том, как он обнаружен на С# или на каком-то другом языке? Если у вас есть вопрос о другом языке, я рекомендую создать новый вопрос.

Я думаю, что нельзя сказать (например), если стек имеет глубину 1000 вызовов, а затем выбросить исключение. Потому что, возможно, в некоторых случаях правильная логика будет такой глубокой.

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

Какова логика обнаружения бесконечного цикла в моей программе?

Вы имеете в виду неограниченную рекурсию, а не бесконечный цикл.

Я опишу ниже.

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

Короткий ответ: да.

Более длинный ответ: стек вызовов используется для двух целей.

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

Во-вторых, для представления информации о продолжении. То есть, когда я закончил с этим методом, что мне нужно сделать дальше? Обратите внимание, что стек не представляет собой "откуда я пришел?". Стек представляет, где я буду дальше, и так получилось, что обычно, когда метод возвращается, вы возвращаетесь туда, откуда вы пришли.

В стеке также хранится информация для нелокальных продолжений, т.е. обработка исключений. Когда метод выбрасывает, стек вызовов содержит данные, которые помогают среде выполнения определить, какой код, если таковой имеется, содержит соответствующий блок catch. Затем этот блок catch становится продолжением - "что делать дальше" - метода.

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

Когда вы вызываете метод, в стеке выделяется память для хранения обратного адреса - что мне делать дальше - и кадра активации - locals нового метода. Стеки в Windows по умолчанию имеют фиксированный размер, поэтому, если места недостаточно, происходят плохие вещи.

Подробнее о том, как Windows делает проверку стека?

Я написал логику обнаружения вне стека для 32-битных версий Windows VBScript и JScript в 1990-х годах; CLR использует аналогичные методы, которые я использовал, но если вы хотите знать специфические для CLR детали, вам придется обратиться к эксперту по CLR.

Рассмотрим только 32-битную Windows; Аналогично работает 64-битная Windows.

Windows использует виртуальную память, конечно - если вы не понимаете, как работает виртуальная память, сейчас самое время научиться, прежде чем читать. Каждому процессу присваивается 32-битное плоское адресное пространство, половина зарезервировано для операционной системы и половина для кода пользователя. Каждому потоку по умолчанию присваивается зарезервированный непрерывный блок в 1 мегабайт адресного пространства. (Примечание: это одна из причин, почему потоки являются тяжеловесными. Миллион байтов непрерывной памяти много, если у вас всего два миллиарда байт.)

Здесь есть несколько тонкостей относительно того, просто ли зарезервировано или фактически зафиксировано это смежное адресное пространство, но пусть затушевывает их. Я продолжу описывать, как это работает в обычной программе Windows, а не в деталях CLR.

ОК, поэтому мы можем сказать, что миллион байт памяти разделен на 250 страниц по 4kb каждый. Но программа, когда она впервые запускается, может понадобиться, может быть, несколько килобайт стека. Так вот как это работает. Текущая страница стека - это хорошо совершенная страница; это просто нормальная память. Страница за ней отмечена как страница защиты. И последняя страница в нашем миллионном стеке отмечена как особая страница защиты.

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

Однако, если последняя страница защиты пострадает - очень специальная, то Windows запускает исключение вне стека, а Windows сбрасывает страницу защиты, означающую "если эта страница снова попала, завершите процесс". Если это произойдет, Windows немедленно прекратит процесс. Никаких исключений. Нет кода очистки. Нет диалогового окна. Если вы когда-либо видели, что приложение Windows просто внезапно исчезает полностью, возможно, что произошло, кто-то во второй раз ударил страницу защиты в конце стека.

ОК, так что теперь, когда мы понимаем механизмы - и снова, я замалчиваю многие подробности здесь - вы, вероятно, можете увидеть, как писать код, который делает исключения из стека. Вежливый способ - это то, что я сделал в VBScript и JScript, - это сделать запрос виртуальной памяти в стеке и спросить, где находится окончательная страница защиты. Затем периодически смотрите на текущий указатель стека, и если он попадает на пару страниц, просто создайте ошибку VBScript или исключите исключение JavaScript прямо тогда и там, вместо того, чтобы позволить операционной системе сделать это за вас.

Если вы не хотите самостоятельно выполнять это исследование, вы можете обработать первое исключение шанса, которое операционная система дает вам, когда наступает окончательная страница защиты, превратить это в исключение, которое понимает С#, и очень осторожно, чтобы второй раз не ударить страницу охраны.

Ответ 3

Стек - это просто блок памяти фиксированного размера, который выделяется при создании потока. Существует также "указатель стека", способ отслеживания того, какая часть стека используется в настоящее время. В рамках создания нового фрейма стека (при вызове метода, свойства, конструктора и т.д.) Он перемещает указатель стека на сумму, необходимую новому кадру. В это время он проверит, переместил ли указатель стека за конец стека, и если да, бросьте SOE.

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

Ответ 4

ПРЕДУПРЕЖДЕНИЕ. Это связано с механизмом капота, в том числе с тем, как сама CLR должна работать. Это будет реально иметь смысл, если вы начнете изучать программирование на уровне сборки.

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

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

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

addendum: причина, по которой я упоминаю рекурсивные циклы, заключается в том, что исключение произойдет только в том случае, если вы перегрузите стек, что обычно означает, что вы вызываете методы, которые в конечном итоге переходят в один и тот же метод и увеличивают стек вызовов. Если у вас есть что-то логически бесконечное, но не рекурсивное, вы обычно не увидите StackOverflowException

Ответ 5

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

Это условие обнаруживается, когда программа пытается получить доступ к части памяти, которая находится за пределами того, что выделено стеку. Это приводит к исключению.

Ответ 6

На большинство подзапросов был дан достаточный ответ. Я хотел бы прояснить часть об обнаружении состояния, надеюсь, таким образом, который легче понять, чем ответ Эрика Липперта (который, конечно, конечно, но излишне запутанный). Вместо этого я сверлю свой ответ по-другому, упоминая не один, а два разных подхода.

Существует два способа обнаружения: либо с помощью кода, либо с помощью аппаратного обеспечения.

Обнаружение с использованием кода использовалось в те времена, когда ПК работали в 16-разрядном реальном режиме, а аппаратное обеспечение было wimpy. Он больше не используется, но стоит упомянуть. В этом случае мы указываем ключ компилятора, запрашивая у компилятора специальный скрытый фрагмент кода проверки стека в начале каждой функции, которую мы пишем. Этот код просто считывает значение регистра указателя стека и проверяет, находится ли он слишком близко к концу стека; если это так, он останавливает нашу программу. Стек на архитектуре x86 увеличивается вниз, поэтому, если диапазон адресов от 0x80000 до 0x90000 обозначен как наш стек программ, тогда указатель стека изначально указывает на 0x90000, и, поскольку вы продолжаете ссылаться на вложенные функции, он уменьшается до 0x80000. Таким образом, если код проверки стека видит, что указатель стека слишком близок к 0x80000 (скажем, на уровне или ниже 0x80010), он останавливается.

Все это имеет недостаток: a) добавление служебных данных для каждого отдельного вызова функции, который мы делаем, и b) неспособность обнаружить переполнение стека во время вызовов внешнего кода, который не был скомпилирован с помощью этого специального компилятора, и, следовательно, не выполняет проверку. Еще в те дни исключение StackOverflow было роскошным, неслыханным: ваша программа либо закончила бы с очень лаконичным (можно было бы сказать грубо) сообщение об ошибке, либо произошел сбой системы, требующий перезагрузки.

Обнаружение с помощью аппаратного обеспечения в основном делегирует задание CPU. Современные процессоры имеют сложную систему для разделения памяти на страницы (обычно длиной 4 КБ каждая) и выполняют различные трюки с каждой страницы, в том числе возможность иметь прерывание (в некоторых архитектурах, называемое "ловушкой" ), автоматически выдаваемое при доступе к определенной странице, Таким образом, операционная система настраивает ЦП таким образом, что прерывание будет выдано, если вы попытаетесь получить доступ к адресу памяти стека ниже установленного минимума. Когда это прерывание происходит, оно принимается во время выполнения вашего языка (в случае С#, время выполнения .Net) и преобразуется в исключение StackOverflow.

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