Perché lo stack di chiamate ha una dimensione massima statica?


46

Avendo lavorato con alcuni linguaggi di programmazione, mi sono sempre chiesto perché lo stack di thread abbia una dimensione massima predefinita, invece di espandersi automaticamente come richiesto. 

In confronto, alcune strutture di alto livello molto comuni (elenchi, mappe, ecc.) Che si trovano nella maggior parte dei linguaggi di programmazione sono progettate per crescere come richiesto mentre vengono aggiunti nuovi elementi, essendo di dimensioni limitate solo dalla memoria disponibile o da limiti computazionali ( es. indirizzamento a 32 bit).

Non sono a conoscenza di linguaggi di programmazione o ambienti di runtime in cui la dimensione massima dello stack non è pre-limitata da alcune opzioni predefinite o del compilatore. Questo è il motivo per cui una ricorsione eccessiva si tradurrà molto rapidamente in un errore / eccezione di overflow dello stack onnipresente, anche quando per lo stack viene utilizzata solo una percentuale minima della memoria disponibile per un processo.

Perché la maggior parte degli ambienti di runtime (se non tutti) imposta un limite massimo per la dimensione che uno stack può aumentare in fase di runtime?


13
Questo tipo di stack è uno spazio di indirizzi continuo che non può essere spostato silenziosamente dietro le quinte. Lo spazio degli indirizzi è prezioso su sistemi a 32 bit.
CodesInChaos,

7
Per ridurre il verificarsi di idee sulla torre d'avorio come la ricorsione che fuoriesce dal mondo accademico e causando problemi nel mondo reale come una ridotta leggibilità del codice e un aumento del costo totale di proprietà;)
Brad Thomas

6
@BradThomas Ecco a cosa serve l'ottimizzazione delle chiamate in coda.
JAB,

3
@JohnWu: La stessa cosa che fa ora, solo un po 'più tardi: esaurire la memoria.
Jörg W Mittag,

1
Nel caso in cui non sia ovvio, uno dei motivi per cui esaurire la memoria è peggio che esaurire lo stack, è che (supponendo che ci sia una pagina trap) che esaurisce lo stack provoca solo il fallimento del processo. A corto di memoria potrebbe causare qualche cosa di fallire, chiunque prossimi cerca di fare un'allocazione di memoria. Inoltre, su un sistema senza una pagina trap o altri mezzi per rilevare lo stack eccessivo, esaurire lo stack può essere catastrofico, portandoti a comportamenti indefiniti. Su un sistema del genere preferiresti esaurire la memoria di archiviazione libera e semplicemente non puoi scrivere codice con ricorsione illimitata.
Steve Jessop,

Risposte:


13

È possibile scrivere un sistema operativo che non richiede che gli stack siano contigui nello spazio degli indirizzi. Fondamentalmente hai bisogno di qualche casino in più nella convenzione di chiamata, per assicurarti che:

  1. se non c'è abbastanza spazio nell'estensione dello stack corrente per la funzione che stai chiamando, allora crei una nuova estensione dello stack e sposti il ​​puntatore dello stack per puntare all'inizio di esso come parte della chiamata.

  2. quando torni da quella chiamata, ritorni all'estensione dello stack originale. Molto probabilmente manterrai quello creato in (1) per un uso futuro da parte dello stesso thread. In linea di principio è possibile rilasciarlo, ma in questo modo si trovano casi piuttosto inefficienti in cui si continua a saltare avanti e indietro attraverso il confine in un ciclo e ogni chiamata richiede l'allocazione di memoria.

  3. setjmpe longjmp, o qualunque equivalente il tuo sistema operativo abbia per il trasferimento di controllo non locale, è in atto e può tornare correttamente al vecchio stack quando richiesto.

Dico "convenzione di chiamata" - per essere precisi penso che probabilmente sia meglio farlo in un prologo di funzione piuttosto che dal chiamante, ma il mio ricordo di ciò è confuso.

