Pourquoi la pile d'appels a-t-elle une taille maximale statique?

46

Ayant travaillé avec quelques langages de programmation, je me suis toujours demandé pourquoi la pile de threads avait une taille maximale prédéfinie, au lieu de se développer automatiquement selon les besoins. 

En comparaison, certaines structures de haut niveau très courantes (listes, cartes, etc.) que l'on trouve dans la plupart des langages de programmation sont conçues pour croître en fonction des besoins, tandis que de nouveaux éléments sont ajoutés, leur taille étant limitée uniquement par la mémoire disponible ou par des limites de calcul ( par exemple, adressage 32 bits).

Je ne connais cependant aucun langage de programmation ou environnement d'exécution dans lequel la taille maximale de la pile n'est pas pré-limitée par une option par défaut ou par le compilateur. C'est pourquoi trop de récursivité entraîne très rapidement une erreur / exception de débordement de pile omniprésente, même lorsque seul un pourcentage minimal de la mémoire disponible pour un processus est utilisé pour la pile.

Pourquoi la plupart des environnements d'exécution (sinon tous) définissent-ils une limite maximale pour la taille d'une pile pouvant croître au moment de l'exécution?

Lynn
la source
13
Ce type de pile est un espace d'adressage continu qui ne peut pas être déplacé silencieusement dans les coulisses. L'espace d'adressage est précieux sur les systèmes 32 bits.
CodesInChaos
7
Pour réduire l'apparition d'idées tour d'ivoire comme récursion fuite du milieu universitaire et causer des problèmes dans le monde réel comme la lisibilité du code réduit et une augmentation du coût total de la propriété;)
Brad Thomas
6
@ BradThomas C'est à quoi l'optimisation des appels de queue est destinée.
JAB
3
@JohnWu: La même chose que maintenant, juste un peu plus tard: manque de mémoire.
Jörg W Mittag
1
Au cas où ce ne serait pas évident, une des raisons qui expliquent que manquer de mémoire est pire que de manquer de pile, c’est que (en supposant qu’une page de piège soit détectée), le manque de pile entraîne uniquement l’ échec de votre processus. L' exécution de mémoire pourrait provoquer quoi que ce soit à l' échec, celui qui essaie de faire venir une allocation de mémoire. Là encore, sur un système dépourvu de page d'interruption ou de tout autre moyen de détecter le manque de pile, il peut être catastrophique de manquer de pile, ce qui vous entraînerait dans un comportement non défini. Sur un tel système, vous préféreriez de beaucoup manquer de mémoire libre et vous ne pouvez tout simplement pas écrire du code avec une récursion sans limite.
Steve Jessop

Réponses:

13

Il est possible d'écrire un système d'exploitation qui n'exige pas que les piles soient contiguës dans l'espace d'adressage. En gros, vous avez besoin de déconner davantage dans la convention d’appel pour vous assurer que:

  1. s'il n'y a pas assez d'espace dans l'étendue de pile actuelle pour la fonction que vous appelez, vous créez une nouvelle étendue de pile et déplacez le pointeur de pile pour qu'il pointe vers le début de celui-ci dans le cadre de l'appel.

  2. lorsque vous revenez de cet appel, vous revenez à l'étendue de pile d'origine. Très probablement, vous conservez celui créé à (1) pour une utilisation future par le même fil. En principe, vous pouvez le libérer, mais dans ce cas, vous rencontrez des problèmes plutôt inefficaces dans lesquels vous continuez à parcourir la limite en boucle, et chaque appel nécessite une allocation de mémoire.

  3. setjmpet longjmp, ou l’équivalent de votre système d’exploitation pour le transfert de contrôle non local, sont actifs et peuvent revenir correctement à l’étendue de la pile lorsque cela est nécessaire.

Je dis "convention d'appel" - pour être précis, je pense que c'est probablement mieux de le faire par un prologue de fonction que par l'appelant, mais je me souviens très mal de cela.

