Le pile sono l'unico modo ragionevole per strutturare i programmi?


74

La maggior parte delle architetture che ho visto si basano su uno stack di chiamate per salvare / ripristinare il contesto prima delle chiamate di funzione. È un paradigma così comune che le operazioni push e pop sono integrate nella maggior parte dei processori. Ci sono sistemi che funzionano senza stack? In tal caso, come funzionano e a cosa servono?


5
Dato come ci si aspetta che le funzioni si comportino in linguaggi simili a C (cioè è possibile nidificare le chiamate nel modo desiderato e tornare indietro in ordine inverso), non mi è chiaro come si possano implementare le chiamate di funzione senza che sia incredibilmente inefficiente. Ad esempio, potresti forzare il programmatore a utilizzare lo stile di passaggio di continuazione o qualche altra forma bizzarra di programmazione, ma nessuno sembra effettivamente lavorare con CPS molto a basso livello per qualche motivo.
Kevin,

5
GLSL funziona senza stack (come fanno altre lingue in quella specifica parentesi). Non consente semplicemente le chiamate ricorsive.
Leushenko,

3
È inoltre possibile esaminare le finestre di registro , utilizzate da alcune architetture RISC.
Mark Booth,

2
@Kevin: "I primi compilatori FORTRAN non supportavano alcuna ricorsione nelle subroutine. Le prime architetture informatiche non supportavano il concetto di stack e quando supportavano direttamente le chiamate di subroutine, la posizione di ritorno veniva spesso memorizzata in una posizione fissa adiacente al codice di subroutine, che non consentire a una subroutine di essere richiamata prima che venga restituita una precedente chiamata della subroutine. Sebbene non sia specificato in Fortran 77, molti compilatori F77 supportano la ricorsione come opzione, mentre è diventato uno standard in Fortran 90. " en.wikipedia.org/wiki/Fortran#FORTRAN_II
Mooing Duck

3
Il microcontrollore P8X32A ("Propeller") non ha il concetto di stack nel suo linguaggio assembly standard (PASM). Le istruzioni responsabili del salto modificano anche automaticamente le istruzioni di ritorno nella RAM per determinare dove tornare - che può essere scelto arbitrariamente. È interessante notare che il linguaggio "Spin" (un linguaggio di alto livello interpretato che gira sullo stesso chip) ha una semantica stack tradizionale.
Wossname

Risposte:


50

Un'alternativa (piuttosto) popolare a uno stack di chiamate sono le continuazioni .

Parrot VM è basato sulla continuazione, ad esempio. È completamente senza stack: i dati sono conservati nei registri (come Dalvik o LuaVM, Parrot è basato su registri) e il flusso di controllo è rappresentato con continuazioni (a differenza di Dalvik o LuaVM, che hanno uno stack di chiamate).

Un'altra struttura di dati popolare, utilizzata tipicamente dalle VM Smalltalk e Lisp è lo spaghetti stack, che è un po 'come una rete di stack.

Come ha sottolineato @rwong , lo stile di passaggio di continuazione è un'alternativa a uno stack di chiamate. I programmi scritti in (o trasformati in) stile di passaggio di continuazione non ritornano mai, quindi non è necessario uno stack.

Rispondere alla tua domanda da una prospettiva diversa: è possibile avere uno stack di chiamate senza uno stack separato, allocando i frame dello stack sull'heap. Alcune implementazioni di Lisp e Scheme fanno questo.


