Reacts Scheduler und der Event Loop: Wer entscheidet wirklich, wann die Aufgaben ausgeführt werden
Sehen Sie, wie Reacts kooperativer Scheduler innerhalb des JavaScript-Eventloops läuft, warum Übergänge Ressourcen freigeben können und warum kein Scheduler einen blockierten Thread retten kann.
Quellen: How Does React’s Scheduler Work With the JavaScript Event Loop?
React entscheidet nicht nur darüber, was auf dem Bildschirm geändert werden soll, sondern auch darüber, wann diese Änderungen stattfinden, und zwar mithilfe von Prioritäten, Arbeitsabläufen sowie unterbrechbarem Rendering. Doch React ist im Grunde nur JavaScript, und JavaScript verfügt bereits über einen Event-Loop, der bestimmt, wann Code ausgeführt wird. Warum liefert React dann seinen eigenen Scheduler – und welcher der beiden ist eigentlich zuständig? Dieser Artikel klärt die Beziehung zwischen ihnen auf, zeigt, wo konkurrierende Funktionen wie Übergänge und useDeferredValue ihren Platz finden, und erläutert die unüberwindbare Grenze, die durch keine noch so kluge Scheduling-Strategie beseitigt werden kann – sowohl im Web als auch in React Native.
Fangen Sie mit dem Call Stack an, nicht mit React
Einfaches JavaScript führt Code schrittweise auf einem Call Stack aus, ein Frame nach dem anderen. Im folgenden Beispiel ist nichts asynchron: ein Log-Eintrag, eine Funktionsaufruf, der ebenfalls einen Log-Eintrag erstellt, und schließlich ein weiterer Log-Eintrag.
console.log("A");
function calculate() {
console.log("B");
}
calculate();
console.log("C");
Die Ausgabe folgt exakt der Reihenfolge des Codes:
A
↓
B
↓
C
Ersetzen Sie nun den Funktionsaufruf durch einen Timer mit einer Verzögerung von null:
console.log("A");
setTimeout(() => {
console.log("B");
}, 0);
console.log("C");
Die Reihenfolge ändert sich:
A
C
B
Das überrascht die Leute, weil die Verzögerung buchstäblich null ist:
0
Die Erklärung lautet, dass setTimeout() niemals „führe das jetzt aus“ bedeutet. Es bedeutet vielmehr, dass der Callback erst nach der Verzögerung ausgeführt werden kann und weiterhin warten muss, bis der aktuell ausführende Code abgeschlossen ist und sich der Laufzeitumgebung Zeit dafür nimmt. Die Entscheidung, wann das geschieht, obliegt dem Event-Loop.
Kurz über den Event-Loop
Ein vereinfachtes Bild reicht aus, um über React nachzudenken:
┌─────────────┐
│ Call Stack │
└──────┬──────┘
│
↓
JavaScript Runs
│
↓
Stack becomes free
│
↓
Event Loop
│
┌──────┴──────┐
↓ ↓
Microtasks Tasks
Der Code wird auf dem Stack ausgeführt, bis dieser leer ist. Erst dann übernimmt der Event-Loop die in der Warteschlange stehenden Aufgaben: Zuerst werden Microtasks (wie z. B. Callbacks von abgeschlossenen Promises) bearbeitet, anschließend die nächste Aufgabe (wie z. B. ein Timer-Callback oder ein Eingabeevent). Reale Browser und Laufzeiten fügen weitere Details hinzu, einschließlich Render-Schritte zwischen den Aufgaben, doch es gibt eine Regel, die hier über alle anderen wichtig ist: synchroner JavaScript-Code, der bereits läuft, kann nicht durch anderen JavaScript-Code unterbrochen werden. Wenn der Thread beschäftigt ist, wartet alles andere. Weitere Informationen zu den Warteschlangen finden Sie unter Closures und der Event-Loop.
Eine absichtlich eingefrorene Anwendung
Um zu verstehen, was diese Regel für React bedeutet, betrachten Sie ein Komponente, deren Press-Handler etwa fünf Sekunden lang in einer Endlosschleife wartet, bevor der Zustand aktualisiert wird. Das Beispiel verwendet React Natives Button, doch das Verhalten ist in React DOM identisch:
function App() {
const [count, setCount] = useState(0);
function handlePress() {
const start = Date.now();
while (Date.now() - start < 5000) {
// intentionally blocking JavaScript
}
setCount(c => c + 1);
}
return (
<Button
title={`Count: ${count}`}
onPress={handlePress}
/>
);
}
Während dieser fünf Sekunden sieht der JavaScript-Thread folgendes aus:
JavaScript Thread
so aus:
██████████████████████████████ BLOCKED FOR 5 SEC
Angestellte Updates, eine dringende Priorität, ein Fiber-Tree, der zur Verarbeitung bereit ist – all das spielt keine Rolle, denn React selbst ist JavaScript und benötigt einen Zeitraum auf dem Thread. Der Scheduler kann den CPU-Zeitrahmen nicht von außerhalb des Laufzeitumfelds einnehmen.
Wer hat die Kontrolle?
Es ist verlockend, sich vorzustellen, dass React oben steht und JavaScript steuert:
React Scheduler
↓
Controls JavaScript
Die genaue Darstellung ist das Gegenteil. Das Laufzeitumfeld gewährt Ausführungszeit, und Reacts Scheduler entscheidet, wie diese genutzt wird:
JavaScript Runtime
↓
Provides execution opportunity
↓
React Scheduler
↓
Coordinates React work
Der Scheduler befindet sich vollständig innerhalb der von JavaScript bereitgestellten Umgebung. Er kann die eigenen Aufgaben von React geschickt organisieren, handelt aber nur dann, wenn ihm ein Thread zur Verfügung gestellt wird.
Warum überhaupt einen Scheduler haben?
Falls React den Event-Loop nicht überschreiben kann, wozu ist dann sein Scheduler nützlich? Betrachten wir einen Bildschirm mit Tausenden von Elementen:
App
├── Header
├── Search
├── Filters
├── ProductList
│ ├── Product
│ ├── Product
│ ├── Product
│ ├── ...
│ └── Product × 5000
└── Footer
Im rein synchronen Modell ist das Rendern dieses Baums ein langer, ununterbrochener Block:
Start Render
↓
████████████████████████████
↓
Finish Entire Render
In der Zwischenzeit interagiert der Benutzer weiterhin:
Type
Tap
Scroll
Falls das Rendern eine einzige, riesige Aufgabe ist, warten diese Interaktionen in der Warteschlange, bis sie abgeschlossen ist – dadurch wirkt die Benutzeroberfläche stecken geblieben. Was React benötigt, ist die Fähigkeit, das Rendern in Einheiten aufzuteilen und mit dem Host zusammenzuarbeiten, indem der Thread regelmäßig zurückgegeben wird, damit Eingaben verarbeitet und das Bild aktualisiert werden können.
Kooperatives Scheduling
Reacts interner Scheduler ist genau diese Art von kooperativem System. Anstelle der alten Vorgehensweise:
Render everything NOW
stellt er kontinuierlich eine Reihe von Fragen zu den ausstehenden Aufgaben:
There is React work pending.What should we work on first?How urgent is it?Should we continue?Should we yield?Did something more important arrive?
Im Überblick sieht der Zyklus so aus:
Pending React Work
↓
Scheduler
↓
Check Priority
↓
Perform Some Work
↓
Should We Yield?
↙ ↘
YES NO
↓ ↓
Give Host Continue
a Chance Rendering
React führt einen Teil der Arbeit aus, prüft, ob er die Kontrolle abgeben sollte, und tut dies im Falle eines Ja, um die Kontrolle an den Host zurückzugeben, damit der Browser (oder die native Laufzeitumgebung) Eingaben verarbeiten, andere Aufgaben ausführen und das Bild aktualisieren kann. Anschließend setzt er dort fort, wo er aufgehört hat, oder überlegt neu, woran er als Nächstes arbeiten soll.
Das Schlüsselwort ist kooperativ. React nimmt JavaScript nichts weg; er strukturiert seine eigene Arbeit so, dass der Thread niemals länger als nötig blockiert wird. Kooperation funktioniert jedoch nur, wenn alle Beteiligten mitmachen.
Zwei Ebenen, zwei Fragen
Es hilft, den Unterschied als zwei separate Fragen zu formulieren.
- Der Event-Loop fragt: Wann kann JavaScript seine in der Warteschlange stehenden Aufgaben ausführen?
- Reacts Scheduler fragt: Da React nun einen Thread hat, welche Reakt-Aufgabe sollte zuerst bearbeitet werden?
Zusammengesetzt sehen die Schichten so aus:
Browser / Native Runtime
↓
JavaScript Runtime
↓
Event Loop
↓
JavaScript gets to run
↓
React Scheduler
↓
Choose React Work
↓
Fiber
↓
Render / Reconcile
↓
Commit
Jede Schicht arbeitet innerhalb der darüber liegenden.
Wenn das Eingeben mit einer umfangreichen Darstellung konkurriert
Stellen Sie sich vor, React ist mitten in der Darstellung einer großen Produktliste:
Rendering Products...
██████████████░░░░░░░░
Dann beginnt der Benutzer, nacheinander Suchanfragen einzugeben:
R
Re
Rea
React
React hat nun zwei Arten von Aufgaben, die um denselben Thread konkurrieren:
Large List Rendering
+
User Input Update
Dem Benutzer ist das Eingeben weitaus wichtiger: Jeder Buchstabe sollte sofort erscheinen, selbst wenn die Liste etwas verzögert ist. Durch die Priorisierung der Tasteneingaben und die Unterbrechung der Liste-Darstellung wird das möglich.
Wie Fiber die Unterbrechungspunkte bereitstellt
Das Planen hilft nur, wenn die Arbeit tatsächlich aufgeteilt werden kann. Das ist die Aufgabe von Fiber: Jede Fiber repräsentiert die Arbeit für einen Teil des Baums, sodass die Darstellung zu einer Abfolge kleiner Schritte statt zu einer einzigen monolithischen Operation wird:
App Fiber
↓
Header Fiber
↓
Search Fiber
↓
List Fiber
↓
Product Fiber
↓
Product Fiber
↓
...
Durch parallele Darstellung kann React einige dieser Schritte verarbeiten, anhalten, sich um etwas Dringenderes kümmern und anschließend weitermachen oder von vorne beginnen:
Start Rendering
↓
Perform Some Fiber Work
↓
Pause
↓
Handle More Urgent Work
↓
Continue / Restart
Reacts Dokumentation beschreibt die parallele Darstellung genau in diesem Sinne als unterbrechbar: Eine Darstellung kann gestartet, pausiert, später wieder aufgenommen oder aufgegeben werden, falls sie veraltet ist – und das alles, bevor irgendetwas gespeichert wird. Das ist eine völlig andere Aussage als die, dass React JavaScript parallel ausführt, was nicht der Fall ist.
Parallel ist nicht gleichzeitig
Der Ausdruck:
Concurrent React
Oft entsteht das Bild von dedizierten Threads für jede Aufgabe:
Thread 1 → InputThread 2 → Product ListThread 3 → React Rendering
Dieses Modell ist falsch. Die Konkurrenz in React erfolgt durch Aufeinandertreffen von Abläufen auf einem einzigen Thread, eher so:
Work A
██████░░░░░░ ↓ pauseWork B
████ ↓Work A
██████
Aufgabe A läuft eine Weile, pausiert, dann läuft Aufgabe B, anschließend setzt sich A fort. Verschiedene Render-Abschnitte werden im Laufe der Zeit koordiniert, doch der Component-Code wird niemals gleichzeitig auf mehreren Threads ausgeführt.
Eilige Aufgaben während eines Übergangs
Nehmen wir an, React rendernt eine aufwändige Aktualisierung, die in einen Übergang eingebettet ist:
startTransition(() => {
setResults(expensiveResults);
});
Während dieser Render noch andauert, gibt der Benutzer einen weiteren Zeichen ein. Die Abfolge sieht dann ungefähr so aus:
Transition Render
██████████░░░░░░░
↓ User Types ↓Urgent Update
████████ ↓React can reconsider
lower-priority work
Weil der Übergang nicht dringend ist, kann React ihn beiseitelegen, zunächst die Tasteneingabe verarbeiten und anschließend zur Aufgabe mit niedrigerer Priorität zurückkehren, oft unter Verwendung eines frischen Zustands.
Aussetzen eines Wertes anstelle einer Aktualisierung
useDeferredValue wendet dieselbe Idee wie auf der Leseseite an. Anstatt eine Zustandsaktualisierung als nicht dringend zu markieren, wird eine verzögerte Kopie eines Wertes erstellt:
const deferredQuery = useDeferredValue(query);
Die Eingabe verwendet weiterhin den echten, aktuellsten Zustand:
<TextInput
value={query}
onChangeText={setQuery}
/>
während der ressourcenintensive Komponente die verzögerte Kopie erhält:
<ExpensiveList query={deferredQuery} />
Dadurch kann das Feld bereits anzeigen:
React
während die Liste noch kurzzeitig die vorherige Abfrage widerspiegelt:
Reac
Unterdessen bereitet React die neue Liste beiseite, und jede weitere Tasteneingabe lässt ihn diesen Versuch einfach ignorieren und mit der neuesten Abfrage erneut beginnen. Dadurch wird die teure Komponente nicht schneller – das Ziel besteht lediglich darin, zu verhindern, dass weniger wichtige Darstellungen die Interaktion stören, auf der der Benutzer sich konzentriert. Die Wahl zwischen den beiden Werkzeugen ist in der Regel einfach: Verwenden Sie startTransition, wenn Sie die Aktualisierung des Zustands selbst steuern, und useDeferredValue, wenn Sie den Wert nur erhalten, beispielsweise als Prop.
Die Grenze: Die Scheduling-Funktion kann Ihren Code nicht unterbrechen
Alles, was oben beschrieben wurde, hängt davon ab, dass React regelmäßig den Thread zurückbekommt. Betrachten Sie nun eine Funktion, die eine enorme synchrone Berechnung durchführt:
function calculateEverything() {
for (let i = 0; i < 10_000_000_000; i++) {
// huge synchronous calculation
}
}
Während sie läuft, ist der JavaScript-Thread:
JS THREAD
vollständig in Anspruch genommen:
████████████████████████████Scheduler waiting...React waiting...Input waiting...
Der Scheduler, React und die ausstehenden Eingaben warten alle gemeinsam. Eine kooperative Scheduling-Strategie kann nur zwischen den Arbeitseinheiten von Reacts eigener Logik wechseln; sie kann nicht in einen von Ihnen geschriebenen Loop eingreifen. Daher ersetzt konkurrierendes Rendering kein effizientes JavaScript. Schwere Berechnungen müssen weiterhin reduziert, in Blöcke unterteilt, gememorisert oder vom Hauptthread weggeführt werden – beispielsweise in einen Worker.
Warum Renderen und Committing getrennt bleiben
Dieses gleiche Modell erklärt auch, warum React darauf besteht, eine Aktualisierung in zwei Phasen aufzuteilen:
Render
und:
Commit
Das Rendern ist der Moment, in dem React herausfindet, wie die Benutzeroberfläche aussehen soll – im konkurrierenden Modus kann diese Vorbereitung unterbrochen werden. Was React jedoch nicht zulässt, ist die Anzeige eines unvollendeten Ergebnisses. Daher während der Render-Phase:
Render
React kann einige Fiber-Operationen abschließen, unterbrochen werden und anschließend fortsetzen oder neu starten; erst wenn der gesamte Baum fertig ist, führt es den Commit durch:
Fiber A ✓
Fiber B ✓
Fiber C ...
↓
Interrupted
↓
Continue / Restart
↓
Entire Tree Ready
↓
COMMIT
↓
Visible UI
Deshalb erzeugt eine unterbrochene Renderung niemals ein Bildschirmbild, das verschiedene Zustände mischt:
Half old UI
+
Half new UI
Der Commit wendet stets nur ein konsistentes, abgeschlossenes Ergebnis an.
Warum Renderungen rein sein müssen
„Haltet die Komponenten während der Renderung rein“ ist ein bekannter Ratschlag – unterbrechbare Renderungen zeigen genau, warum das wichtig ist. Schauen Sie sich eine Komponente an, die aus ihrem Code einen Analytics-Aufruf auslöst:
function Product() {
analytics.track("Product Rendered");
return <View />;
}
Beim konkurrierenden Rendering kann React eine solche Abfolge durchlaufen:
Starts Render
↓
Pauses
↓
Abandons Work
↓
Renders Again
Jeder dieser Versuche ruft die Komponentenfunktion auf, sodass dieser Aufruf:
analytics.track(...)
Kann bei Renderings ausgelöst werden, die nie gespeichert wurden und von niemandem gesehen wurden. Ihre Metriken würden dann phantomhafte Produktansichten erfassen. React benötigt die Freiheit, dies zu tun:
Render
Pause
Retry
Abandon
Restart
ohne dass eine dieser Aktionen die Außenwelt verändert. Nebeneffekte gehören in Effects oder Event-Handler, die nur für gespeicherte Aktionen ausgeführt werden. Für eine praktische Anleitung zur Auswahl des geeigneten Ortes für Logik, siehe die richtige React-Tool-Wahl: derive, handle, fetch, defer oder effect.
Der React Native-Aspekt
In einer React Native-App verläuft der Weg von einer Tastatureingabe bis zum Bildschirm durch dieselben Schichten, wobei am Ende ein nativer Renderer steht:
User Types
↓
JS Event Handler
↓
setState()
↓
React Scheduler
↓
Fiber Work
↓
React Native Renderer
↓
Native UI Update
Blockieren Sie den JavaScript-Thread mit aufwändiger synchroner Arbeit, und die Kette stoppt bereits bei dem zweiten Schritt:
User Types
↓
JS Thread Busy
████████████████
↓
React can't process JS work yet
Die App scheint eingefroren zu sein, obwohl der native UI-Thread selbst völlig intakt sein könnte. Bei der Diagnose von Langsamkeit lohnt es sich, die möglichen Ursachen voneinander zu trennen, da jede eine andere Lösung erfordert:
Is React rendering too much?ORIs JavaScript itself blocked?ORIs expensive native work happening?ORIs the UI thread overloaded?
Zu viele Renderaufrufe erfordern Memoisierung, Virtualisierung oder Übergänge. Ein blockierter JavaScript-Thread erfordert das Verschieben oder Aufteilen der aufwendigen Arbeit. Teure native Aufgaben oder ein überlasteter UI-Thread deuten hingegen auf eine native Profilierung hin.
Der gesamte Ablauf
Wenn man alle Komponenten zusammenfasst, verläuft eine einzelne Interaktion durch diese Phasen:
USER INTERACTION
↓
JavaScript Runtime
↓
Event Handler
↓
setState()
↓
Update Created
↓
Lane / Priority
↓
React Scheduler
↓
Choose Work
↓
Fiber Render
↓
Can Yield / Restart
↓
Reconciliation
↓
Completed Work
↓
Commit
↓
UI Updated
Und all das findet innerhalb der Grenzen des JavaScript-Runtimes statt:
┌─────────────────────────────┐
│ JavaScript Runtime │
│ │
│ Event Loop gives JS a chance│
│ ↓ │
│ React coordinates │
│ its own work │
└─────────────────────────────┘
Der Event-Loop weist JavaScript seine Aufgaben zu; innerhalb jeder solchen Aufgabe entscheidet React, wie diese genutzt werden sollen.
Fragen, auf die man antworten können sollte
Ist Reacts Scheduler dasselbe wie der Event Loop?
Nicht unbedingt. Der Event Loop gehört zum Host-Runtime-Umfeld. Reacts Scheduler koordiniert die Arbeiten von React innerhalb der vom Runtime gewährten Zeit.
Führt konkurrierendes Rendering dazu, dass Komponenten auf mehreren Threads ausgeführt werden?
Nicht direkt. Konkurrierendes Rendering ermöglicht es React, die Render-Arbeiten auf einem einzigen Thread anzuhalten, fortzusetzen, neu zu starten oder abzubrechen. Der Komponentencodex wird dabei nicht parallelisiert.
Kann React das Rendering unterbrechen?
Ja, im Rahmen des konkurrierenden Renderings. React kann dringende Aktualisierungen vorziehen und veraltete Render-Arbeiten vor deren Abspeicherung ignorieren.
Kann der Scheduler einen blockierten Thread retten?
Nicht direkt. Lang andauernde, synchrone JavaScript-Ausführungen halten React genauso wie alles andere in Wartestellung, bis der Thread wieder frei wird.
Warum müssen Komponenten rein bleiben?
Denn eine Renderung kann mehrmals ausgeführt werden und unterbrochen oder verworfen werden. Nebenwirkungen während der Renderung dringen in Arbeiten ein, die niemals auf dem Bildschirm angezeigt werden.
Wie sich das mentale Modell entwickelt
Die meisten Entwickler beginnen mit dem Bild, dass das Setzen des Zustands direkt zu einer erneuten Renderung führt:
setState()
↓
React renders
↓
Screen updates
Das Erlernen von Puffern und Scheduling fügt einige Zwischenschritte hinzu:
setState()
↓
Update
↓
Lane
↓
Scheduler
↓
Render
↓
Commit
Die letzte Schicht ist die Laufzeit selbst, die unter allem liegt, was React tut:
JavaScript Runtime
↓
Event Loop
↓
React Scheduler
↓
Fiber
↓
Render
↓
Commit
React plant seine eigenen Aufgaben intelligent, aber immer aus dem JavaScript-Umfeld heraus. Wenn man es so betrachtet, passen Übergänge, unterbrechbare Renderungen sowie blockierte JS-Threads zu einem kohärenten Bild.
Wohin die Pipeline als Nächstes geht
In diesem Artikel wurde der Weg von einer Zustandsaktualisierung bis zum Event-Loop nachverfolgt:
State Update
↓
Update Queue
↓
Fiber
↓
Lanes
↓
Scheduler
↓
JavaScript Event Loop
In React Native setzt dieser Weg fort, von den Commit-Operationen in React bis hin zu den tatsächlichen Plattformansichten. Ein so einfaches Baumstrukturmodell:
<View>
<Text>Hello React Native</Text>
</View>
muß dennoch durch den Bundler, den Hermes-Engine, JSI sowie den Fabric-Renderer gelangen, bevor eine native Android- oder iOS-Oberfläche entsteht. Diese Schichten bringen eigene Threads sowie Scheduling-Probleme mit sich, die es lohnt, getrennt voneinander zu verstehen, sobald der JavaScript-Teil klar ist.
Haupterkenntnisse
- Der Event-Loop entscheidet, wann JavaScript ausgeführt wird; Reacts Scheduler bestimmt, welche React-Aufgaben in diesem Zeitraum erledigt werden sollen.
- Reacts Scheduling ist kooperativ: Es gibt den Kontrollfluss zwischen Fiber-Aufgaben frei, damit Eingaben verarbeitet und Bilder dargestellt werden können.
- Konkurrenz in React bedeutet das Abwechseln von Aufgaben auf einem einzigen Thread, nicht parallele Ausführung.
startTransition markiert Aktualisierungen als nicht dringend; useDeferredValue ermöglicht es einem Komponenten, mit einem verzögerten Wert zu rendern.