La ragione per cui alcune lingue specificano una dimensione dello stack fissa per un thread, è che vogliono lavorare usando lo stack nativo, su sistemi operativi che non lo fanno. Come affermano le risposte di tutti gli altri, supponendo che ogni stack debba essere contiguo nello spazio degli indirizzi e non possa essere spostato, è necessario riservare un intervallo di indirizzi specifico per l'utilizzo da parte di ciascun thread. Ciò significa scegliere una taglia in avanti. Anche se lo spazio degli indirizzi è enorme e le dimensioni che scegli sono davvero grandi, devi comunque sceglierlo non appena hai due thread.

"Ah" dici "che cosa sono questi presunti sistemi operativi che usano stack non contigui? Scommetto che è un oscuro sistema accademico che non mi serve!". Bene, questa è un'altra domanda che fortunatamente è già stata posta e risposta.


36

Tali strutture dati in genere hanno proprietà che lo stack del sistema operativo non ha:

  • Gli elenchi collegati non richiedono spazi di indirizzi contigui. Quindi possono aggiungere un pezzo di memoria da dove vogliono quando crescono.

  • Anche le raccolte che necessitano di archiviazione contigua, come il vettore di C ++, presentano un vantaggio rispetto agli stack del sistema operativo: possono dichiarare non validi tutti i puntatori / iteratori ogni volta che crescono. D'altro canto, lo stack del sistema operativo deve mantenere validi i puntatori allo stack fino a quando non viene restituita la funzione al cui frame appartiene la destinazione.

Un linguaggio di programmazione o runtime può scegliere di implementare i propri stack non contigui o mobili per evitare le limitazioni del sistema operativo stack. Golang utilizza tali stack personalizzati per supportare un numero molto elevato di co-routine, originariamente implementate come memoria non contigua e ora tramite stack mobili grazie al tracciamento del puntatore (vedi il commento di hobb). Python senza stack, Lua ed Erlang potrebbero anche usare stack personalizzati, ma non ho confermato.

Sui sistemi a 64 bit è possibile configurare stack relativamente grandi con costi relativamente bassi, poiché lo spazio degli indirizzi è abbondante e la memoria fisica viene allocata solo quando la si utilizza effettivamente.


1
Questa è una buona risposta e seguo il tuo significato, ma il termine non è un blocco di memoria "contiguo" anziché "continuo", poiché ogni unità di memoria ha il suo indirizzo univoco?
DanK,

2
+1 per "uno stack di chiamate non deve essere limitato" Spesso viene implementato in questo modo per semplicità e prestazioni, ma non deve essere così.
Paul Draper,

Hai ragione su Go. In realtà, la mia comprensione è che le vecchie versioni avevano stack non contigui e le nuove versioni hanno stack mobili . In entrambi i casi, è necessario consentire un gran numero di goroutine. La preallocazione di alcuni megabyte per goroutine per lo stack li renderebbe troppo costosi per servire correttamente il loro scopo.
Hobbs,

@hobbs: Sì, Go ha iniziato con stack coltivabili, tuttavia è stato difficile renderli veloci. Quando Go ottenne un preciso Garbage Collector, si appoggiò su di esso per implementare stack mobili: quando lo stack si sposta, la mappa dei tipi precisa viene utilizzata per aggiornare i puntatori allo stack precedente.
Matthieu M.

26

In pratica, è difficile (e talvolta impossibile) far crescere lo stack. Per capire perché richiede una certa comprensione della memoria virtuale.

In Ye Olde Days, con applicazioni a thread singolo e memoria contigua, tre erano tre componenti di uno spazio degli indirizzi di processo: il codice, l'heap e lo stack. Il modo in cui quei tre erano disposti dipendeva dal sistema operativo, ma in genere il codice veniva prima, iniziando dal fondo della memoria, l'heap veniva poi e cresceva verso l'alto, e lo stack iniziava in cima alla memoria e cresceva verso il basso. C'era anche un po 'di memoria riservata al sistema operativo, ma possiamo ignorarlo. I programmi in quei giorni avevano degli overflow dello stack in qualche modo più drammatici: lo stack si schiantava nell'heap e, a seconda di quale aggiornamento veniva prima, si lavorava con dati errati o si tornava da una subroutine a una parte arbitraria della memoria.

