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 s'étendre automatiquement si nécessaire.
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 selon les besoins tandis que de nouveaux éléments sont ajoutés, étant limités en taille 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 où la taille de pile maximale n'est pas prédéfinie par une option par défaut ou de compilation. C'est pourquoi une récursivité excessive entraînera 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 (sinon la totalité) des environnements d'exécution définissent-ils une limite maximale pour la taille qu'une pile peut augmenter lors de l'exécution?
Il est possible d'écrire un système d'exploitation qui ne nécessite pas que les piles soient contiguës dans l'espace d'adressage. Fondamentalement, vous avez besoin de quelques détails supplémentaires dans la convention d'appel, pour vous assurer que:
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 pointer vers le début de celle-ci dans le cadre de l'appel.
lorsque vous revenez de cet appel, vous retransférez dans l'étendue de pile d'origine. Vous conservez probablement celui créé en (1) pour une utilisation future par le même thread. En principe, vous pouvez le libérer, mais de cette façon, il y a des cas plutôt inefficaces où vous continuez à faire des allers-retours à travers la frontière dans une boucle, et chaque appel nécessite une allocation de mémoire.
setjmp
et longjmp
, ou tout autre équivalent de votre système d'exploitation pour le transfert de contrôle non local, sont en jeu et peuvent revenir correctement à l'ancienne étendue de la pile si nécessaire.
Je dis "convention d'appel" - pour être précis, je pense qu'il est probablement préférable de le faire dans un prologue de fonction plutôt que par l'appelant, mais ma mémoire est floue.
La raison pour laquelle un certain nombre de langues spécifient une taille de pile fixe pour un thread, c'est qu'elles veulent travailler avec la pile native, sur des systèmes d'exploitation qui ne le font pas faites cela. 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 à utiliser par chaque thread. Cela signifie choisir une taille à l'avance. 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 OS qui utilisent des piles non contiguës? Je parie que c'est un système académique obscur qui ne sert à rien pour moi!". Eh bien, c'est une autre question qui heureusement est déjà posée et répondue.
Ces structures de données ont généralement des propriétés que la pile du système d'exploitation n'a pas:
Les listes lié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 qui ont besoin d'un stockage contigu, comme le vecteur de C++, ont un avantage sur les piles de système d'exploitation: elles peuvent déclarer tous les pointeurs/itérateurs invalides à chaque fois qu'ils grandissent. D'un autre côté, la pile du système d'exploitation doit conserver les pointeurs vers la pile valides jusqu'à ce que la fonction à l'image à laquelle appartient la cible revienne.
Un langage de programmation ou un runtime peut choisir d'implémenter ses propres piles non contiguës ou mobiles pour éviter les limitations des piles du système d'exploitation. Golang utilise de telles piles personnalisées pour prendre en charge un très grand nombre de co-routines, initialement implémentées en tant que mémoire non contiguë et maintenant via des piles mobiles grâce au suivi du pointeur (voir le commentaire de Hobb). Python sans pile, Lua et Erlang peuvent é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 faible, car l'espace d'adressage est important et la mémoire physique n'est allouée que lorsque vous l'utilisez réellement.
En pratique, il est difficile (et parfois impossible) d'agrandir la pile. Comprendre pourquoi nécessite une certaine compréhension de la mémoire virtuelle.
Dans Ye Olde Days d'applications monothread et de mémoire contiguë, trois étaient trois composants d'un espace d'adressage de processus: le code, le tas et la pile. La façon dont ces trois étaient disposés dépendait du système d'exploitation, mais généralement le code venait en premier, en commençant par le bas de la mémoire, le tas venait ensuite et augmentait vers le haut, et la pile commençait en haut de la mémoire et augmentait vers le bas. Il y avait également de la mémoire réservée au système d'exploitation, mais nous pouvons l'ignorer. À cette époque, les programmes avaient des débordements de pile un peu plus dramatiques: la pile plantait dans le tas, et en fonction de celle qui était mise à jour en premier, vous travailliez avec des données incorrectes ou retourniez d'un sous-programme dans une partie arbitraire de la mémoire.
La gestion de la mémoire a quelque peu changé ce modèle: du point de vue du programme, vous disposiez toujours des trois composants d'une carte de mémoire de processus, et ils étaient généralement organisés de la même manière, mais maintenant chacun des composants était géré comme un segment indépendant et le MMU signalerait le système d'exploitation si le programme tentait d'accéder à la mémoire en dehors d'un segment. Une fois que vous aviez de la mémoire virtuelle, il n'était pas nécessaire ou désir de donner à un programme l'accès à tout son espace d'adressage Les segments ont donc été affectés de 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 de validation" contre le swap; à tout moment, une partie ou la totalité de la mémoire d'un programme peut devoir être écrite pour permuter pour faire de la place à la mémoire d'un autre programme. Si chaque programme pouvait potentiellement consommer 2 Go de swap, alors vous devriez fournir suffisamment de swap pour tous vos programmes ou prendre 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 pourriez étendre ces segments si nécessaire, et le segment de données (tas) augmente en fait au fil du temps: vous commencez avec un petit segment de données, et quand l'allocateur de mémoire demande plus d'espace lorsque cela 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 intercepter 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, à nouveau de taille fixe. Mais maintenant, les segments sont disposés les uns après les autres dans l'espace d'adressage virtuel, il n'y a donc aucun moyen de développer un segment sans en déplacer un autre - ce que vous ne pouvez pas faire car le programme aura potentiellement des pointeurs vers la mémoire vivant dans la pile. Vous pouvez également laisser un espace entre les segments, mais cet espace serait gaspillé dans presque tous les cas. Une meilleure approche était de mettre la charge sur le développeur de l'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 des piles effectivement infinies pour un nombre effectivement infini de threads. Mais encore une fois, ce n'est pas particulièrement souhaitable: dans presque tous les cas, un débordement de pile indique un bogue avec votre code. Vous fournir une pile de 1 Go diffère simplement la découverte de ce bogue.
La pile ayant une taille maximale fixe n'est pas omniprésente.
Il est également difficile de bien faire les choses: les profondeurs de pile suivent une distribution de loi de puissance, ce qui signifie que peu importe la taille de la pile, il y aura toujours une fraction importante de fonctions avec des piles encore plus petites (vous perdrez donc de l'espace), et quelle que soit la taille de votre création, il y aura toujours des fonctions avec des piles encore plus grandes (donc vous forcez une erreur de dépassement de pile pour les fonctions qui n'ont pas d'erreur). En d'autres termes: quelle que soit la taille que vous choisissez, elle sera toujours à la fois trop petite et trop grande en même temps.
Vous pouvez résoudre le premier problème en permettant aux piles de commencer petit et de se développer dynamiquement, mais vous avez toujours le deuxième problème. Et si vous autorisez quand même la pile à se développer dynamiquement, alors pourquoi lui imposer une limite arbitraire?
Il existe des systèmes où les piles peuvent croître dynamiquement et n'ont pas de taille maximale: Erlang, Go, Smalltalk et Scheme, par exemple. Il existe de nombreuses façons de mettre en œuvre quelque chose comme ça:
Dès que vous disposez de puissantes constructions de flux de contrôle non locales, l'idée d'une seule pile contiguë sort quand même de la fenêtre: les exceptions et les continuations pouvant être reprises, par exemple, "bifurquent" la pile, vous vous retrouvez donc avec un réseau de piles (par exemple implémenté avec une pile de spaghetti). En outre, les systèmes avec des piles modifiables de première classe, tels que Smalltalk, nécessitent à peu près des piles de spaghetti ou quelque chose de similaire.
Le système d'exploitation doit donner un bloc contigu lorsqu'une pile est demandée. La seule façon de le faire est de spécifier une taille maximale.
Par exemple, supposons que la mémoire ressemble à ceci lors de la requête (les X représentent les utilisés, les Os non utilisés):
XOOOXOOXOOOOOX
Si une demande pour une taille de pile de 6, la réponse du système d'exploitation répondra non, même si plus de 6 sont disponibles. Si une demande pour une pile de taille 3, 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, donc quand ils grandissent, ils ne prennent que de l'espace, ils n'ont pas besoin d'être contigus car ils sont 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 plus grande est requise.
Sous Linux, il s'agit purement d'une limite de ressources qui existe pour tuer les processus incontrôlables 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 logicielle à tout montant. Cependant, tant que le programmeur n'a aucune raison de croire que le programme a vraiment besoin de quantités inhabituelles de mémoire de pile, le processus sera interrompu lorsqu'il dépassera une taille de pile de huit mégaoctets.
En raison de cette limite, un processus d'emballement (récursion infinie involontaire) est tué longtemps avant de commencer à consommer des quantités de mémoire si importantes que le système est obligé de commencer à échanger. Cela peut faire la différence entre un processus en panne et un serveur en panne. Cependant, il ne limite pas les programmes ayant un besoin légitime d'une grande pile, il leur suffit de définir la limite souple à une valeur appropriée.
Techniquement, les piles augmentent de manière dynamique: lorsque la limite logicielle est définie sur huit mégaoctets, cela ne signifie pas que cette quantité de mémoire a déjà été mappée. Ce serait plutôt du gaspillage car la plupart des programmes n'atteignent jamais leurs limites logicielles respectives. Au contraire, 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 véritable limitation 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 d'adressage de 16 zebibytes).
La taille de pile maximale est statique car il s'agit de la définition de "maximum" . Toute sorte de maximum sur quoi que ce soit est un chiffre limite fixe et convenu. S'il se comporte comme une cible se déplaçant spontanément, ce n'est pas un maximum.
Les piles sur les systèmes d'exploitation à mémoire virtuelle font en fait croître dynamiquement, jusqu'au maximum.
En parlant de ça, ça n'a pas besoin d'être statique. Au contraire, il peut même être configurable, sur une base par processus ou par thread.
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)?
L'une des raisons est que la plupart des algorithmes ne nécessitent pas une énorme quantité d'espace de pile. Une grande pile est une indication d'une possible récursivité galopante. Il est bon d'arrêter la récursivité galopante avant d'allouer toute la mémoire disponible. Un problème qui ressemble à une récursivité galopante est l'utilisation dégénérée de la pile, peut-être déclenchée par un cas de test inattendu. Par exemple, supposons qu'un analyseur pour un binaire, l'opérateur infixe fonctionne en récursif sur l'opérande de droite: analyser le premier opérande, opérateur de scan, analyser le reste de l'expression. Cela signifie que la profondeur de pile est proportionnelle à la longueur de l'expression: a op b op c op d ...
. Un énorme cas de test de ce formulaire nécessitera une énorme pile. L'abandon du programme lorsqu'il atteint une limite de pile raisonnable le rattrapera.
Une autre raison pour une taille de pile maximale fixe est que l'espace virtuel pour cette pile peut être réservé via un type spécial de mappage, et donc garanti. Garanti signifie que l'espace ne sera pas donné à une autre allocation que la pile entrera en collision avec elle avant d'atteindre la limite. Le paramètre de taille de pile maximale est requis pour demander ce mappage.
Les threads ont besoin d'une taille de pile maximale pour une raison similaire à cela. 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.