3 former av asynkron bearbetning i JavaScript
5 minuter i lästid JavaScript Nodejs Async

3 former av asynkron bearbetning i JavaScript

Vad innebär asynkron bearbetning/hantering (eng. asynchronous computation)? I korthet, innebär det att anropa en funktion vid ett senare tillfälle när resultatet av en operation (t.ex. ett musklick eller ett HTTP anrop) är klart.

Funktionen som anropas brukar formellt kallas för en continuation, medan den informellt kallas för en call-back function. Programspråk och programbibliotek med stöd för continuation-passing style har funnits under lång tid. Själva begreppet daterar sig tillbaka till 1964, men kom att formaliseras under 1970-talet.

Då JavaScript skapades

När Brendan Eich anställdes på Netscape i april 1995, fick han 10 dagar på sig att designa ett nytt programmeringsspråk för deras, vid den tiden, populära webbläsare. Det fanns givetvis en hel del begränsningar att ta hänsyn till, men den av dessa som helt kom att forma det nya språkets exekveringsmodell, var bristen på fler-trådad exekvering (eng. multi-threading). Språket i fråga, var givetvis JavaScript.

Fler-trådad exekvering används bl.a. i språket Java för att hantera flera samtidigt pågående operationer. Lösningen för Brendan blev att använda sig av continuation funktioner för varje operation som på ett eller annat sätt involverade väntetid.

Hur JavaScript exekverar

De två centrala delarna av JavaScript motorn (eng. JS virtual machine), som hanterar exekveringen är

  1. Call-back Queue
  2. Function Execution Stack

Stacken fungerar på ett liknande sätt som för konventionella programspråk, d.v.s. en anropad funktion lägger till överst (eng. push stack) en beskrivning av funktionens lokala variabler och återhopps-information (eng. return address). Detta kallas på engelska för en stack-frame. Om aktuell funktion anropar en annan funktion, så läggs det en ny stack-frame överst, och så vidare. När en funktion returnerar, så tas motsvarande stack-frame bort (eng. pop stack).

För konventionella programspråk, så avslutas exekveringen när stacken är tom. Så icke för JS, utan då hämtas nästa funktion för exekvering från call-back queue. Så där håller det på tills, både stack och kö är tom, då först avslutas programmet.

Detta gäller när JS exekveras på kommando-raden, typiskt då som ett Node.js program. När ett JS program emellertid exekveras i en webbläsare, så ligger programmet och väntar på att någon händelse ska inträffa, såsom musklick, tangentbordsklick eller att en påbörjad operation, såsom HTTP, är avslutad.

Call-back

Ett exempel på användning av call-back är fördröjt funktionsanrop med setTimeout(). Exemplet nedan visar hur man fördröjer en utskrift med en 1 sekund (1000 ms).

setTimeout(() => { console.log('Tjabba Habba'); }, 1000);

Nästa exempel visar hur man fyller på call-back queue med rekursiva funktioner. En intervall timer startas, vilken registrerar en call-back varje sekund. En rekursiv funktion anropas, vilket push:ar ett flertal stack-frames på stacken.

Dessutom, registreras en timeout call-back, som efter ett visst antal sekunder stannar intervall timern. Efter att denna har exekverat är både stacken och kön tomma, vilket leder till att programmer avslutas.

function factorial(n) {
    if (n < 0) return NaN;
    if (n === 0) return 0;
    if (n === 1) return 1;
    return n * factorial(n - 1);
}

function main(num_invocations = 5) {
    console.time('elapsed time');
    
    const timer = setInterval(() => {
        const argument = Math.floor(30 * Math.random());
        const result   = factorial(argument);
        console.log('%d! = %d', argument, result);
    }, 1000);
    
    setTimeout(() => {
        clearInterval(timer);
        console.timeEnd('elapsed time');
        }, (num_invocations + 1) * 1000);
}

main();

Så här kan det se ut när programmet körs:

5! = 120
23! = 2.585201673888498e+22
1! = 1
16! = 20922789888000
23! = 2.585201673888498e+22
elapsed time: 6.010s

Continuation-passing style

En call-back funktion som anropas kan anropa en annan funktion, som i sin tur tar en call-back och så vidare. Programexekveringen fortsätter i varje (nästlad) continuation, dvs call-back. Så här kan det se ut rent schematiskt:

func1(args1, (result1) => {
    //...do something 1...
    func2(args2, (result2) => {
        //...do something 2...
        func3(arg3, (result3) => {
            //...do something 3...
        });
    });
});

Problemet med call-backs

Ovanstående illustreras bäst med ett konkret exempel. Nedan visar jag ett program som kopierar innehållet i en fil (scriptet själv) till en ny fil (där texten gjorts om till versaler).

Funktionen copyfile() öppnar infilen för läsning, tar reda på hur stor den är och skapar en buffert för hela innehållet. Sedan läses innehållet in och filen stängs. Efter det öppnas utfilen för skrivning, varpå textinnehållet skrivs ut som versaler och filen stängs.

import {close, fstat, open, read, write} from 'node:fs';
import {fileURLToPath} from 'node:url';
import {basename, dirname, extname, join} from 'node:path';

function die(err) { console.error(err); process.exit(1); }

function copyfile(fromFile, toFile) {
   open(fromFile, 'r', (err, fd) => {
      if (err) die(err);
      fstat(fd, (err, stats) => {
         if (err) die(err);
         const storage = Buffer.alloc(stats.size);
         read(fd, storage,0, storage.length,null, (err, bytesIn, data) => {
            if (err) die(err);
            console.log('Read  %d bytes from %s', bytesIn, basename(fromFile));
            close(fd, (err) => {
               if (err) die(err);
               open(toFile, 'w', (err, fd) => {
                  if (err) die(err);
                  const payload = data.toString().toUpperCase();
                  write(fd, payload, (err, bytesOut) => {
                     if (err) die(err);
                      close(fd, (err) => {
                        console.log('Wrote %d bytes to %s', bytesOut, basename(toFile));
                      });
                  });
               });
            });
         });
      })
   });
}

const scriptFilePath = fileURLToPath(import.meta.url);
const scriptFileName = basename(scriptFilePath, extname(scriptFilePath));
const outputFilePath = join(dirname(scriptFilePath), scriptFileName + '-copy.txt');

copyfile(scriptFilePath, outputFilePath);

Så här kan det se ut när programmet exekverar:

Read  1517 bytes from copyfile-callback.mjs
Wrote 1517 bytes to copyfile-callback-copy.txt

Exemplet ovan är förvisso ett väldigt litet exempel, men det visar ändock tydligt hur den indenterade programkoden glider mer och mer åt höger. Det här brukar kallas bumerang-effekten, eftersom koden liknar formen på en bumerang. Andra personer kallar det rätt och slätt call-back hell.

I nästa avsnitt, som kommer om en vecka, ska vi diskutera hur man fixar bumerang problemet med hjälp av löften om framtida värden, d.v.s. det som kallas promises.

Länkar