La gestione della memoria ha in qualche modo cambiato questo modello: dal punto di vista del programma c'erano ancora i tre componenti di una mappa di memoria di processo, ed erano generalmente organizzati allo stesso modo, ma ora ciascuno dei componenti era gestito come un segmento indipendente e la MMU segnalava Sistema operativo se il programma ha tentato di accedere alla memoria all'esterno di un segmento. Una volta che avevi memoria virtuale, non c'era bisogno o desiderio di dare a un programma l'accesso a tutto il suo spazio di indirizzi. Quindi ai segmenti sono stati assegnati limiti fissi.

Quindi perché non è desiderabile dare a un programma l'accesso al suo spazio di indirizzi completo? Perché quella memoria costituisce una "commissione di commit" contro lo swap; in qualsiasi momento potrebbe essere necessario scrivere una parte o tutta la memoria per un programma per fare spazio alla memoria di un altro programma. Se ogni programma potrebbe potenzialmente consumare 2 GB di scambio, allora dovresti fornire uno scambio sufficiente per tutti i tuoi programmi o avere la possibilità che due programmi avrebbero bisogno di più di quanto potrebbero ottenere.

A questo punto, assumendo uno spazio di indirizzi virtuale sufficiente, è possibile estendere questi segmenti, se necessario, e il segmento di dati (heap) infatti aumenta nel tempo: si inizia con un piccolo segmento di dati e quando l'allocatore di memoria richiede più spazio quando è necessario. A questo punto, con un singolo stack, sarebbe stato fisicamente possibile estendere il segmento dello stack: il sistema operativo potrebbe intrappolare il tentativo di spingere qualcosa al di fuori del segmento e aggiungere più memoria. Ma neanche questo è particolarmente desiderabile.

Inserisci il multi-threading. In questo caso, ogni thread ha un segmento di stack indipendente, di nuovo a dimensione fissa. Ma ora i segmenti sono disposti uno dopo l'altro nello spazio degli indirizzi virtuali, quindi non c'è modo di espandere un segmento senza spostarne un altro, cosa che non si può fare perché il programma avrà potenzialmente puntatori alla memoria che vivono nello stack. In alternativa, potresti lasciare un po 'di spazio tra i segmenti, ma quello spazio sarebbe sprecato in quasi tutti i casi. Un approccio migliore è stato quello di gravare sullo sviluppatore dell'applicazione: se avessi davvero bisogno di stack profondi, potresti specificarlo durante la creazione del thread.

Oggi, con uno spazio di indirizzi virtuali a 64 bit, potremmo creare stack effettivamente infiniti per un numero effettivamente infinito di thread. Ma ancora una volta, questo non è particolarmente desiderabile: in quasi tutti i casi, uno stack overlow indica un bug con il tuo codice. Fornire uno stack da 1 GB difende semplicemente la scoperta di quel bug.


3
Le attuali CPU x86-64 hanno solo 48 bit di spazio degli indirizzi
CodesInChaos

Dopo tutto, Linux fa crescere lo stack in modo dinamico: quando un processo tenta di accedere all'area proprio sotto lo stack attualmente allocato, l'interruzione viene gestita semplicemente mappando una pagina aggiuntiva di memoria dello stack, anziché segfault il processo.
cmaster

2
@cmaster: vero, ma non è ciò che kdgregory significa "crescere lo stack". C'è un intervallo di indirizzi attualmente designato per l'uso come stack. Stai parlando di mappare gradualmente più memoria fisica in quell'intervallo di indirizzi quando è necessario. kdgregory sta dicendo che è difficile o impossibile aumentare l'intervallo.
Steve Jessop,