La raison pour laquelle de nombreuses langues spécifient une taille de pile fixe pour un thread est qu'elle souhaite travailler avec la pile native, sur des systèmes d'exploitation qui ne le font pas . Comme le disent les réponses de tous les autres, en supposant que chaque pile doit être contiguë dans l'espace d'adressage et ne peut pas être déplacée, vous devez réserver une plage d'adresses spécifique à chaque thread. Cela signifie choisir une taille à l'avant. Même si votre espace d'adressage est énorme et que la taille que vous choisissez est vraiment grande, vous devez toujours le choisir dès que vous avez deux threads.

"Aha", dites-vous, "quels sont ces supposés systèmes d'exploitation qui utilisent des piles non contiguës? Je parie que c'est un système universitaire obscur qui ne me sert à rien!". Eh bien, c’est une autre question heureusement déjà posée et à laquelle on a déjà répondu.

Steve Jessop
la source
36

Ces structures de données ont généralement des propriétés que la pile de systèmes d'exploitation n'a pas:

  • Les listes chaînées ne nécessitent pas d'espace d'adressage contigu. Ainsi, ils peuvent ajouter un morceau de mémoire où ils veulent quand ils grandissent.

  • Même les collections nécessitant un stockage contigu, comme le vecteur C ++, ont un avantage sur les piles de systèmes d'exploitation: elles peuvent déclarer tous les pointeurs / itérateurs non valides à chaque fois qu'ils s'agrandissent. D'autre part, la pile de systèmes d'exploitation doit conserver les pointeurs sur elle jusqu'à ce que la fonction à laquelle appartient la cible appartient.

Un langage de programmation ou un environnement d'exécution peut choisir d'implémenter leurs propres piles non contiguës ou déplaçables pour éviter les limitations des piles de système d'exploitation. Golang utilise de telles piles personnalisées pour prendre en charge un très grand nombre de co-routines, implémentées à l'origine sous forme de mémoire non contiguë et maintenant via des piles déplaçables grâce au suivi de pointeur (voir le commentaire de hobb). Luke et Erlang pourraient également utiliser des piles personnalisées, mais je ne l'ai pas confirmé.

Sur les systèmes 64 bits, vous pouvez configurer des piles relativement grandes à un coût relativement bas, car l'espace d'adressage est abondant et la mémoire physique n'est allouée que lorsque vous l'utilisez réellement.

CodesInChaos
la source
1
C'est une bonne réponse et je suis votre sens, mais le terme n'est-il pas un bloc de mémoire "contigu" par opposition à "continu", puisque chaque unité de mémoire a sa propre adresse?
DanK
2
+1 pour "une pile d'appels ne doit pas nécessairement être limitée" Il est souvent implémenté de cette façon pour des raisons de simplicité et de performance, mais cela ne doit pas nécessairement l'être.
Paul Draper
Vous avez tout à fait raison à propos de Go. En fait, si j'ai bien compris, les anciennes versions comportaient des piles non contiguës et les nouvelles versions, des piles déplaçables . Quoi qu'il en soit, il est nécessaire d'autoriser un grand nombre de goroutines. Préallouer quelques mégaoctets par goroutine pour la pile les rendrait trop coûteux pour remplir correctement leur fonction.
Hobbs
@ Hobbs: Oui, Go a commencé avec des piles développables, mais il était difficile de les faire rapidement. Lorsque Go a obtenu un récupérateur de déchets précis, il s’y est adossé pour implémenter des piles mobiles: lorsque la pile est déplacée, la mappe de type précise est utilisée pour mettre à jour les pointeurs sur la pile précédente.
Matthieu M.
26

En pratique, il est difficile (et parfois impossible) de faire fructifier la pile. Comprendre pourquoi nécessite une certaine compréhension de la mémoire virtuelle.

Dans Ye Olde Days d'applications à une seule unité d'exécution et de mémoire contiguë, trois constituaient trois composants d'un espace d'adressage de processus: le code, le segment de mémoire et la pile. La manière dont ces trois éléments étaient disposés dépendait du système d'exploitation, mais le code venait généralement en premier, en commençant par le bas de la mémoire, puis le tas venait grandir, et la pile commençait en haut de la mémoire, puis en bas. Il y avait aussi de la mémoire réservée au système d'exploitation, mais nous pouvons l'ignorer. Les programmes de cette époque présentaient des débordements de pile un peu plus dramatiques: la pile se plantait dans le tas et, en fonction de celui qui était mis à jour en premier, vous utilisiez des données incorrectes ou retourniez d'un sous-programme à une partie arbitraire de la mémoire.

