Una struttura dello stack viene utilizzata per i processi asincroni?


10

Questa domanda ha una risposta eccellente di Eric Lippert che descrive a cosa serve lo stack. Per anni ho saputo - in generale - che cos'è lo stack e come viene utilizzato, ma parti delle sue risposte mi fanno chiedermi se questa struttura dello stack sia oggi meno utilizzata dove la programmazione asincrona è la norma.

Dalla sua risposta:

lo stack fa parte della reificazione della continuazione in una lingua senza coroutine.

In particolare, la parte senza coroutine di questo mi fa chiedere.

Spiega un po 'di più qui:

Le coroutine sono funzioni che possono ricordare dove si trovavano, cedere il controllo a un'altra coroutine per un po ', quindi riprendere da dove si erano interrotte in seguito, ma non necessariamente immediatamente dopo le rese coroutine appena chiamate. Pensa a "return return" o "wait" in C #, che deve ricordare dove si trovavano quando viene richiesto l'articolo successivo o quando l'operazione asincrona viene completata. Le lingue con coroutine o caratteristiche linguistiche simili richiedono strutture di dati più avanzate rispetto a uno stack per implementare la continuazione.

Questo è eccellente per quanto riguarda lo stack, ma mi lascia con una domanda senza risposta su quale struttura viene utilizzata quando uno stack è troppo semplice per gestire queste funzionalità linguistiche che richiedono strutture di dati più avanzate?