x86 non è la sola architettura e 48 bit è ancora effettivamente infinito
kdgregory

1
A proposito, ricordo che i miei giorni di lavoro con x86 non erano così divertenti, principalmente a causa della necessità di gestire la segmentazione. Preferivo di gran lunga i progetti su piattaforme MC68k ;-)
kdgregory,

4

Lo stack con una dimensione massima fissa non è onnipresente.

È anche difficile ottenere il giusto: le profondità dello stack seguono una distribuzione della legge di potenza, il che significa che, indipendentemente da quanto si riduca la dimensione dello stack, ci sarà comunque una frazione significativa di funzioni con stack ancora più piccoli (quindi, si spreca spazio), e non importa quanto sia grande, ci saranno ancora funzioni con stack ancora più grandi (quindi si forza un errore di overflow dello stack per funzioni che non hanno errori). In altre parole: qualunque sia la dimensione scelta, sarà sempre sia troppo piccola che troppo grande allo stesso tempo.

Puoi risolvere il primo problema consentendo agli stack di iniziare in piccolo e crescere dinamicamente, ma poi hai ancora il secondo problema. E se permetti allo stack di crescere in modo dinamico, allora perché metterne un limite arbitrario?

Esistono sistemi in cui gli stack possono crescere in modo dinamico e non hanno dimensioni massime: Erlang, Go, Smalltalk e Scheme, ad esempio. Esistono molti modi per implementare qualcosa del genere:

  • pile mobili: quando la pila contigua non può più crescere perché c'è qualcos'altro sulla strada, spostala in un'altra posizione in memoria, con più spazio libero
  • stack non contigui: invece di allocare l'intero stack in un singolo spazio di memoria contiguo, allocarlo in più spazi di memoria
  • stack allocati in heap: invece di avere aree di memoria separate per stack e heap, basta allocare lo stack sull'heap; come hai notato te stesso, le strutture di dati allocate in heap tendono a non avere problemi a crescere e restringersi secondo necessità
  • non usare affatto le pile: anche questa è un'opzione, ad esempio invece di tenere traccia dello stato della funzione in una pila, fare in modo che la funzione passi una continuazione alla chiamata

Non appena si hanno potenti costrutti di flusso di controllo non locali, l'idea di un singolo stack contiguo esce comunque dalla finestra: eccezioni e continuazioni ripristinabili, ad esempio, "forzano" lo stack, quindi si finisce con una rete di pile (ad esempio implementato con una pila di spaghetti). Inoltre, i sistemi con stack modificabili di prima classe, come Smalltalk richiedono praticamente stack di spaghetti o qualcosa di simile.


1

Il sistema operativo deve fornire un blocco contiguo quando viene richiesto uno stack. L'unico modo in cui può farlo è se viene specificata una dimensione massima.

Ad esempio, supponiamo che la memoria assomigli a questa durante la richiesta (Xs utilizzato, Os non utilizzato):

XOOOXOOXOOOOOX

Se una richiesta per una dimensione dello stack di 6, la risposta del sistema operativo risponderà no, anche se sono disponibili più di 6. Se una richiesta per uno stack di dimensioni 3, la risposta del sistema operativo sarà una delle aree di 3 slot vuoti (O) di fila.

Inoltre, si può vedere la difficoltà di consentire la crescita quando viene occupata la successiva area contigua.

Gli altri oggetti citati (Elenchi, ecc.) Non vanno in pila, finiscono nell'heap in aree non contigue o frammentate, quindi quando crescono prendono solo spazio, non richiedono contigui come sono gestito diversamente.

La maggior parte dei sistemi imposta un valore ragionevole per le dimensioni dello stack, è possibile sovrascriverlo quando viene costruito il thread se è richiesta una dimensione maggiore.


1

Su Linux, questo è puramente un limite di risorse che esiste per uccidere i processi in fuga prima che consumino quantità dannose della risorsa. Sul mio sistema debian, il seguente codice

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

