web-dev-qa-db-fra.com

Comment une StackOverflowException est-elle détectée?

TL; TR
Quand j'ai posé la question, j'ai supposé qu'un StackOverflowException était un mécanisme pour empêcher les applications de s'exécuter à l'infini. Ce n'est pas vrai.
Un StackOverflowException n'est pas détecté.
Il est levé lorsque la pile n'a pas la capacité d'allouer plus de mémoire.

[Question d'origine:]

Il s'agit d'une question générale, qui peut avoir des réponses différentes par langage de programmation.
Je ne sais pas comment les langages autres que C # traitent un débordement de pile.

Je passais par des exceptions aujourd'hui et continuais à réfléchir à la façon dont un StackOverflowException pouvait être détecté. Je pense qu'il n'est pas possible de dire f.e. si la pile a une profondeur de 1000 appels, lancez l'exception. Parce que dans certains cas, la logique correcte sera aussi profonde.

Quelle est la logique derrière la détection d'une boucle infinie dans mon programme?

StackOverflowException classe:
https://msdn.Microsoft.com/de-de/library/system.stackoverflowexception%28v=vs.110%29.aspx

Référence croisée mentionnée dans la documentation de la classe StackOverflowException:
https://msdn.Microsoft.com/de-de/library/system.reflection.emit.opcodes.localloc (v = vs.110) .aspx

Je viens d'ajouter le stack-overflow tag à cette question, et la description indique qu'elle est levée lorsque la pile d'appels consomme trop de mémoire. Cela signifie-t-il que la pile d'appels est une sorte de chemin vers la position d'exécution actuelle de mon programme et si elle ne peut pas stocker plus informations sur le chemin, alors l'exception est levée?

67
Noel Widmer

Débordements de pile

Je vais vous faciliter la tâche; mais c'est en fait assez complexe ... Notez que je vais généraliser un peu ici.

Comme vous le savez peut-être, la plupart des langues utilisent la pile pour stocker les informations d'appel. Voir aussi: https://msdn.Microsoft.com/en-us/library/zkwh89ks.aspx pour savoir comment fonctionne cdecl. Si vous appelez une méthode, vous poussez des trucs sur la pile; si vous revenez, vous sortez des trucs de la pile.

Notez que la récursivité n'est normalement pas "en ligne". (Remarque: je dis explicitement "récursivité" ici et non "récursivité de queue"; ce dernier fonctionne comme un "goto" et ne fait pas grossir la pile).

Le moyen le plus simple de détecter un débordement de pile est de vérifier la profondeur de votre pile actuelle (par exemple, les octets utilisés) - et s'il atteint une limite, donnez une erreur. Pour clarifier cette "vérification des limites": la façon dont ces vérifications sont effectuées consiste normalement à utiliser des pages de garde; cela signifie que les vérifications des limites ne sont normalement pas implémentées comme des vérifications if-then-else (bien que certaines implémentations existent ...).

Dans la plupart des langues, chaque thread a sa propre pile.

Détection de boucles infinies

Eh bien maintenant, voici une question que je n'ai pas entendue depuis un moment. :-)

Fondamentalement, la détection de toutes les boucles infinies vous oblige à résoudre le Halting Problem . Ce qui est d'ailleurs un problème indécidable . Ce n'est certainement pas fait par les compilateurs.

Cela ne signifie pas que vous ne pouvez faire aucune analyse; en fait, vous pouvez faire pas mal d'analyses. Cependant, notez également que vous souhaitez parfois que les choses s'exécutent indéfiniment (comme la boucle principale d'un serveur Web).

Autres langues

Aussi intéressant ... Les langages fonctionnels utilisent la récursivité, ils sont donc essentiellement liés par la pile. (Cela dit, les langages fonctionnels ont également tendance à utiliser la récursivité de queue, qui fonctionne plus ou moins comme un `` goto '' et ne fait pas grossir la pile.)

Et puis il y a les langages logiques ... eh bien maintenant, je ne sais pas comment boucler pour toujours - vous finirez probablement par quelque chose qui ne sera pas évalué du tout (aucune solution ne peut être trouvée). (Cependant, cela dépend probablement de la langue ...)

Rendement, async, continuations

Un concept intéressant auquel vous pourriez penser est appelé suite . J'ai entendu de Microsoft que lorsque yield a été implémenté pour la première fois, de véritables continuations étaient considérées comme une implémentation. Les continuations vous permettent essentiellement de "sauvegarder" la pile, de continuer ailleurs et de "restaurer" la pile à un stade ultérieur ... (Encore une fois, les détails sont beaucoup plus compliqués que cela; ce n'est que l'idée de base).

Malheureusement, Microsoft n'a pas opté pour cette idée (bien que je puisse imaginer pourquoi), mais l'a implémentée en utilisant une classe d'assistance. Le rendement et l'async en C # fonctionnent en ajoutant une classe temporaire et en internant toutes les variables locales au sein de la classe. Si vous appelez une méthode qui fait un 'yield' ou 'async', vous créez en fait une classe d'assistance (à partir de la méthode que vous appelez et poussez sur la pile) qui est poussée sur le tas. La classe qui est poussée sur le tas a la fonctionnalité (par exemple pour yield c'est l'implémentation d'énumération). La façon de procéder est d'utiliser une variable d'état, qui stocke l'emplacement (par exemple un identifiant d'état) où le programme devrait continuer lorsque MoveNext est appelé. Une branche (commutateur) utilisant cet ID s'occupe du reste. Notez que ce mécanisme n'a rien de "spécial" avec la façon dont la pile fonctionne elle-même; vous pouvez implémenter la même chose vous-même en utilisant des classes et des méthodes (cela implique juste plus de frappe :-)).

Résolution des débordements de pile avec une pile manuelle

J'aime toujours un bon remblayage. Une image vous donnera un tas d'appels récursifs si vous faites cela mal ... dites, comme ceci:

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);
    }
}