La gestion de la mémoire a quelque peu modifié ce modèle: du point de vue du programme, vous disposiez toujours des trois composants d’une mappe de mémoire de processus, généralement organisés de la même manière, mais chacun des composants était désormais géré en tant que segment indépendant et le MMU signalait la OS si le programme a essayé d'accéder à la mémoire en dehors d'un segment. Une fois que vous avez eu la mémoire virtuelle, il n'était ni nécessaire ni désir de donner à un programme l'accès à tout son espace d'adressage. Donc, les segments ont été assignés des limites fixes.

Alors, pourquoi n'est-il pas souhaitable de donner à un programme l'accès à son espace d'adressage complet? Parce que cette mémoire constitue une "charge engagée" contre le swap; à tout moment, il peut être nécessaire d'écrire toute la mémoire d'un programme pour l'échanger afin de laisser de la place à la mémoire d'un autre programme. Si chaque programme pouvait potentiellement consommer 2 Go d’échange, vous deviez soit fournir suffisamment d’échange pour tous vos programmes, soit courir le risque que deux programmes aient besoin de plus que ce qu’ils pourraient obtenir.

À ce stade, en supposant un espace d'adressage virtuel suffisant, vous pouvez étendre ces segments si nécessaire et le segment de données (segment de mémoire) s'agrandit au fil du temps: vous démarrez avec un petit segment de données et lorsque l'allocateur de mémoire demande plus d'espace lorsque c'est nécessaire. À ce stade, avec une seule pile, il aurait été physiquement possible d'étendre le segment de pile: le système d'exploitation pourrait empêcher la tentative de pousser quelque chose en dehors du segment et ajouter plus de mémoire. Mais ce n'est pas particulièrement souhaitable non plus.

Entrez multi-threading. Dans ce cas, chaque thread a un segment de pile indépendant, toujours de taille fixe. Mais maintenant, les segments sont disposés les uns après les autres dans l'espace d'adressage virtuel. Il est donc impossible de développer un segment sans en déplacer un autre, ce que vous ne pouvez pas faire car le programme comportera potentiellement des pointeurs sur la mémoire. Vous pouvez également laisser un espace entre les segments, mais cet espace serait gaspillé dans presque tous les cas. Une meilleure approche consistait à imposer un fardeau au développeur d’application: si vous aviez vraiment besoin de piles profondes, vous pouvez le spécifier lors de la création du thread.

Aujourd'hui, avec un espace d'adressage virtuel 64 bits, nous pourrions créer efficacement des piles infinies pour un nombre infini de threads. Mais là encore, cela n’est pas particulièrement souhaitable: dans presque tous les cas, un dépassement de pile indique un bogue avec votre code. Vous fournir une pile de 1 Go retarde simplement la découverte de ce bogue.

ségrégation
la source
3
Les processeurs x86-64 actuels ne disposent que de 48 bits d'espace adresse
CodesInChaos
En gros, linux fait croître la pile de manière dynamique: lorsqu'un processus tente d'accéder à la zone située juste en dessous de la pile actuellement allouée, l'interruption est gérée en mappant simplement une page supplémentaire de mémoire de pile, au lieu de commettre une erreur de processus.
cmaster
2
@cmaster: vrai, mais pas ce que kdgregory veut dire par "faire grandir la pile". Il existe actuellement une plage d'adresses destinée à être utilisée en tant que pile. Vous parlez de mapper progressivement plus de mémoire physique dans cette plage d'adresses si nécessaire. kdgregory dit qu'il est difficile voire impossible d'augmenter la portée.
Steve Jessop
x86 n'est pas l'architecture seulement, et 48 bits est encore efficace infini
kdgregory
1
BTW, je me souviens de mes journées avec le x86 comme peu amusantes, principalement à cause de la nécessité de gérer la segmentation. J'ai de loin préféré les projets sur les plateformes MC68k ;-)
kdgregory
4

La pile ayant une taille maximale fixe n'est pas omniprésente.