Lo stack sta andando via mentre la tecnologia avanza? Cosa lo sostituisce? È un tipo di cosa ibrido? (ad esempio, il mio programma .NET utilizza uno stack fino a quando non raggiunge una chiamata asincrona, quindi passa a un'altra struttura fino al completamento, a quel punto lo stack viene riavvolto in uno stato in cui può essere sicuro degli elementi successivi, ecc.? )

Che questi scenari siano troppo avanzati per uno stack ha perfettamente senso, ma cosa sostituisce lo stack? Quando ne avevo appreso anni fa, lo stack era lì perché era velocissimo e leggero, un pezzo di memoria allocato all'applicazione lontano dall'heap perché supportava una gestione altamente efficiente per l'attività a portata di mano (gioco di parole?). Che cosa è cambiato?

Risposte:


14

È un tipo di cosa ibrido? (ad esempio, il mio programma .NET utilizza uno stack fino a quando non raggiunge una chiamata asincrona, quindi passa a un'altra struttura fino al completamento, a quel punto lo stack viene riavvolto in uno stato in cui può essere sicuro degli elementi successivi, ecc.? )

Fondamentalmente sì.

Supponiamo di avere

async void MyButton_OnClick() { await Foo(); Bar(); }
async Task Foo() { await Task.Delay(123); Blah(); }

Ecco una spiegazione estremamente semplificata di come vengono reificate le continuazioni. Il vero codice è considerevolmente più complesso, ma questo rende l'idea.

Fai clic sul pulsante. Un messaggio è in coda. Il ciclo di messaggi elabora il messaggio e chiama il gestore di clic, mettendo nello stack l'indirizzo di ritorno della coda dei messaggi. Cioè, la cosa che succede dopo che il gestore è finito è che il ciclo di messaggi deve continuare a funzionare. Quindi la continuazione del gestore è il ciclo.

Il gestore di clic chiama Foo (), mettendo l'indirizzo di ritorno di se stesso nello stack. Cioè, la continuazione di Foo è il resto del gestore di clic.

Foo chiama Task.Delay, mettendo l'indirizzo di ritorno di se stesso nello stack.

Task.Delay fa tutto il necessario per restituire immediatamente un'attività. Lo stack è saltato fuori e siamo di nuovo a Foo.

Foo controlla l'attività restituita per vedere se è stata completata. Non è. La prosecuzione della attendono è quello di chiamare Blah (), in modo da Foo crea un delegato che chiama Blah (), e segni che delegare come la continuazione del compito. (Ho appena fatto una leggera dichiarazione errata; l'hai colta? In caso contrario, lo riveleremo tra un momento.)

Foo quindi crea il proprio oggetto Task, lo contrassegna come incompleto e lo restituisce nello stack al gestore dei clic.

Il gestore di clic esamina l'attività di Foo e scopre che è incompleta. La continuazione dell'attesa nel gestore è di chiamare Bar (), quindi il gestore di clic crea un delegato che chiama Bar () e lo imposta come continuazione dell'attività restituita da Foo (). Quindi restituisce lo stack al loop dei messaggi.

Il ciclo di messaggi continua a elaborare i messaggi. Alla fine la magia del timer creata dall'attività di ritardo fa la sua cosa e pubblica un messaggio in coda dicendo che ora è possibile eseguire la continuazione dell'attività di ritardo. Quindi il loop di messaggi chiama la continuazione dell'attività, mettendosi in pila come al solito. Quel delegato chiama Blah (). Blah () fa quello che fa e ritorna nello stack.

Ora che succede? Ecco la parte difficile. La continuazione dell'attività di ritardo non chiama solo Blah (). Deve anche attivare una chiamata a Bar () , ma quell'attività non conosce Bar!

Foo ha effettivamente creato un delegato che (1) chiama Blah () e (2) chiama la continuazione dell'attività che Foo ha creato e restituito al gestore dell'evento. Ecco come chiamiamo un delegato che chiama Bar ().

E ora abbiamo fatto tutto ciò che dovevamo fare, nell'ordine corretto. Ma non abbiamo mai smesso di elaborare i messaggi nel ciclo dei messaggi per molto tempo, quindi l'applicazione è rimasta reattiva.

Che questi scenari siano troppo avanzati per uno stack ha perfettamente senso, ma cosa sostituisce lo stack?

Un grafico di oggetti attività contenente riferimenti reciproci tramite le classi di chiusura dei delegati. Queste classi di chiusura sono macchine a stati che tengono traccia della posizione dell'attesa eseguita più di recente e dei valori dei locali. Inoltre, nell'esempio fornito, una coda di azioni a stato globale implementata dal sistema operativo e il ciclo di messaggi che esegue tali azioni.

Esercizio: come pensi che tutto funzioni in un mondo senza loop di messaggi? Ad esempio, applicazioni console. aspettare in un'app console è abbastanza diverso; puoi dedurre come funziona da quello che sai finora?

Quando ne avevo appreso anni fa, lo stack era lì perché era velocissimo e leggero, un pezzo di memoria allocato all'applicazione lontano dall'heap perché supportava una gestione altamente efficiente per l'attività a portata di mano (gioco di parole?). Che cosa è cambiato?

Le pile sono una struttura di dati utile quando le vite delle attivazioni del metodo formano uno stack, ma nel mio esempio le attivazioni del gestore di clic, Foo, Bar e Blah non formano uno stack. E quindi la struttura dei dati che rappresenta quel flusso di lavoro non può essere uno stack; piuttosto è un grafico delle attività e dei delegati allocati in heap che rappresenta un flusso di lavoro. Gli aspetti attesi sono i punti del flusso di lavoro in cui non è possibile compiere ulteriori progressi nel flusso di lavoro fino al completamento dei lavori avviati in precedenza; mentre stiamo aspettando, possiamo eseguire altri lavori che non dipendono dal completamento di quelle particolari attività avviate.

Lo stack è solo una matrice di frame, in cui i frame contengono (1) puntatori al centro delle funzioni (dove è avvenuta la chiamata) e (2) i valori delle variabili e delle temp locali. Le continuazioni delle attività sono la stessa cosa: il delegato è un puntatore alla funzione e ha uno stato che fa riferimento a un punto specifico nel mezzo della funzione (dove è avvenuta l'attesa) e la chiusura ha campi per ogni variabile locale o temporanea . I frame non formano più una bella matrice ordinata, ma tutte le informazioni sono uguali.


1
Molto utile, grazie. Se potessi contrassegnare entrambe le risposte come accettate lo farei, ma poiché non posso, le lascerò vuote (ma, non volevo che nessuno pensasse che il tempo di risposta non fosse apprezzato)
jleach

3
@ jdl134679: Suggerirei di contrassegnare qualcosa come risposta se ritieni che la tua domanda abbia ricevuto risposta; che invia un segnale che le persone dovrebbero venire qui se vogliono leggere una buona risposta anziché scriverne una. (Ovviamente scrivere sempre buone risposte è sempre incoraggiato.) Non mi interessa chi ottiene il segno di spunta.
Eric Lippert,

8

Appel ha scritto che una vecchia raccolta di rifiuti di carta può essere più veloce dell'allocazione delle pile . Leggi anche il suo Compiling with continuations book e il manuale di garbage collection . Alcune tecniche GC sono (involontariamente) molto efficienti. Lo stile passando continuazione definisce un canonica intero programma di trasformazione (CPS trasformazione ) per eliminare pile (concettualmente sostituzione telai di chiamata con heap allocata chiusure , in altre parole "reificanti" fotogrammi chiamate individuali come "valori" individuali o "oggetti" ).

Ma lo stack di chiamate è ancora ampiamente utilizzato e gli attuali processori hanno hardware dedicato (registro stack, macchine cache, ....) dedicato agli stack di chiamate (e questo perché la maggior parte dei linguaggi di programmazione di basso livello, in particolare C, sono più facili da implementare con uno stack di chiamate). Si noti inoltre che gli stack sono compatibili con la cache (e ciò conta molto per le prestazioni).

In pratica, le pile di chiamate sono ancora qui. Ma ora ne abbiamo molti, e talvolta lo stack di chiamate è suddiviso in molti segmenti più piccoli (ad esempio di alcune pagine di 4Kbyte ciascuno), che a volte vengono raccolti in modo inutile o allocati in heap. Questi segmenti di stack potrebbero essere organizzati in un elenco collegato (o in qualche struttura di dati più complessa, quando necessario). Per esempio, i GCC compilatori hanno un -fsplit-stackopzione (in particolare utile per Go, e le sue "goroutines" e dei suoi "processi asincrone"). Con gli stack divisi puoi avere molte migliaia di stack (e le co-routine diventano più facili da implementare) costituite da milioni di piccoli segmenti dello stack e "svolgendo" lo stack può essere più veloce (o almeno quasi veloce come con un singolo blocco pila).

(in altre parole, la distinzione tra stack e heap è sfocata, ma potrebbe richiedere la trasformazione dell'intero programma o la modifica incompatibile della convenzione di chiamata e del compilatore)

Vedi anche questo e quello e molti articoli (ad esempio questo ) che parlano della trasformazione del CPS. Leggi anche su ASLR e chiama / cc . Leggi (& STFW) di più sulle continuazioni .

Le implementazioni .CLR e .NET potrebbero non avere trasformazioni GC e CPS all'avanguardia, per molte ragioni pragmatiche. È un compromesso correlato a trasformazioni dell'intero programma (e facilità d'uso delle routine C di basso livello e un runtime codificato in C o C ++).

Chicken Scheme sta usando lo stack machine (o C) in modo non convenzionale con la trasformazione CPS: ogni allocazione avviene nello stack e quando diventa troppo grande un passaggio generazionale di copiatura e inoltro GC capita di spostare i valori allocati dello stack recente (e probabilmente il continuazione corrente) all'heap, quindi lo stack viene drasticamente ridotto con un grande setjmp.


Leggi anche SICP , Programming Language Pragmatics , the Dragon Book , Lisp In Small Pieces .


1
Molto utile, grazie. Se potessi contrassegnare entrambe le risposte come accettate lo farei, ma poiché non posso, le lascerò vuote (ma, non volevo che nessuno pensasse che il tempo di risposta non fosse apprezzato)
jleach
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.