Il n'y a rien de mal à ce code. Il fait tout le travail, mais notre pile gêne. Avoir une pile manuelle résout ce problème, même si l'implémentation est fondamentalement la même:

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));
        }
    }
}
42
atlaste

Il y a déjà un certain nombre de réponses ici, dont beaucoup font passer l'essentiel, et dont beaucoup ont des erreurs subtiles ou importantes. Plutôt que d'essayer d'expliquer le tout à partir de zéro, permettez-moi de souligner quelques points saillants.

Je ne sais pas comment les langages autres que C # gèrent un débordement de pile.

Votre question est "comment un débordement de pile est-il détecté?" Votre question porte-t-elle sur la façon dont il est détecté en C # ou dans un autre langage? Si vous avez une question sur une autre langue, je vous recommande de créer une nouvelle question.

Je pense qu'il n'est pas possible de dire (par exemple) si la pile est profonde de 1000 appels, puis lancez l'exception. Parce que dans certains cas, la logique correcte sera aussi profonde.

Il est absolument possible d'implémenter une détection de débordement de pile comme ça. En pratique, ce n'est pas ainsi que cela se fait, mais il n'y a aucune raison de principe pour laquelle le système n'aurait pas pu être conçu de cette façon.

Quelle est la logique derrière la détection d'une boucle infinie dans mon programme?

Vous voulez dire un récursion illimitée, pas un boucle infinie.

Je vais le décrire ci-dessous.

Je viens d'ajouter la balise stack-overflow à cette question, et la description indique qu'elle est levée lorsque la pile d'appels consomme trop de mémoire. Cela signifie-t-il que la pile d'appels est une sorte de chemin vers la position d'exécution actuelle de mon programme et si elle ne peut pas stocker plus d'informations sur le chemin, l'exception est levée?

Réponse courte: oui.

Réponse plus longue: la pile d'appels est utilisée à deux fins.

Tout d'abord, pour représenter informations d'activation. C'est-à-dire les valeurs des variables locales et des valeurs temporaires dont les durées de vie sont égales ou plus courtes que l'activation actuelle ("appel") d'une méthode.