Il est également difficile d’obtenir des résultats corrects: les profondeurs des piles suivent une distribution de loi de puissance, ce qui signifie que, quelle que soit la taille de la pile, il restera une fraction importante de fonctions avec des piles encore plus petites (vous perdez donc de l’espace), et quelle que soit la taille de votre travail, il restera des fonctions avec des piles encore plus grandes (vous forcez donc une erreur de débordement de pile pour les fonctions sans erreur). En d'autres termes: quelle que soit la taille choisie, elle sera toujours à la fois trop petite et trop grande.

Vous pouvez résoudre le premier problème en autorisant les piles à démarrer petit et à croître de manière dynamique, mais vous avez toujours le deuxième problème. Et si vous permettez quand même que la pile se développe de façon dynamique, pourquoi alors lui imposer une limite arbitraire?

Il existe des systèmes dans lesquels les piles peuvent se développer de manière dynamique et n'ont pas de taille maximale: Erlang, Go, Smalltalk et Scheme, par exemple. Il y a beaucoup de façons d'implémenter quelque chose comme ça:

  • piles mobiles: lorsque la pile contiguë ne peut plus grandir car il y a autre chose dans le chemin, déplacez-la en mémoire, avec plus d'espace libre
  • piles non contiguës: au lieu d'allouer la pile entière dans un seul espace mémoire contigu, allouez-la dans plusieurs espaces mémoire
  • piles allouées au tas: au lieu d'avoir des zones de mémoire séparées pour la pile et le tas, allouez simplement la pile sur le tas; comme vous l'avez constaté vous-même, les structures de données allouées aux segments de mémoire n'ont généralement pas de problèmes de croissance ou de réduction au besoin
  • n'utilisez pas du tout les piles: c'est aussi une option, par exemple, au lieu de garder une trace de l'état de la fonction dans une pile, demandez à la fonction de transmettre une continuation à l'appelé

Dès que vous disposez de puissantes structures de contrôle-flux non locales, l'idée d'une seule pile contiguë disparaît quand même: les exceptions et les suites résumables, par exemple, "jetteront" la pile, de sorte que vous vous retrouverez avec un réseau de piles (par exemple, implémenté avec une pile de spaghettis). En outre, les systèmes dotés de piles modifiables de première classe, telles que Smalltalk, nécessitent quasiment des piles de spaghettis ou similaires.

Jörg W Mittag
la source
1

Le système d'exploitation doit donner un bloc contigu quand une pile est demandée. La seule façon de le faire est si une taille maximale est spécifiée.

Par exemple, supposons que la mémoire ressemble à ceci pendant la requête (Xs représentent utilisés, Os non utilisés):

XOOOXOOXOOOOOX

Si une demande pour une taille de pile de 6 est demandée, le système d’exploitation répondra non, même si plus de 6 sont disponibles. Si une demande de pile de taille 3 est demandée, la réponse du système d'exploitation sera l'une des zones de 3 emplacements vides (Os) d'affilée.

En outre, on peut voir la difficulté de permettre la croissance lorsque le prochain emplacement contigu est occupé.

Les autres objets mentionnés (Listes, etc.) ne vont pas sur la pile, ils se retrouvent sur le tas dans des zones non contiguës ou fragmentées. Ainsi, lorsqu'ils grandissent, ils ne font que saisir de l'espace, ils n'ont pas besoin d'être contigus. géré différemment.

La plupart des systèmes définissent une valeur raisonnable pour la taille de la pile. Vous pouvez la remplacer lorsque le thread est construit si une taille supérieure est requise.

Jon Raynor
la source
1

Sur linux, il s'agit simplement d'une limite de ressource qui existe pour tuer les processus en fuite avant qu'ils ne consomment des quantités nuisibles de la ressource. Sur mon système Debian, le code suivant

#include <sys/resource.h>
#include <stdio.h>

int main() {
    struct rlimit limits;
    getrlimit(RLIMIT_STACK, &limits);
    printf("   soft limit = 0x%016lx\n", limits.rlim_cur);
    printf("   hard limit = 0x%016lx\n", limits.rlim_max);
    printf("RLIM_INFINITY = 0x%016lx\n", RLIM_INFINITY);
}

