Lær at bygge en parallel processor med høj ydeevne i JavaScript ved hjælp af async iterators. Mestr håndtering af samtidige streams for at øge hastigheden markant i datatunge applikationer.
Opnå høj ydeevne i JavaScript: Et dybdegående kig på parallelle processorer med Iterator Helpers til håndtering af samtidige streams
I en verden af moderne softwareudvikling er ydeevne ikke en feature; det er et fundamentalt krav. Fra behandling af enorme datasæt i en backend-tjeneste til håndtering af komplekse API-interaktioner i en webapplikation, er evnen til at håndtere asynkrone operationer effektivt afgørende. JavaScript har med sin single-threaded, event-drevne model længe excelleret i I/O-bundne opgaver. Men i takt med at datamængderne vokser, bliver traditionelle sekventielle behandlingsmetoder til betydelige flaskehalse.
Forestil dig at skulle hente detaljer for 10.000 produkter, behandle en logfil på en gigabyte eller generere thumbnails for hundredvis af bruger-uploadede billeder. At håndtere disse opgaver én ad gangen er pålideligt, men smerteligt langsomt. Nøglen til at opnå dramatiske ydeevneforbedringer ligger i samtidighed (concurrency) — at behandle flere elementer på samme tid. Det er her, styrken ved asynkrone iteratorer, kombineret med en specialbygget strategi for parallel behandling, transformerer måden, vi håndterer datastrømme på.
Denne omfattende guide er for JavaScript-udviklere på mellemniveau til avanceret niveau, som ønsker at bevæge sig ud over simple `async/await`-løkker. Vi vil udforske grundlaget for JavaScript-iteratorer, dykke ned i problemet med sekventielle flaskehalse og, vigtigst af alt, bygge en kraftfuld, genanvendelig parallel processor med Iterator Helper fra bunden. Dette værktøj vil give dig mulighed for at administrere samtidige opgaver over enhver datastrøm med finkornet kontrol, hvilket gør dine applikationer hurtigere, mere effektive og mere skalerbare.
Forståelse af grundlaget: Iteratorer og asynkron JavaScript
Før vi kan bygge vores parallelle processor, skal vi have et solidt greb om de underliggende JavaScript-koncepter, der gør det muligt: iterator-protokollerne og deres asynkrone modstykker.
Styrken ved iteratorer og iterables
Grundlæggende set giver iterator-protokollen en standardiseret måde at producere en sekvens af værdier på. Et objekt betragtes som iterable, hvis det implementerer en metode med nøglen `Symbol.iterator`. Denne metode returnerer et iterator-objekt, som har en `next()`-metode. Hvert kald til `next()` returnerer et objekt med to egenskaber: `value` (den næste værdi i sekvensen) og `done` (en boolean, der angiver, om sekvensen er fuldført).
Denne protokol er magien bag `for...of`-løkken og er implementeret i mange indbyggede typer:
- Arrays: `['a', 'b', 'c']`
- Strings: `"hello"`
- Maps: `new Map([['key1', 'value1'], ['key2', 'value2']])`
- Sets: `new Set([1, 2, 3])`
Skønheden ved iterables er, at de repræsenterer datastrømme på en "lazy" måde. Du trækker værdier én ad gangen, hvilket er utroligt hukommelseseffektivt for store eller endda uendelige sekvenser, da du ikke behøver at holde hele datasættet i hukommelsen på én gang.
Fremkomsten af async iteratorer
Standard-iterator-protokollen er synkron. Hvad nu hvis værdierne i vores sekvens ikke er umiddelbart tilgængelige? Hvad hvis de kommer fra en netværksanmodning, en database-cursor eller en fil-stream? Det er her, asynkrone iteratorer kommer ind i billedet.
Den asynkrone iterator-protokol er en nær slægtning til sin synkrone modpart. Et objekt er async iterable, hvis det har en metode med nøglen `Symbol.asyncIterator`. Denne metode returnerer en async iterator, hvis `next()`-metode returnerer et `Promise`, der resolver til det velkendte `{ value, done }`-objekt.
Dette gør det muligt for os at arbejde med datastrømme, der ankommer over tid, ved hjælp af den elegante `for await...of`-løkke:
Eksempel: En async generator, der yielder tal med en forsinkelse.
async function* createDelayedNumberStream() {
for (let i = 1; i <= 5; i++) {
// Simuler en netværksforsinkelse eller anden asynkron operation
await new Promise(resolve => setTimeout(resolve, 500));
yield i;
}
}
async function consumeStream() {
const numberStream = createDelayedNumberStream();
console.log('Starter forbrug...');
// Løkken vil pause ved hvert 'await', indtil den næste værdi er klar
for await (const number of numberStream) {
console.log(`Modtaget: ${number}`);
}
console.log('Forbrug afsluttet.');
}
// Output vil vise tal, der dukker op hvert 500ms
Dette mønster er fundamentalt for moderne databehandling i Node.js og browsere, og det giver os mulighed for at håndtere store datakilder på en elegant måde.
Introduktion til Iterator Helpers-forslaget
Selvom `for...of`-løkker er kraftfulde, kan de være imperative og verbose. For arrays har vi et rigt sæt af deklarative metoder som `.map()`, `.filter()` og `.reduce()`. Iterator Helpers TC39-forslaget sigter mod at bringe den samme udtryksfulde kraft direkte til iteratorer.
Dette forslag tilføjer metoder til `Iterator.prototype` og `AsyncIterator.prototype`, hvilket giver os mulighed for at kæde operationer sammen på enhver iterable kilde uden først at konvertere den til et array. Dette er en game-changer for hukommelseseffektivitet og kodens klarhed.
Overvej dette "før og efter"-scenarie for filtrering og mapping af en datastrøm:
Før (med en standard-løkke):
async function processData(source) {
const results = [];
for await (const item of source) {
if (item.value > 10) { // filter
const processedItem = await transform(item); // map
results.push(processedItem);
}
}
return results;
}
Efter (med de foreslåede async iterator helpers):
async function processDataWithHelpers(source) {
const results = await source
.filter(item => item.value > 10)
.map(async item => await transform(item))
.toArray(); // .toArray() er en anden foreslået helper
return results;
}
Selvom dette forslag endnu ikke er en standarddel af sproget i alle miljøer, danner dets principper det konceptuelle grundlag for vores parallelle processor. Vi ønsker at skabe en `map`-lignende operation, der ikke bare behandler ét element ad gangen, men kører flere `transform`-operationer parallelt.
Flaskehalsen: Sekventiel behandling i en asynkron verden
`for await...of`-løkken er et fantastisk værktøj, men den har en afgørende egenskab: den er sekventiel. Løkkens krop begynder ikke for det næste element, før `await`-operationerne for det nuværende element er fuldt afsluttet. Dette skaber et ydeevneloft, når man har med uafhængige opgaver at gøre.
Lad os illustrere med et almindeligt, virkeligt scenarie: hentning af data fra et API for en liste af identifikatorer.
Forestil dig, at vi har en async iterator, der yielder 100 bruger-ID'er. For hvert ID skal vi foretage et API-kald for at få brugerens profil. Lad os antage, at hvert API-kald i gennemsnit tager 200 millisekunder.
async function fetchUserProfile(userId) {
// Simuler et API-kald
await new Promise(resolve => setTimeout(resolve, 200));
return { id: userId, name: `User ${userId}`, fetchedAt: new Date() };
}
async function fetchAllUsersSequentially(userIds) {
console.time('SequentialFetch');
const profiles = [];
for await (const id of userIds) {
const profile = await fetchUserProfile(id);
profiles.push(profile);
console.log(`Fetched user ${id}`);
}
console.timeEnd('SequentialFetch');
return profiles;
}
// Antager at 'userIds' er en async iterable med 100 ID'er
// await fetchAllUsersSequentially(userIds);
Hvad er den samlede eksekveringstid? Fordi hvert `await fetchUserProfile(id)` skal afsluttes, før det næste starter, vil den samlede tid være cirka:
100 brugere * 200 ms/bruger = 20.000 ms (20 sekunder)
Dette er en klassisk I/O-bundet flaskehals. Mens vores JavaScript-proces venter på netværket, er dens event loop for det meste inaktiv. Vi udnytter ikke den fulde kapacitet af systemet eller det eksterne API. Behandlingstidslinjen ser således ud:
Opgave 1: [---VENT---] Færdig
Opgave 2: [---VENT---] Færdig
Opgave 3: [---VENT---] Færdig
...og så videre.
Vores mål er at ændre denne tidslinje til noget i stil med dette, ved at bruge et samtidighedsniveau på 10:
Opgave 1-10: [---VENT---][---VENT---]... Færdig
Opgave 11-20: [---VENT---][---VENT---]... Færdig
...
Med 10 samtidige operationer kan vi teoretisk reducere den samlede tid fra 20 sekunder til kun 2 sekunder. Dette er det ydeevnespring, vi sigter mod at opnå ved at bygge vores egen parallelle processor.
Opbygning af en parallel processor med JavaScript Iterator Helper
Nu kommer vi til kernen i denne artikel. Vi vil konstruere en genanvendelig async generator-funktion, som vi kalder `parallelMap`, der tager en async iterable kilde, en mapper-funktion og et samtidighedsniveau. Den vil producere en ny async iterable, der yielder de behandlede resultater, efterhånden som de bliver tilgængelige.
Kerne-designprincipper
- Begrænsning af samtidighed: Processoren må aldrig have mere end et specificeret antal `mapper`-funktions-promises i gang på én gang. Dette er afgørende for at styre ressourcer og respektere eksterne API rate limits.
- Lazy forbrug: Den må kun trække fra kilde-iteratoren, når der er en ledig plads i dens behandlingspulje. Dette sikrer, at vi ikke bufferer hele kilden i hukommelsen og bevarer fordelene ved streams.
- Håndtering af modtryk (backpressure): Processoren bør naturligt pause, hvis forbrugeren af dens output er langsom. Async generators opnår dette automatisk via `yield`-nøgleordet. Når eksekveringen er pauset ved `yield`, trækkes ingen nye elementer fra kilden.
- Uordnet output for maksimal gennemstrømning: For at opnå den højest mulige hastighed vil vores processor yielde resultater, så snart de er klar, ikke nødvendigvis i den oprindelige rækkefølge af input. Vi vil diskutere, hvordan man bevarer rækkefølgen senere som et avanceret emne.
Implementeringen af `parallelMap`
Lad os bygge vores funktion trin for trin. Det bedste værktøj til at skabe en brugerdefineret async iterator er en `async function*` (async generator).
/**
* Skaber en ny async iterable, der behandler elementer fra en kilde-iterable parallelt.
* @param {AsyncIterable|Iterable} source Den kilde-iterable, der skal behandles.
* @param {Function} mapperFn En asynkron funktion, der tager et element og returnerer et promise med det behandlede resultat.
* @param {object} options
* @param {number} options.concurrency Det maksimale antal opgaver, der skal køres parallelt.
* @returns {AsyncGenerator} En async generator, der yielder de behandlede resultater.
*/
async function* parallelMap(source, mapperFn, { concurrency = 5 }) {
// 1. Hent async-iteratoren fra kilden.
// Dette virker for både synkrone og asynkrone iterables.
const asyncIterator = source[Symbol.asyncIterator] ?
source[Symbol.asyncIterator]() :
source[Symbol.iterator]();
// 2. Et Set til at holde styr på de promises for de opgaver, der er i gang.
// Brug af et Set gør tilføjelse og sletning af promises effektivt.
const processing = new Set();
// 3. Et flag til at spore, om kilde-iteratoren er udtømt.
let sourceIsDone = false;
// 4. Hovedløkken: fortsætter, så længe der er opgaver i gang
// eller kilden har flere elementer.
while (!sourceIsDone || processing.size > 0) {
// 5. Fyld behandlingspuljen op til samtidighedsgrænsen.
while (processing.size < concurrency && !sourceIsDone) {
const nextItemPromise = asyncIterator.next();
const processingPromise = nextItemPromise.then(item => {
if (item.done) {
sourceIsDone = true;
return; // Signalér, at denne gren er færdig, intet resultat at behandle.
}
// Udfør mapper-funktionen og sørg for, at dens resultat er et promise.
// Dette returnerer den endelige behandlede værdi.
return Promise.resolve(mapperFn(item.value));
});
// Dette er et afgørende skridt for at styre puljen.
// Vi skaber et wrapper-promise, der, når det resolver, giver os både
// det endelige resultat og en reference til sig selv, så vi kan fjerne det fra puljen.
const trackedPromise = processingPromise.then(result => ({
result,
origin: trackedPromise
}));
processing.add(trackedPromise);
}
// 6. Hvis puljen er tom, må vi være færdige. Bryd løkken.
if (processing.size === 0) break;
// 7. Vent på, at ENHVER af de igangværende opgaver afsluttes.
// Promise.race() er nøglen til at opnå dette.
const { result, origin } = await Promise.race(processing);
// 8. Fjern det afsluttede promise fra behandlingspuljen.
processing.delete(origin);
// 9. Yield resultatet, medmindre det er 'undefined' fra et 'done'-signal.
// Dette pauser generatoren, indtil forbrugeren anmoder om det næste element.
if (result !== undefined) {
yield result;
}
}
}
Gennemgang af logikken
- Initialisering: Vi henter async-iteratoren fra kilden og initialiserer et `Set` ved navn `processing` til at fungere som vores samtidighedspulje.
- Opfyldning af puljen: Den indre `while`-løkke er motoren. Den tjekker, om der er plads i `processing`-sættet, og om `source` stadig har elementer. Hvis ja, trækker den det næste element.
- Udførelse af opgave: For hvert element kalder vi `mapperFn`. Hele operationen — at hente det næste element og mappe det — er pakket ind i et promise (`processingPromise`).
- Sporing af promises: Den vanskeligste del er at vide, hvilket promise der skal fjernes fra sættet efter `Promise.race()`. `Promise.race()` returnerer den resolvede værdi, ikke selve promise-objektet. For at løse dette skaber vi et `trackedPromise`, der resolver til et objekt, som indeholder både det endelige `result` og en reference til sig selv (`origin`). Vi tilføjer dette sporings-promise til vores `processing`-sæt.
- Venter på den hurtigste opgave: `await Promise.race(processing)` pauser eksekveringen, indtil den første opgave i puljen er færdig. Dette er kernen i vores samtidighedsmodel.
- Yielding og genopfyldning: Når en opgave er færdig, får vi dens resultat. Vi fjerner det tilsvarende `trackedPromise` fra `processing`-sættet, hvilket frigør en plads. Vi `yield`er derefter resultatet. Når forbrugerens løkke beder om det næste element, fortsætter vores hoved-`while`-løkke, og den indre `while`-løkke vil forsøge at fylde den tomme plads med en ny opgave fra kilden.
Dette skaber en selvregulerende pipeline. Puljen bliver konstant tømt af `Promise.race` og genopfyldt fra kilde-iteratoren, hvilket opretholder en stabil tilstand af samtidige operationer.
Brug af vores `parallelMap`
Lad os vende tilbage til vores eksempel med at hente brugere og anvende vores nye værktøj.
// Antag at 'createIdStream' er en async generator, der yielder 100 bruger-ID'er.
const userIdStream = createIdStream();
async function fetchAllUsersInParallel() {
console.time('ParallelFetch');
const profilesStream = parallelMap(userIdStream, fetchUserProfile, { concurrency: 10 });
for await (const profile of profilesStream) {
console.log(`Behandlet profil for bruger ${profile.id}`);
}
console.timeEnd('ParallelFetch');
}
// await fetchAllUsersInParallel();
Med en samtidighed på 10 vil den samlede eksekveringstid nu være cirka 2 sekunder i stedet for 20. Vi har opnået en 10x ydeevneforbedring ved blot at wrappe vores stream med `parallelMap`. Skønheden er, at den forbrugende kode forbliver en simpel, læsbar `for await...of`-løkke.
Praktiske anvendelsesscenarier og globale eksempler
Dette mønster er ikke kun til at hente brugerdata. Det er et alsidigt værktøj, der kan anvendes på en bred vifte af problemer, som er almindelige i global applikationsudvikling.
API-interaktioner med høj gennemstrømning
Scenarie: En finansiel applikation skal berige en strøm af transaktionsdata. For hver transaktion skal den kalde to eksterne API'er: et til svindeldetektering og et andet til valutakonvertering. Disse API'er har en rate limit på 100 anmodninger pr. sekund.
Løsning: Brug `parallelMap` med en `concurrency`-indstilling på `20` eller `30` til at behandle strømmen af transaktioner. `mapperFn` ville foretage de to API-kald ved hjælp af `Promise.all`. Samtidighedsgrænsen sikrer, at du får høj gennemstrømning uden at overskride API'ets rate limits, hvilket er en kritisk bekymring for enhver applikation, der interagerer med tredjepartstjenester.
Storskala databehandling og ETL (Extract, Transform, Load)
Scenarie: En dataanalyseplatform i et Node.js-miljø skal behandle en 5GB CSV-fil, der er gemt i en cloud bucket (som Amazon S3 eller Google Cloud Storage). Hver række skal valideres, renses og indsættes i en database.
Løsning: Opret en async iterator, der læser filen fra cloud storage-strømmen linje for linje (f.eks. ved hjælp af `stream.Readable` i Node.js). Pipe denne iterator ind i `parallelMap`. `mapperFn` vil udføre valideringslogikken og database-`INSERT`-operationen. `concurrency` kan justeres baseret på databasens connection pool-størrelse. Denne tilgang undgår at indlæse den 5GB store fil i hukommelsen og paralleliserer den langsomme databaseindsættelsesdel af pipelinen.
Pipeline til transkodning af billeder og video
Scenarie: En global social medieplatform giver brugerne mulighed for at uploade videoer. Hver video skal transkodes til flere opløsninger (f.eks. 1080p, 720p, 480p). Dette er en CPU-intensiv opgave.
Løsning: Når en bruger uploader en batch af videoer, opret en iterator af videofilstier. `mapperFn` kan være en asynkron funktion, der starter en child-proces for at køre et kommandolinjeværktøj som `ffmpeg`. `concurrency` bør indstilles til antallet af tilgængelige CPU-kerner på maskinen (f.eks. `os.cpus().length` i Node.js) for at maksimere hardwareudnyttelsen uden at overbelaste systemet.
Avancerede koncepter og overvejelser
Selvom vores `parallelMap` er kraftfuld, kræver virkelige applikationer ofte mere nuance.
Robust fejlhåndtering
Hvad sker der, hvis et af `mapperFn`-kaldene rejecter? I vores nuværende implementering vil `Promise.race` rejecte, hvilket vil få hele `parallelMap`-generatoren til at kaste en fejl og afslutte. Dette er en "fail-fast"-strategi.
Ofte ønsker man en mere modstandsdygtig pipeline, der kan overleve individuelle fejl. Du kan opnå dette ved at wrappe din `mapperFn`.
const resilientMapper = async (item) => {
try {
return { status: 'fulfilled', value: await originalMapper(item) };
} catch (error) {
console.error(`Kunne ikke behandle element ${item.id}:`, error);
return { status: 'rejected', reason: error, item: item };
}
};
const resultsStream = parallelMap(source, resilientMapper, { concurrency: 10 });
for await (const result of resultsStream) {
if (result.status === 'fulfilled') {
// behandl succesfuld værdi
} else {
// håndter eller log fejlen
}
}
Bevarelse af rækkefølge
Vores `parallelMap` yielder resultater ude af rækkefølge og prioriterer hastighed. Nogle gange skal rækkefølgen af output matche rækkefølgen af input. Dette kræver en anderledes, mere kompleks implementering, ofte kaldet `parallelOrderedMap`.
Den generelle strategi for en ordnet version er:
- Behandl elementer parallelt som før.
- I stedet for at yielde resultater med det samme, gem dem i en buffer eller et map, med deres oprindelige indeks som nøgle.
- Vedligehold en tæller for det næste forventede indeks, der skal yieldes.
- I en løkke, tjek om resultatet for det nuværende forventede indeks er tilgængeligt i bufferen. Hvis det er, yield det, inkrementer tælleren, og gentag. Hvis ikke, vent på, at flere opgaver bliver færdige.
Dette tilføjer overhead og hukommelsesforbrug for bufferen, men er nødvendigt for rækkefølgeafhængige arbejdsgange.
Modtryk (backpressure) forklaret
Det er værd at gentage en af de mest elegante funktioner ved denne async generator-baserede tilgang: automatisk håndtering af modtryk (backpressure). Hvis koden, der forbruger vores `parallelMap`, er langsom — for eksempel ved at skrive hvert resultat til en langsom disk eller en overbelastet netværkssocket — vil `for await...of`-løkken ikke bede om det næste element. Dette får vores generator til at pause ved `yield result;`-linjen. Mens den er pauset, kører den ikke i løkke, den kalder ikke `Promise.race`, og vigtigst af alt, den fylder ikke behandlingspuljen. Denne mangel på efterspørgsel forplanter sig hele vejen tilbage til den oprindelige kilde-iterator, som der ikke læses fra. Hele pipelinen sænker automatisk farten for at matche hastigheden på dens langsomste komponent, hvilket forhindrer hukommelsesproblemer fra over-buffering.
Konklusion og fremtidsperspektiver
Vi har rejst fra de grundlæggende koncepter i JavaScript-iteratorer til at bygge et sofistikeret, højtydende parallelt behandlingsværktøj. Ved at gå fra sekventielle `for await...of`-løkker til en styret, samtidig model, har vi demonstreret, hvordan man kan opnå ydeevneforbedringer i størrelsesordenen en faktor for dataintensive, I/O-bundne og CPU-bundne opgaver.
De vigtigste pointer er:
- Sekventiel er langsom: Traditionelle asynkrone løkker er en flaskehals for uafhængige opgaver.
- Samtidighed er nøglen: At behandle elementer parallelt reducerer den samlede eksekveringstid dramatisk.
- Async generators er det perfekte værktøj: De giver en ren abstraktion til at skabe brugerdefinerede iterables med indbygget understøttelse af afgørende funktioner som modtryk.
- Kontrol er afgørende: En styret samtidighedspulje forhindrer ressourceudmattelse og respekterer eksterne systemgrænser.
Efterhånden som JavaScript-økosystemet fortsætter med at udvikle sig, vil Iterator Helpers-forslaget sandsynligvis blive en standarddel af sproget, hvilket giver et solidt, indbygget grundlag for stream-manipulation. Men logikken for parallelisering — at styre en pulje af promises med et værktøj som `Promise.race` — vil forblive et kraftfuldt mønster på et højere niveau, som udviklere kan implementere for at løse specifikke ydeevneudfordringer.
Jeg opfordrer dig til at tage `parallelMap`-funktionen, vi har bygget i dag, og eksperimentere med den i dine egne projekter. Identificer dine flaskehalse, uanset om det er API-kald, databaseoperationer eller filbehandling, og se, hvordan dette mønster for samtidig stream-håndtering kan gøre dine applikationer hurtigere, mere effektive og klar til kravene i en datadrevet verden.