javascript - Hvordan kjøre hendelsesløkke når man gjør nestede/rekursive beregninger?
De vanlige eksemplene på hvordan man kan bryte en beregning og frigjøre ved bruk av setTimeout() ser ut til å være avhengig av å ha en grunn (1-dyp) kallstabel. Men hva med når du utfører en dypt nestet eller gjensidig rekursiv beregning (som et tresøk) og du har mye kontekst i stabelen?
Det ville vært ideelt om JavaScript hadde en funksjon som kunne kapsle inn den 'nåværende kontinum' (det vil si: den nåværende kall-stakken), legge den på hendelseskøen og returnere/kaste/tilbakekalle til top-nivå hendelsessløyfe. (slik at andre hendelser kunne kjøre, og deretter ville beregningen bli startet på nytt akkurat der den sluttet). Jeg leter etter en enkel måte for en funksjon å frivillig 'overgi' kontroll, la hendelser ta igjen, og deretter returnere kontrollen til der vi slapp. Helst uten å måtte omskrive hver funksjon i kallkjeden.
Men jeg kan ikke finne noe som gjør dette...
- Som en pensjonert schemer, forventer jeg noe som call/cc, men finner det ikke.
setTimeout()vil returnere kontroll [men bare 1 nivå opp], og starte noen andre beregninger (men ikke den implisitte nåværende-kontinuasjonen, med mindre vi forplikter hele applikasjonen til CPS...)- 'yield' vil pakke inn fortsettelsen av den nåværende funksjonen/stakk-rammen, slik at den kan startes på nytt, men yield returnerer bare ett nivå opp. (yield er som: return/cc vs call/cc)
- 'throw' kan kaste langt opp i stakken, men har ingen mulighet til å gjenoppta beregningen fra punktet der throw skjedde (så vidt jeg vet; trenger noe som 'throw/cc')
Jeg har bygget en semi-løsning ved å bruke 'yield', men den er klossete, og krever at hver funksjon i stakken (a) deklareres som 'function*' og (b) inkluderer standardkode rundt hvert kall ned til neste funksjon [for å propagere en yield og starte på nytt med next()]
Spørsmål: Finnes det en måte å oppnå dette på i JavaScript uten å instrumentere alle funksjonene i kallkjeden?
Svar #1
Jeg vil legge til en alternativ løsning som du ser ut til å ikke ha vurdert: Promises. Eller mer spesifikt syntakssukkeret for håndtering av promises: async/await.
Ved å bruke en Promise er det enkelt å implementere din allowEventLoop()-funksjon:
function allowEventLoop () {
return new Promise((ok,fail) => setTimeout(ok,0));
}
Nå, hver gang du trenger å sette den nåværende beregningen på pause og kjøre hendelsesløkken, trenger du bare å kalle:
await allowEventLoop();
Her er et eksempel på en enkel rekursiv nedstigningsparser som bruker funksjonen ovenfor (merk: kode i Js, men det bør være trivielt å gjøre dette i Ts):
async function drawTree(node, indent) {
if (!indent) indent = 0;
let tree = `${'\t'.repeat(indent)}${node.name}\n`;
await allowEventLoop();
if (node.children) {
for (let child of node.children) {
tree += await drawTree(child, indent+1);
}
}
return tree;
}
Som du kan se, er det veldig lite som er endret i logikken til den rekursive funksjonen. Den ser nesten helt lik ut som den vanlige synkrone versjonen. Hovedforskjellen er at funksjonen nå returnerer et Promise av resultatet.
Når du bruker async/await hopper du egentlig over kallestakken. Det du egentlig gjør er å bruke en kjede av .then()-kall. Så i realiteten er kallestakken fortsatt 1-nivå dyp, men du konstruerer dynamisk en komplisert .then()-kjede. I praksis føles det som vanlig rekursjon basert på kallestakken.
Køen av funksjoner som skal utføres håndteres usynlig av Promises - som i hovedsak er et designmønster for håndtering av Continuation-Passing-Style (CPS) kode. Dette ligner på hvordan kallstakken håndterer køen av funksjoner som skal returnere. Dette er grunnen til at det føles likt.
Svar #2
Vi ønsker å muliggjøre hendelsesbehandling under langvarige, gjensidig rekursive funksjonskall. (for eksempel en rekursiv tre-søk) Etter en viss dybde eller tid ønsker søket å frivillig suspendere utførelsen for å la toppnivå Event Loop kjøre (håndtere muse-/tastaturhendelser, tegne grafikk på nytt, osv)
Det ideelle ville være en systemnivåfunksjon runEventLoop() som 'gir fra seg' den nåværende beregningen, legger sin egen fortsettelse i hendelseskøen, og overfører kontrollen til systemets EventLoop.
Det ser ut til at Javascript bare gir delvise løsninger for dette:
- 'setTimeout()' vil legge en funksjon på hendelseskøen [men ikke på den nåværende fortsettelsen]
- 'yield' vil suspendere den nåværende continuasjonen, men ikke sette den i hendelseskøen. Og 'yield' returnerer en verdi til Generatorens oppringer ett nivå opp i kallestakken. Så den oppringeren må allerede ha 'continuation' i form av Generatoren.
Vi bemerker også at selv om en ufanget 'throw' vil returnere kontroll til toppnivå, finnes det ingen måte (TIKO) i JS for å gjenopprette og starte på nytt den 'kastede' beregningen. (fra toppnivå gjennom de gjensidig rekursive kallene til den frivillige 'yield')
Så: for å returnere kontroll fra den frivillige overgivelsen, opp gjennom de nestede eller gjensidig rekursive funksjonene, hele veien til systemets EventLoop, gjør vi 3 ting:
- Hver funksjon [kaller og kalt] må deklareres som function* (slik at den kan yield)
- Hver funksjon [caller] må teste om dens [called] etterkommer ble suspendert, og i så fall gi fra seg kontroll for å spre 'yield' til toppnivå:
let result, genR = calledStarFunction(args);
while (result = genR.next(), !result.done) yield;
use (result.value)
Merk: #2 kan ikke nyttig innkapsles i en funksjon... fordi den funksjonen ville være underlagt #1, og den som kaller den funksjonen er underlagt #2
- På toppnivå, bruk
setTimeout(() => genR.next())for å returnere til JS EventLoop og deretter starte kjeden av suspenderte funksjoner på nytt.
[før #2 var åpenbart, skrev jeg denne typescript-koden, nå er 'yieldR' inline, som vist ovenfor]
/** <yield: void, return: TReturn, yield-in: unknown> */
export type YieldR<TReturn> = Generator<void, TReturn, unknown>
/**
* Top-level function to give control to JS Event Loop, and then restart the stack of suspended functions.
* 'genR' will restart the first/outermost suspended block, which will have code like *yieldR()
* that loops to retry/restart the next/inner suspended function.
* @param genR
* @param done
*/
export function allowEventLoop<T>(genR: YieldR<T>, done?: (result: T) => void): void {
let result = genR.next()
if (result.done) done && done(result.value)
else setTimeout(() => allowEventLoop(genR, done))
}
/**
* Return next result from genR.
* If genR returns an actual value, return that value
* If genR yields<void> then propagate a 'yield' to each yieldR up to allowEventLoop();
*
* This shows the canonical form of the code.
* It's not useful to actually *call* this code since it also returns a Generator,
* and the calling code must then write a while loop to handle the yield-vs-return!
*/
export function* yieldR<T extends object> (genR: YieldR<T>, log?:string) {
let result: IteratorResult<void, T>
while (result = genR.next(), !result.done) yield
return result.value
}
Merk: mest dokumentert bruk av function* er for å lage en Iterator, et tilfelle der 'yield' gir den interessante/nyttige verdien, og 'return' signaliserer når det er ferdig. I dette brukstilfellet er det omvendt: yield gir et signal, men ingen interessant verdi, og 'return' gir den interessante beregningsverdien.
Appell til JS-gudene: Gi en funksjon: runEventLoop() som transparent legger den nåværende fortsettelsen (hele stakken) på eventløkken og returnerer kontroll direkte til toppnivå, slik at alle de andre kallene og kallstakken ikke trenger å være klar over suspenderingen/ gjenopptagelsen som gjøres på lavere nivå.
Etter merknad: det ser ut til å være et betydelig ytelsestap ved å bruke Generatorer på denne måten. Etter å ha inline-et koden for å redusere nestede Generatorer fra 4 til 2, kjørte koden 10 ganger raskere. Så kanskje CPS- eller dataflytdesign kan være aktuelt for komplekse/tidskritiske apper. (men fortsatt, det fungerte under utvikling/debugging for å få tastatur/grafikk til å fungere)
En annen merknad: Chrome pålegger en minimumsforsinkelse på 4 ms for 'setTimeout'; så hvis du beregner i 1 ms og deretter gir kontrollen i 4 ms, er det tregt og kan forklare merknaden ovenfor. Det hjelper å beregne deltaen fra siste pause til Date.now() og gi kontrollen bare når dette er større enn [20 -- 200 ms?] (avhengig av hvor rask respons du trenger).