produit la sortie

   soft limit = 0x0000000000800000
   hard limit = 0xffffffffffffffff
RLIM_INFINITY = 0xffffffffffffffff

Notez que la limite stricte est définie sur RLIM_INFINITY: Le processus est autorisé à augmenter sa limite souple à n’importe quel montant. Cependant, tant que le programmeur n'a aucune raison de penser que le programme a réellement besoin de quantités inhabituelles de mémoire de pile, le processus sera interrompu s'il dépasse une taille de pile de huit mégo-octets.

En raison de cette limite, un processus d'emballement (récursion infinie involontaire) est tué longtemps avant de commencer à consommer une quantité de mémoire si importante que le système est obligé de commencer à permuter. Cela peut faire la différence entre un processus en panne et un serveur en panne. Cependant, cela ne limite pas les programmes ayant un besoin légitime pour une pile importante, il leur suffit de définir la limite souple à une valeur appropriée.


Techniquement, les piles croissent de manière dynamique: lorsque la limite souple est définie sur huit mégo-octets, cela ne signifie pas que cette quantité de mémoire a déjà été mappée. Ce serait plutôt inutile, car la plupart des programmes ne se rapprochent jamais de leurs limites respectives. Au lieu de cela, le noyau détectera les accès en dessous de la pile et mappera simplement dans les pages de mémoire selon les besoins. Ainsi, la seule limitation réelle de la taille de la pile est la mémoire disponible sur les systèmes 64 bits (la fragmentation de l'espace d'adressage est plutôt théorique avec une taille d'espace de 16 zébioctets).

cmaster
la source
2
C'est la pile pour le premier thread seulement. Les nouveaux threads doivent allouer de nouvelles piles et sont limités car ils vont se retrouver dans d'autres objets.
Zan Lynx
0

La taille maximale de la pile est statique car il s'agit de la définition de "maximum" . Toute sorte de maximum sur n'importe quoi est un chiffre limitatif fixe et convenu. Si elle se comporte comme une cible en mouvement spontané, ce n'est pas un maximum.

Les piles sur les systèmes d'exploitation à mémoire virtuelle se développent en fait de manière dynamique, au maximum .

En parlant de ça, il n'est pas nécessaire que ce soit statique. Au contraire, il peut être configurable, par processus ou par thread, même.

Si la question est "pourquoi y a- t - il une taille de pile maximale" (une taille imposée artificiellement, généralement beaucoup moins que la mémoire disponible)?

Une des raisons est que la plupart des algorithmes ne nécessitent pas une quantité énorme d’espace de pile. Une grande pile est une indication d'une éventuelle récursion . Il est bon d’arrêter la récursivité sans allouer toute la mémoire disponible. L’utilisation dégénérée de la pile, qui peut être provoquée par un scénario de test inattendu, est un problème qui ressemble à une récursion emballée. Par exemple, supposons qu'un analyseur syntaxique pour un opérateur infixe binaire fonctionne en récursant sur l'opérande droit: analyse du premier opérande, opérateur d'analyse, analyse du reste de l'expression. Cela signifie que la profondeur de la pile est proportionnelle à la longueur de l'expression: a op b op c op d .... Un cas de test énorme de cette forme nécessitera une pile énorme. Abandonner le programme quand il atteindra une limite de pile raisonnable entraînera ce problème.

Une autre raison de la taille de pile maximale fixe est que l’espace virtuel de cette pile peut être réservé via un type de mappage spécial, et donc garanti. Garanti signifie que l’espace ne sera pas attribué à une autre allocation à laquelle la pile entrera en collision avec elle avant d’atteindre la limite. Le paramètre taille maximale de la pile est requis pour demander ce mappage.

Les threads ont besoin d'une taille de pile maximale pour une raison similaire à celle-ci. Leurs piles sont créées dynamiquement et ne peuvent pas être déplacées si elles entrent en collision avec quelque chose; l'espace virtuel doit être réservé à l'avance et une taille est requise pour cette allocation.

Kaz
la source
@ Lynn N'a pas demandé pourquoi la taille maximale était statique, il a demandé pourquoi il était prédéfini.
Will Calderwood,