Deuxièmement, pour représenter informations de continuation. Autrement dit, quand j'en ai fini avec cette méthode, que dois-je faire ensuite? Notez que la pile pas représente "d'où suis-je venu?". La pile représente où vais-je ensuite, et il se trouve que généralement quand une méthode revient, vous retournez d'où vous venez.

La pile stocke également des informations pour les continuations non locales, c'est-à-dire la gestion des exceptions. Lorsqu'une méthode est lancée, la pile d'appels contient des données qui aident le runtime à déterminer quel code, le cas échéant, contient le bloc catch approprié. Ce bloc catch devient alors le suite - le "que dois-je faire ensuite" - de la méthode.

Maintenant, avant de continuer, je note que la pile d'appels est une structure de données utilisée à des fins deux, violant le principe de responsabilité unique. Il n'y a pas exigence qu'il y ait une pile utilisée à deux fins, et en fait il y a des architectures exotiques dans lesquelles il y a deux piles, une pour les trames d'activation et une pour les adresses de retour (qui sont la réification de telles architectures sont moins vulnérables aux attaques de "destruction de pile" qui peuvent se produire dans des langages comme C.

Lorsque vous appelez une méthode, de la mémoire est allouée sur la pile pour stocker l'adresse de retour - que dois-je faire ensuite - et la trame d'activation - les sections locales de la nouvelle méthode. Les piles sur Windows sont par défaut de taille fixe, donc s'il n'y a pas assez de place, de mauvaises choses se produisent.

Plus en détail, comment fonctionne Windows en cas de détection de pile?

J'ai écrit la logique de détection hors pile pour les versions Windows 32 bits de VBScript et JScript dans les années 1990; le CLR utilise des techniques similaires à celles que j'ai utilisées, mais si vous voulez connaître les détails spécifiques au CLR, vous devrez consulter un expert sur le CLR.

Prenons seulement Windows 32 bits; Windows 64 bits fonctionne de manière similaire.

Windows utilise la mémoire virtuelle bien sûr - si vous ne comprenez pas comment fonctionne la mémoire virtuelle, ce serait le bon moment pour apprendre avant de continuer la lecture. Chaque processus se voit attribuer un espace d'adressage plat de 32 bits, à moitié réservé au système d'exploitation et à moitié au code utilisateur. Chaque thread reçoit par défaut un bloc contigu réservé d'un mégaoctet d'espace d'adressage. (Remarque: c'est une des raisons pour lesquelles les threads sont lourds. Un million d'octets de mémoire contigu est beaucoup quand vous n'avez que deux milliards d'octets en premier lieu.)

Il y a ici quelques subtilités quant à savoir si cet espace d'adressage contigu est simplement réservé ou réellement engagé, mais passons-les en revue. Je vais continuer à décrire comment cela fonctionne dans un programme Windows classique plutôt que d'entrer dans les détails du CLR.

OK, nous avons donc disons un million d'octets de mémoire, divisé en 250 pages de 4 Ko chacune. Mais le programme, lorsqu'il commencera à fonctionner, n'aura peut-être besoin que de quelques Ko de pile. Voici donc comment cela fonctionne. La page de pile actuelle est une page engagée parfaitement bonne; c'est juste une mémoire normale. La page au-delà qui est marquée comme une page de garde. Et la page dernier de notre pile de millions d'octets est marquée comme une page de garde très spéciale.

Supposons que nous essayions d'écrire un octet de mémoire de pile au-delà de notre bonne page de pile. Cette page est protégée, donc une erreur de page se produit. Le système d'exploitation gère l'erreur en rendant cette page de pile correcte, et la page suivante devient la nouvelle page de garde.

Cependant, si la page de garde dernière est atteinte - la très spéciale - alors Windows déclenche une exception hors de la pile, et Windows réinitialise la page de garde pour signifier msgstr "si cette page est de nouveau consultée, terminer le processus". Si cela se produit, Windows met fin au processus immédiatement. Pas exception. Pas de code de nettoyage. Aucune boîte de dialogue. Si vous avez déjà vu une application Windows disparaître complètement soudainement, ce qui s'est probablement passé est que quelqu'un a frappé la page de garde à la fin de la pile pour la seconde fois.

OK, alors maintenant que nous comprenons les mécanismes - et encore une fois, je passe sous silence de nombreux détails ici - vous pouvez probablement voir comment écrire du code qui fait des exceptions hors de la pile. La manière polie - ce que j'ai fait dans VBScript et JScript - est de faire une requête de mémoire virtuelle sur la pile et de demander où se trouve la dernière page de garde. Ensuite, examinez périodiquement le pointeur de pile actuel, et s'il pénètre dans quelques pages, créez simplement une erreur VBScript ou lancez une exception JavaScript sur le champ plutôt que de laisser le système d'exploitation le faire pour vous.

Si vous ne voulez pas le faire vous-même, vous pouvez gérer l'exception première chance que le système d'exploitation vous donne lorsque la dernière page de garde est atteinte, transformez-la en une exception de dépassement de pile que C # comprend, et soyez très prudent pour ne pas frapper la page de garde une deuxième fois.

33
Eric Lippert

La pile est simplement un bloc de mémoire de taille fixe qui est alloué lors de la création du thread. Il existe également un "pointeur de pile", un moyen de garder une trace de la quantité de pile actuellement utilisée. Dans le cadre de la création d'un nouveau cadre de pile (lors de l'appel d'une méthode, d'une propriété, d'un constructeur, etc.), il déplace le pointeur de pile de la quantité dont le nouveau cadre aura besoin. À ce moment, il vérifiera si le pointeur de pile a été déplacé au-delà de la fin de la pile, et si c'est le cas, lance un SOE.

Le programme ne fait rien pour détecter une récursion infinie. Récursion infinie (lorsque le runtime est obligé de créer un nouveau cadre de pile pour chaque invocation), il en résulte tout simplement autant d'appels de méthode effectués pour remplir cet espace fini. Vous pouvez tout aussi facilement remplir cet espace fini avec un nombre fini d'appels de méthode imbriqués qui s'avèrent justement consommer plus d'espace que la pile. (Cela a tendance à être assez difficile à faire cependant; il est généralement causé par des méthodes récursives, et non infiniment, mais d'une profondeur suffisante pour que la pile ne puisse pas la gérer.)

14
Servy

AVERTISSEMENT: Cela a beaucoup à voir avec la mécanique sous le capot, y compris le fonctionnement du CLR lui-même. Cela n'aura de sens que si vous commencez à étudier la programmation au niveau de l'assemblage.

Sous le capot, les appels de méthode sont effectués en passant le contrôle au site d'une autre méthode. Afin de passer des arguments et des retours, ceux-ci sont chargés sur la pile. Pour savoir comment renvoyer le contrôle à la méthode appelante, le CLR doit également implémenter une pile d'appels , qui est poussé à quand une méthode est appelée et sauté à partir du moment où une méthode revient. Cette pile indique la méthode de retour pour retourner le contrôle.

Puisqu'un ordinateur n'a qu'une mémoire finie, il y a des moments où la pile d'appels devient trop grande. Ainsi, un StackOverflowException est pas la détection d'un programme infiniment courant ou infiniment récursif, c'est le détection que l'ordinateur ne peut plus gérer la taille de la pile requise pour suivre où vos méthodes doivent retourner, les arguments nécessaires, les retours, les variables ou (plus communément) une combinaison de ceux-ci. Le fait que cette exception se produit pendant une récursion infinie est dû au fait que la logique submerge inévitablement la pile.

Pour répondre à votre question, si un programme intentionnellement a une logique qui surchargerait la pile alors oui vous verrez un StackOverflowException. Cependant, il s'agit généralement de milliers jusqu'à millions d'appels profonds et rarement une préoccupation réelle, sauf si vous avez créé une boucle infiniment récursive.

addendum: La raison pour laquelle je mentionne les boucles récursives est parce que l'exception ne se produira que si vous écrasez la pile - ce qui signifie généralement que vous appelez des méthodes qui finissent par appeler revenir dans la même méthode et augmenter la pile d'appels. Si vous avez quelque chose qui est logiquement infini, mais pas récursif, vous ne verrez généralement pas un StackOverflowException

6
David

Le problème avec les débordements de pile n'est pas qu'ils pourraient provenir d'un calcul infini. Le problème est l'épuisement de la mémoire de pile qui est une ressource limitée dans les systèmes d'exploitation et les langages d'aujourd'hui.

Cette condition est détectée lorsque le programme tente d'accéder à une partie de la mémoire qui dépasse ce qui est alloué à la pile. Il en résulte une exception.

4
usr

La plupart des sous-questions ont reçu une réponse suffisante. Je voudrais clarifier la partie sur la détection de la condition de débordement de la pile, espérons-le d'une manière plus facile à comprendre que la réponse d'Eric Lippert (qui est, bien sûr, mais alambiquée inutilement.) Au lieu de cela, je vais alourdir ma réponse d'une manière différente, en mentionnant non pas une, mais deux approches différentes.

Il existe deux façons de détecter un débordement de pile: soit avec du code, soit à l'aide de matériel.

Détection de débordement de pile à l'aide de code était utilisé à l'époque où les PC fonctionnaient en mode 16 bits réel et le matériel était mauviette. Il n'est plus utilisé, mais il convient de le mentionner. Dans ce scénario, nous spécifions un commutateur de compilation demandant au compilateur d'émettre un morceau caché spécial de code de vérification de pile au début de chaque fonction que nous écrivons. Ce code lit simplement la valeur du registre de pointeur de pile et vérifie s'il est trop proche de la fin de la pile; si c'est le cas, cela arrête notre programme. La pile de l'architecture x86 augmente vers le bas, donc si la plage d'adresses 0x80000 à 0x90000 a été désignée comme pile de notre programme, le pointeur de pile pointe initialement sur 0x90000, et lorsque vous continuez d'appeler des fonctions imbriquées, il descend vers 0x80000. Donc, si le code de vérification de pile voit que le pointeur de pile est trop proche de 0x80000 (disons à ou en dessous de 0x80010), il s'arrête.

Tout cela a l'inconvénient de a) ajouter une surcharge à chaque appel de fonction que nous faisons, et b) de ne pas pouvoir détecter un débordement de pile lors d'appels à du code externe qui n'a pas été compilé avec ce commutateur de compilateur spécial et, par conséquent, n'effectue aucune vérification du débordement de la pile. À cette époque, une exception de StackOverflow était un luxe inouï: votre programme se terminait soit par un très laconique (on pourrait presque dire grossier ) message d'erreur, ou il y aurait un plantage du système, nécessitant un redémarrage.

Détection de débordement de pile à l'aide du matériel délègue essentiellement le travail au CPU. Les processeurs modernes ont un système élaboré pour subdiviser la mémoire en pages (généralement 4 Ko de long chacune) et effectuer diverses astuces avec chaque page, y compris la possibilité d'avoir une interruption (dans certaines architectures appelée un "piège") automatiquement émise lors de l'accès à une page particulière . Ainsi, le système d'exploitation configure le CPU de telle sorte qu'une interruption sera émise si vous essayez d'accéder à une adresse de mémoire de pile inférieure au minimum assigné. Lorsque cette interruption se produit, elle est reçue par le runtime de votre langue (dans le cas de C #, le runtime .Net) et elle est traduite en une exception StackOverflow.

Cela a l'avantage de n'avoir absolument aucun frais supplémentaire. Il y a un surcoût associé à la gestion des pages que le CPU effectue tout le temps, mais cela est payé de toute façon, car il est nécessaire que la mémoire virtuelle fonctionne, et pour diverses autres choses comme la protection de l'espace d'adressage mémoire d'un processus des autres processus, etc.

3
Mike Nakis