4
Dipende dalla tua definizione di pila. Non sono sicuro che avere un elenco collegato (o una matrice di puntatori a o ...) di frame di stack sia tanto "non uno stack" quanto "una diversa rappresentazione di uno stack"; e i programmi nei linguaggi CPS (in pratica) tendono a costruire elenchi di continuazioni effettivamente collegati che sono molto simili agli stack (se non lo hai fatto, potresti dare un'occhiata a GHC, che spinge ciò che chiama "continuazioni" su uno stack lineare per efficienza).
Jonathan Cast,

6
"I programmi scritti in (o trasformati in) stile di passaggio di continuazione non ritornano mai " ... sembra minaccioso.
Rob Penridge,

5
@RobPenridge: è un po 'enigmatico, sono d'accordo. CPS significa che invece di tornare, le funzioni prendono come argomento extra un'altra funzione da chiamare al termine del loro lavoro. Quindi, quando chiami una funzione e hai qualche altro lavoro che devi fare dopo aver chiamato la funzione, invece di aspettare che la funzione ritorni e poi continui con il tuo lavoro, avvolgi il lavoro rimanente ("la continuazione" ) in una funzione e passa quella funzione come argomento aggiuntivo. La funzione che hai chiamato quindi chiama quella funzione invece di tornare, e così via e così via. Nessuna funzione ritorna mai, solo
Jörg W Mittag

3
... chiama la prossima funzione. Pertanto, non è necessario uno stack di chiamate, poiché non è mai necessario tornare indietro e ripristinare lo stato di associazione di una funzione precedentemente chiamata. Invece di portare in giro lo stato passato in modo che tu possa tornare ad esso, ti porti in giro lo stato futuro , se vuoi.
Jörg W Mittag,

1
@jcast: la caratteristica che definisce uno stack è l'IMO che puoi accedere solo all'elemento più in alto. Un elenco di continuazioni, OTOH, ti darebbe accesso a tutte le continuazioni e non solo allo stackframe più in alto. Se ad esempio sono presenti eccezioni ripristinabili in stile Smalltalk, devi essere in grado di attraversare la pila senza farla scoppiare. E avere continuazioni nella lingua pur volendo mantenere l'idea familiare di una pila di chiamate porta a pile di spaghetti, che è fondamentalmente un albero di pile in cui le continuazioni "forzano" la pila.
Jörg W Mittag,

36

Ai vecchi tempi, i processori non avevano istruzioni di stack e i linguaggi di programmazione non supportavano la ricorsione. Nel tempo, sempre più lingue scelgono di supportare la ricorsione e l'hardware ha seguito la suite con capacità di allocazione dei frame dello stack. Questo supporto è variato notevolmente nel corso degli anni con processori diversi. Alcuni processori hanno adottato i registri dello stack frame e / o del puntatore dello stack; alcune hanno adottato istruzioni che porterebbero a termine l'assegnazione dei frame dello stack in un'unica istruzione.

Man mano che i processori avanzavano con cache a livello singolo, quindi multilivello, un vantaggio fondamentale dello stack è quello della localizzazione della cache. La parte superiore dello stack è quasi sempre nella cache. Ogni volta che puoi fare qualcosa che ha un alto tasso di hit della cache, sei sulla strada giusta con i processori moderni. La cache applicata allo stack significa che le variabili locali, i parametri, ecc. Sono quasi sempre nella cache e godono del massimo livello di prestazioni.

In breve, l'utilizzo dello stack si è evoluto sia in hardware che in software. Esistono altri modelli (ad esempio il calcolo del flusso di dati è stato provato per un lungo periodo), tuttavia, la località dello stack lo fa funzionare davvero bene. Inoltre, il codice procedurale è proprio ciò che i processori vogliono, per prestazioni: un'istruzione che dice cosa fare dopo l'altra. Quando le istruzioni non sono in ordine lineare, il processore rallenta enormemente, almeno per il momento, dal momento che non abbiamo capito come rendere l'accesso casuale veloce come l'accesso sequenziale. (A proposito, ci sono problemi simili a ogni livello di memoria, dalla cache, alla memoria principale, al disco ...)

Tra le prestazioni dimostrate delle istruzioni di accesso sequenziale e il comportamento di memorizzazione nella cache utile dello stack di chiamate, abbiamo, almeno al momento, un modello di prestazioni vincente.

(Potremmo lanciare anche la mutabilità delle strutture dati nelle opere ...)

Ciò non significa che altri modelli di programmazione non possano funzionare, specialmente quando possono essere tradotti in istruzioni sequenziali e chiamare il modello stack dell'hardware di oggi. Ma c'è un netto vantaggio per i modelli che supportano l'hardware. Tuttavia, le cose non rimangono sempre le stesse, quindi potremmo vedere cambiamenti in futuro poiché diverse tecnologie di memoria e transistor consentono un maggiore parallelismo. È sempre un gioco di parole tra i linguaggi di programmazione e le capacità hardware, quindi vedremo!


9
In effetti, le GPU non hanno ancora stack. È vietato ricorrere in GLSL / SPIR-V / OpenCL (non sono sicuro di HLSL, ma probabilmente non vedo alcun motivo per cui sarebbe diverso). Il modo in cui gestiscono effettivamente le "pile" di chiamate di funzione è utilizzando un numero assurdamente elevato di registri.
LinearZoetrope,

@Jsor: Questo è in gran parte un dettaglio dell'implementazione, come si può vedere dall'architettura SPARC. Come la tua GPU, SPARC ha un enorme set di registri, ma è unico in quanto ha una finestra scorrevole che su wraparound riversa i registri molto vecchi in uno stack nella RAM. Quindi è davvero un ibrido tra i due modelli. E SPARC non ha specificato esattamente quanti registri fisici esistessero, quanto fosse grande la finestra dei registri, quindi diverse implementazioni potrebbero trovarsi ovunque su quella scala di "enormi quantità di registri" a "appena sufficienti per una finestra, su ogni chiamata di funzione versare direttamente per impilare "
MSalters il

Un aspetto negativo del modello di stack di chiamate è che l'array e / o l'overflow degli indirizzi devono essere osservati con molta attenzione, poiché sono possibili programmi di auto-modifica come exploit se sono eseguibili bit arbitrari dell'heap.
BenPen,

14

TL; DR

  • Stack di chiamate come meccanismo di chiamata di funzione:
    1. In genere è simulato dall'hardware ma non è fondamentale per la costruzione dell'hardware
    2. È fondamentale per la programmazione imperativa
    3. Non è fondamentale per la programmazione funzionale
  • Lo stack come astrazione di "last-in, first-out" (LIFO) è fondamentale per l'informatica, gli algoritmi e persino alcuni domini non tecnici.
  • Alcuni esempi di organizzazione del programma che non utilizzano stack di chiamate:
    • Stile di continuazione (CPS)
    • Macchina statale: un anello gigante, con tutto in linea. (Presumibilmente ispirato all'architettura del firmware Saab Gripen e attribuito a una comunicazione di Henry Spencer e riprodotto da John Carmack.) (Nota n. 1)
    • Architettura del flusso di dati - una rete di attori collegati da code (FIFO). Le code sono talvolta chiamate canali.

Il resto di questa risposta è una raccolta casuale di pensieri e aneddoti, e quindi in qualche modo disorganizzata.


Lo stack che hai descritto (come meccanismo di chiamata di funzione) è specifico della programmazione imperativa.

Sotto la programmazione imperativa, troverai il codice macchina. Il codice macchina può emulare lo stack di chiamate eseguendo una piccola sequenza di istruzioni.

Sotto il codice macchina, troverai l'hardware responsabile dell'esecuzione del software. Mentre il moderno microprocessore è troppo complesso per essere descritto qui, si può immaginare che esiste un design molto semplice che è lento ma è ancora in grado di eseguire lo stesso codice macchina. Un design così semplice utilizzerà gli elementi di base della logica digitale:

  1. Logica combinatoria, cioè una connessione di porte logiche (e, o, non, ...) Notare che la "logica combinatoria" esclude i feedback.
  2. Memoria, ad esempio infradito, chiavistelli, registri, SRAM, DRAM, ecc.
  3. Una macchina a stati che consiste di una logica combinatoria e di una memoria, quel tanto che basta per poter implementare un "controller" che gestisce il resto dell'hardware.

Le seguenti discussioni contenevano numerosi esempi di modi alternativi di strutturare i programmi imperativi.

La struttura di tale programma sarà simile a questa:

void main(void)
{
    do
    {
        // validate inputs for task 1
        // execute task 1, inlined, 
        // must complete in a deterministically short amount of time
        // and limited to a statically allocated amount of memory
        // ...
        // validate inputs for task 2
        // execute task 2, inlined
        // ...
        // validate inputs for task N
        // execute task N, inlined
    }
    while (true);
    // if this line is reached, tell the programmers to prepare
    // themselves to appear before an accident investigation board.
    return 0; 
}

Questo stile sarebbe appropriato per i microcontrollori, vale a dire per coloro che vedono il software come un compagno delle funzioni dell'hardware.



@Peteris: gli stack sono strutture di dati LIFO.
Christopher Creutzig

1
Interessante. Avrei pensato il contrario. Ad esempio, FORTRAN è un linguaggio di programmazione indispensabile e le prime versioni non utilizzavano uno stack di chiamate. Tuttavia, la ricorsione è fondamentale per la programmazione funzionale e non credo sia possibile implementarla nel caso generale senza utilizzare uno stack.
TED,

@TED ​​- in un'implementazione del linguaggio funzionale c'è una struttura di dati dello stack (o in genere un albero) che rappresenta i calcoli in sospeso, ma non è necessariamente eseguirla con le istruzioni utilizzando le modalità di indirizzamento orientate allo stack della macchina o anche le istruzioni di chiamata / ritorno (in modo annidato / ricorsivo, forse solo come parte di un ciclo di macchine a stati).
davidbak,

@davidbak - IIRC, un algoritmo ricorsivo praticamente deve essere ricorsivo di coda per poter sbarazzarsi dello stack. Probabilmente ci sono altri casi in cui è possibile ottimizzarlo, ma nel caso generale , è necessario disporre di uno stack . In effetti, mi è stato detto che c'era una prova matematica di questo fluttuare da qualche parte. Questa risposta afferma che è il teorema di Church-Turing (penso basato sul fatto che le macchine di Turing usano una pila?)
TED

1
@TED ​​- Sono d'accordo con te. Credo che la cattiva comunicazione qui sia che ho letto il post dell'OP per parlare di architettura di sistema che significava per me architettura della macchina . Penso che gli altri che hanno risposto qui abbiano la stessa comprensione. Quindi quelli di noi che hanno capito che si tratta del contesto hanno risposto rispondendo che non è necessario uno stack a livello di modalità istruzione / indirizzamento della macchina. Ma vedo che la domanda può anche essere interpretata nel senso che, semplicemente, un sistema linguistico in generale ha bisogno di uno stack di chiamate. Quella risposta è anche no, ma per motivi diversi.
davidbak,

11

No, non necessariamente.

Leggi la vecchia carta di Appel Garbage Collection può essere più veloce di Stack Allocation . Utilizza lo stile di passaggio di continuazione e mostra un'implementazione senza stack.

Si noti inoltre che le vecchie architetture di computer (ad es. IBM / 360 ) non avevano alcun registro stack hardware. Ma il sistema operativo e il compilatore hanno riservato un registro per il puntatore dello stack per convenzione (relativo alle convenzioni di chiamata ) in modo che potessero avere uno stack di chiamate software .

In linea di principio, un compilatore e un ottimizzatore C dell'intero programma potrebbero rilevare il caso (un po 'comune per i sistemi embedded) in cui il grafico della chiamata è staticamente noto e senza ricorsioni (o puntatori a funzioni). In un tale sistema, ogni funzione poteva mantenere il suo indirizzo di ritorno in una posizione statica fissa (ed era così che Fortran77 lavorava nei computer dell'era 1970).

In questi giorni, i processori hanno anche stack di chiamate (e istruzioni di chiamata e restituzione della macchina) consapevoli delle cache della CPU .


1
Abbastanza sicuro FORTRAN ha smesso di usare posizioni di ritorno statiche quando FORTRAN-66 è uscito e ha richiesto il supporto per SUBROUTINEe FUNCTION. Tuttavia, hai ragione per le versioni precedenti (FORTRAN-IV e possibilmente WATFIV).
TMN,

E COBOL, ovviamente. E un punto eccellente su IBM / 360: ha ottenuto un notevole uso anche se mancano le modalità di indirizzamento dello stack hardware. (R14, credo di sì?) E aveva compilatori per linguaggi basati su stack, ad esempio PL / I, Ada, Algol, C.
davidbak,

In effetti, ho studiato il 360 al college e all'inizio l'ho trovato sconcertante.
JDługosz,

1
@ JDługosz Il modo migliore per gli studenti moderni di architettura informatica di considerare la 360 è come una macchina RISC molto semplice ... anche se con più di un formato di istruzione ... e alcune anomalie come TRe TRT.
davidbak,

Che ne dici di "zero e aggiungi imballati" per spostare un registro? Ma "branch and link" anziché stack per l'indirizzo di ritorno ha fatto un ritorno.
JDługosz,

10

Finora hai delle buone risposte; lascia che ti dia un esempio poco pratico ma altamente educativo di come potresti progettare una lingua senza la nozione di stack o "flusso di controllo". Ecco un programma che determina i fattoriali:

function f(i) => if i == 0 then 1 else i * f(i - 1)
let x = f(3)

Inseriamo questo programma in una stringa e valutiamo il programma mediante sostituzione testuale. Quindi, quando stiamo valutando f(3), facciamo una ricerca e sostituiamo con 3 per i, in questo modo:

function f(i) => if i == 0 then 1 else i * f(i - 1)
let x = if 3 == 0 then 1 else 3 * f(3 - 1)

Grande. Ora eseguiamo un'altra sostituzione testuale: vediamo che la condizione del "if" è falsa e facciamo sostituire un'altra stringa, producendo il programma:

function f(i) => if i == 0 then 1 else i * f(i - 1)
let x = 3 * f(3 - 1)

Ora facciamo un'altra sostituzione di stringa su tutte le sottoespressioni che coinvolgono costanti:

function f(i) => if i == 0 then 1 else i * f(i - 1)
let x = 3 * f(2)

E vedi come va; Non affronterò ulteriormente il punto. Potremmo continuare a fare una serie di sostituzioni di stringhe fino a quando non avremo finito let x = 6e avremmo finito.

Usiamo lo stack tradizionalmente per variabili locali e informazioni di continuazione; ricorda, uno stack non ti dice da dove vieni, ti dice dove andrai dopo con quel valore di ritorno in mano.

Nel modello di programmazione della sostituzione di stringhe, non ci sono "variabili locali" nello stack; i parametri formali vengono sostituiti con i loro valori quando la funzione viene applicata al suo argomento, anziché essere inserita in una tabella di ricerca nello stack. E non c'è "andare da qualche parte dopo" perché la valutazione del programma sta semplicemente applicando regole semplici per la sostituzione delle stringhe per produrre un programma diverso ma equivalente.

Ora, ovviamente, fare delle sostituzioni di stringhe probabilmente non è la strada da percorrere. Ma i linguaggi di programmazione che supportano il "ragionamento equazionale" (come Haskell) usano logicamente questa tecnica.


3
Retina è un esempio di un linguaggio di programmazione basato su Regex che utilizza le operazioni di stringa per il calcolo.
Andrew Piliser,

2
@AndrewPiliser Progettato e realizzato da questo figo .
gatto,

3

Dalla pubblicazione da parte di Parnas nel 1972 di On dei criteri da utilizzare per scomporre i sistemi in moduli , è stato ragionevolmente accettato che nascondere le informazioni nel software è una buona cosa. Ciò segue un lungo dibattito durante gli anni '60 sulla decomposizione strutturale e sulla programmazione modulare.

modularità

Un risultato necessario delle relazioni black-box tra moduli implementati da diversi gruppi in qualsiasi sistema multi-thread richiede un meccanismo per consentire il rientro e un mezzo per tracciare il grafico dinamico delle chiamate del sistema. Il flusso di esecuzione controllato deve passare sia dentro che fuori da più moduli.

Scoping dinamico

Non appena lo scoping lessicale è insufficiente per tenere traccia del comportamento dinamico, è necessaria una contabilità di runtime per tenere traccia della differenza.

Dato che qualsiasi thread (per definizione) ha solo un singolo puntatore di istruzioni corrente, uno stack LIFO è appropriato per tenere traccia di ogni invocazione.

eccezioni

Quindi, mentre il modello di continuazione non mantiene esplicitamente una struttura di dati per lo stack, c'è ancora la chiamata nidificata dei moduli che deve essere mantenuta da qualche parte!

Anche i linguaggi dichiarativi mantengono la cronologia delle valutazioni o, al contrario, appiattiscono il piano di esecuzione per motivi di prestazioni e mantengono i progressi in qualche altro modo.

La struttura ad anello infinito identificata da rwong è comune nelle applicazioni ad alta affidabilità con programmazione statica che non consente molte strutture di programmazione comuni ma richiede che l'intera applicazione sia considerata una casella bianca senza informazioni significative nascoste.

I cicli infiniti simultanei multipli non richiedono alcuna struttura per contenere gli indirizzi di ritorno in quanto non chiamano funzioni, rendendo discutibile la domanda. Se comunicano utilizzando variabili condivise, queste possono facilmente degenerare in analoghi di indirizzi di ritorno in stile Fortran legacy.


1
Ti dipingi in un angolo assumendo " qualsiasi sistema multi-thread". Le macchine a stati finiti accoppiati potrebbero avere più thread nella loro implementazione, ma non richiedono uno stack LIFO. Non vi è alcuna limitazione in FSM che si ritorna a qualsiasi stato precedente, figuriamoci nell'ordine LIFO. Quindi questo è un vero sistema multi-thread per cui non regge. E se ti limiti a una definizione di multi-thread come "stack di chiamate di funzioni indipendenti parallele", finisci con una definizione circolare.
Salterio,

Non leggo la domanda in questo modo. OP ha familiarità con le chiamate di funzione, ma chiede altri sistemi.
Salterio il

@MSalters Aggiornato per incorporare loop infiniti simultanei. Il modello è valido, ma limita la scalabilità. Suggerirei che anche le macchine a stato moderato incorporino chiamate di funzione per consentire il riutilizzo del codice.
Pekka,

2

Tutti i vecchi mainframe (IBM System / 360) non avevano alcuna idea di uno stack. Sul 260, ad esempio, i parametri sono stati costruiti in una posizione fissa in memoria e quando è stata chiamata una subroutine, è stata chiamata R1indicando il blocco di parametri e R14contenente l'indirizzo di ritorno. La routine chiamata, se si desidera chiamare un'altra subroutine, dovrebbe essere memorizzata R14in una posizione nota prima di effettuare quella chiamata.

Questo è molto più affidabile di uno stack perché tutto può essere archiviato in posizioni di memoria fisse stabilite al momento della compilazione e si può garantire al 100% che i processi non si esauriranno mai nello stack. Non esiste nessuno degli "Assegna 1 MB e incrocia le dita" che dobbiamo fare al giorno d'oggi.

Le chiamate di subroutine ricorsive sono state consentite in PL / I specificando la parola chiave RECURSIVE. Intendevano dire che la memoria utilizzata dalla subroutine era allocata dinamicamente anziché staticamente. Ma le chiamate ricorsive erano rare come allora.

Il funzionamento senza stack rende inoltre molto più semplice il multi-threading di massa, motivo per cui vengono spesso fatti tentativi per rendere le lingue moderne prive di significato. Non vi è alcun motivo, ad esempio, perché un compilatore C ++ non possa essere modificato in back-end per utilizzare la memoria allocata dinamicamente anziché gli stack.

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.