produce l'output

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

Si noti che il limite rigido è impostato su RLIM_INFINITY: Il processo può aumentare il limite soft a qualsiasi importo. Tuttavia, finché il programmatore non ha motivo di credere che il programma abbia davvero bisogno di quantità insolite di memoria dello stack, il processo verrà interrotto quando supera una dimensione dello stack di otto mebibyte.

A causa di questo limite, un processo in fuga (ricorsione infinita involontaria) viene interrotto molto tempo prima che inizi a consumare così grandi quantità di memoria che il sistema è costretto a iniziare lo scambio. Ciò può fare la differenza tra un processo bloccato e un server bloccato. Tuttavia, non limita i programmi con una necessità legittima per uno stack di grandi dimensioni, devono solo impostare il limite software su un valore appropriato.


Tecnicamente, gli stack crescono dinamicamente: quando il limite morbido è impostato su otto mebibyte, ciò non significa che questa quantità di memoria sia stata effettivamente mappata. Ciò sarebbe piuttosto dispendioso poiché la maggior parte dei programmi non arriva mai vicino ai rispettivi limiti soft. Piuttosto, il kernel rileverà gli accessi sotto lo stack e, se necessario, eseguirà la mappatura delle pagine di memoria. Pertanto, l'unica vera limitazione della dimensione dello stack è la memoria disponibile su sistemi a 64 bit (la frammentazione dello spazio degli indirizzi è piuttosto teorica con una dimensione dello spazio degli indirizzi di 16 zebibyte).


2
Questo è lo stack solo per il primo thread. I nuovi thread devono allocare nuovi stack e sono limitati perché si imbatteranno in altri oggetti.
Zan Lynx,

0

La dimensione massima dello stack è statica perché questa è la definizione di "massimo" . Qualsiasi tipo di massimo su qualsiasi cosa è una cifra limite fissa, concordata. Se si comporta come un bersaglio che si muove spontaneamente, non è un massimo.

Le pile su sistemi operativi a memoria virtuale crescono infatti dinamicamente, fino al massimo .

A proposito, non deve essere statico. Piuttosto, può essere configurabile, anche per processo o per thread.

Se la domanda è "perché è lì una dimensione massima dello stack" (un uno artificialmente imposto, di solito molto meno di memoria disponibile)?

Uno dei motivi è che la maggior parte degli algoritmi non richiede un'enorme quantità di spazio nello stack. Una grande pila è un'indicazione di una possibile ricorsione in fuga . È utile interrompere la ricorsione in fuga prima di allocare tutta la memoria disponibile. Un problema che sembra una ricorsione in fuga è l'uso degenerato dello stack, forse innescato da un caso di test imprevisto. Ad esempio, supponiamo che un parser per un operatore binario, infix funzioni ricorrendo all'operando corretto: analizza il primo operando, scansiona l'operatore, analizza il resto dell'espressione. Ciò significa che la profondità dello stack è proporzionale alla lunghezza della espressione: a op b op c op d .... Un enorme test case di questo modulo richiederà uno stack enorme. L'annullamento del programma quando raggiunge un limite di stack ragionevole prenderà questo.

Un altro motivo per una dimensione massima dello stack fissa è che lo spazio virtuale per quello stack può essere prenotato tramite un tipo speciale di mappatura, e quindi garantito. Garantito significa che lo spazio non verrà assegnato a un'altra allocazione che lo stack si scontrerà con esso prima di raggiungere il limite. Per richiedere questa mappatura è necessario il parametro di dimensione massima dello stack.

I thread hanno bisogno di una dimensione massima dello stack per un motivo simile a questo. Le loro pile sono create dinamicamente e non possono essere spostate se si scontrano con qualcosa; lo spazio virtuale deve essere prenotato in anticipo e per tale allocazione è necessaria una dimensione.


@Lynn Non ha chiesto perché la dimensione massima fosse statica, ha chiesto perché era predefinita.
Will Calderwood
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.