VM-Scheduler & Intrinsic-ABI Spezifikation¶
Status: Normativer Entwurf Geltungsbereich: VM-Architektur, Task-Management, Concurrency, I/O, Timer, Intrinsic-ABI Bezieht sich auf: Runtime-Modell (03), Intrinsics & VM API (08), Engine-Lifecycle, Design Constitution Ring-Zugehörigkeit: Ring 0/1 (D-Implementierung) Autor: Elias
1. Architektur¶
Der Scheduler ist Teil der VM, nicht extern. Ein Worker-Thread führt denselben Code aus: Task holen (Scheduler), Bytecode ausführen (VM), bei Yield/Preemption zurück zum Scheduler. Zwei verschachtelte Loops, eine Funktion.
Die Jade-VM ist ein OS-Prozess mit folgenden Threads:
Worker 0..N: Scheduler-Loop + VM-Loop (verschachtelt)
I/O-Poller: epoll_wait Loop (schläft bis Event)
Master-Clock: Timer-Tick Loop (schläft bis nächster Tick)
1.1 M:N Work-Stealing¶
M Tasks auf N Worker-Threads. N = CPU-Kerne (konfigurierbar). Tasks wandern zwischen Workern via Work-Stealing.
graph TB
GQ[Global Queue] --> W0[Worker 0]
GQ --> W1[Worker 1]
GQ --> W2[Worker N]
W0 <-.->|steal| W1
W1 <-.->|steal| W2
POLL[I/O Poller] -->|weckt Tasks| W0
POLL -->|weckt Tasks| W1
MC[Master Clock] -->|Timer-Events| W0
MC -->|Timer-Events| W2
1.2 Task-Beschaffung pro Worker¶
- Local Queue (eigene, lock-free, Chase-Lev Deque)
- Global Queue (geteilt, selten benutzt)
- Work-Stealing (Hälfte von zufälligem Worker stehlen)
- Parken (OS-Level sleep, bis unparked)
2. Der Task¶
Kleinste suspendierbare Ausführungseinheit.
stateDiagram-v2
[*] --> Runnable: spawn()
Runnable --> Running: Worker nimmt auf
Running --> Runnable: Preemption
Running --> Suspended: I/O-Yield / Lock / Timer
Running --> Dead: Beendet / Error
Suspended --> Runnable: Completion / Release / Expiry
Dead --> [*]
Task-Datenstruktur:
id TaskId
state Running | Runnable | Suspended | Dead
registers RegisterFile (gesicherter Zustand)
ip Instruction Pointer
reductions Preemption-Counter
engineId Zugehörige Engine (Config-Lookup)
wakeCondition Worauf wartet dieser Task?
result Ergebnis nach Beendigung
2.1 Task vs. Actor¶
Task = VM-Ebene (Ring 0/1). Kein Mailbox, keine Supervision. Actor = JDL-Ebene (Ring 2). Ein Task mit Mailbox, Supervision und State. Jeder Actor ist ein Task.
3. Yield-Mechanismen¶
3.1 Kooperativer Yield (I/O)¶
Intrinsics die blockieren würden, geben JadeResult.yield() zurück. Der Task wird suspendiert, der Worker holt den nächsten.
sequenceDiagram
participant T as Task
participant W as Worker
participant IO as I/O Poller
participant OS as OS
T->>W: INTRINSIC FILE_READ
W->>IO: I/O-Request registrieren
W->>W: Task suspendieren, nächsten laden
OS-->>IO: Completion
IO->>W: Task aufwecken
W->>T: Ergebnis in Register, resumed
3.2 Präemptiver Yield (CPU)¶
Reduction-Counter pro Task, konfiguriert durch die Engine. Bei null wird der Task hinten in die Queue gestellt — lauffähig, aber andere kommen erst dran.
Engine-Typ Reduction-Limit
HttpTransport 1.000–5.000
ActorSupervisor 3.000–5.000
BatchProcessor 50.000
RenderEngine 10.000
REPL 1.000
3.3 Transparente Asynchronität (Imperativer Stil)¶
Diese Sektion beschreibt das Verhalten des imperativen Stils. Der Effect-Stil (Effect[R, E, D], siehe 07-cast-effekte-refinements.md § 2) macht Service-Abhängigkeiten erstklassig im Typ sichtbar und ist die idiomatische Schicht für User-Code, der Services konsumiert. §3.3 trifft keine Aussage über den Effect-Stil — dort lebt die Färbung explizit im D-Channel.
Im imperativen Stil sieht User-Code synchrone Aufrufe. Blockierende Intrinsics yielden über den Scheduler — der Task wird suspendiert, der Worker holt den nächsten, die Wiederaufnahme erfolgt transparent. Es gibt keine async/await-Schlüsselwörter, keine zweite Aufrufkonvention pro Operation.
val data = file.read("config.yaml") // Task yieldet unsichtbar
val parsed = parser.parse(data) // data ist garantiert da
Suspendability ist im imperativen Stil nicht im Funktionstyp sichtbar — sie ist eine Eigenschaft des verwendeten Service bzw. der aufgerufenen Intrinsics. Wer diese Sichtbarkeit braucht (Test, Komposition, Dependency-Tracking), arbeitet im Effect-Stil; dort trägt der D-Channel die Service-Färbung exakt.
Für expliziten Parallelismus im imperativen Stil dient spawn { ... } -> Handle (Intrinsic-Gruppe 0x04__, vm). Das Handle wird über handle.join() synchronisiert. Im Effect-Stil leistet die Effect-Kompositions-API (Stdlib) dasselbe ohne Sprach-Sonderfall.
Eine eigene readAsync/xxxAsync-API-Variante existiert nicht — sie wäre genau die Funktionsfärbung, die der imperative Stil vermeidet, und ihre Aufgabe übernimmt auf Sprachebene spawn.
4. Engine Timer-Wheels¶
4.1 Kein globaler Heartbeat¶
Der Scheduler hat keinen eigenen Tick. Er ist event-getrieben. Im Leerlauf: null CPU-Verbrauch. Die Timing-Quelle sind Engine Timer-Wheels — pro Engine eins, manche haben keins.
Mit Timer-Wheel: RenderEngine (60Hz), HealthCheck (1Hz)
Ohne Timer-Wheel: HttpTransport (reaktiv), ActorSupervisor (event-driven)
4.2 Master-Clock¶
Ein Thread der mit der feinsten Granularität aller aktiven Wheels tickt. Ohne aktive Wheels schläft er. Tick-Divisoren für gröbere Wheels.
4.3 Hierarchisches Timer-Wheel¶
Drei Ebenen pro Engine-Wheel:
Level 0: 256 Slots × Basis-Granularität (fein, kurze Timer)
Level 1: 64 Slots × 256 × Granularität (mittel)
Level 2: 64 Slots × 64 × 256 × Granularität (grob, lange Timer)
Einfügen O(1), Feuern O(1) amortisiert. Timer kaskadieren von Level 2 → 1 → 0.
4.4 Korrigierter Drift als Default¶
Impliziter adaptiver Back-Pressure: das System kann nie schneller ticken als der Handler verarbeiten kann. Keine verschwendete Arbeit, kein Queue-Stau.
4.5 Overrun-Policies¶
:> OverrunPolicy(Drift) // Default. Adaptiv drosseln.
:> OverrunPolicy(Skip) // Verpasste Ticks überspringen (Rendering).
:> OverrunPolicy(Queue) // Verpasste Ticks nachholen (Simulation).
4.6 Metrics¶
Jedes Wheel trackt: targetRate, actualRate, avgHandlerTime, maxHandlerTime, driftEvents. Abfragbar via Intrinsic.
5. I/O-Integration¶
Der I/O-Poller ist ein dedizierter Thread — VM-Infrastruktur, kein Actor. Er pollt das OS (epoll/kqueue/io_uring), weckt Tasks bei Completion.
I/O ist async per Default unter der Haube, synchron an der Oberfläche. Der Event-Loop lebt unter den Actors, nicht als Actor. Actors brauchen I/O um zu kommunizieren — zirkulär wenn I/O selbst ein Actor wäre.
6. Engine-Integration¶
6.0 Begriffstrennung: semantische Engine vs. Scheduler-Engine¶
Der Scheduler verwendet den Begriff Engine ausschließlich als Runtime-Konfiguration: engineId -> EngineRuntimeConfig. Er kennt keine JDL-semantische Engine wie ActorEngine, FfiEngine oder ProtocolDispatcherEngine.
EngineDefinition Ring-1-JDL: validiert Blueprints und erzeugt Descriptoren
EngineDescriptor materialisierter Vertrag aus Blueprint + Refinements
EngineRuntimeConfig Ring-0-Schedulerdaten: Limits, Timer-Wheel, Capabilities
EngineInstance laufendes Ergebnis, sichtbar über Handle/Ref
Der Scheduler konsumiert nur EngineRuntimeConfig. Die semantische Engine erzeugt diese Konfiguration über run() beziehungsweise describe(). Dadurch bleibt der Scheduler dumm, mechanisch und stabil. Eine kleine Gnade in einer Welt, die sonst alles in „Manager“ umbenennt.
6.1 Engine als Task-Gruppe mit Konfiguration¶
Eine Engine ist kein Thread. Sie ist eine Konfiguration in einer Lookup-Tabelle. Alle Tasks einer Engine tragen deren engineId. Der Worker schaut bei jedem Task: engines[task.engineId].reductionLimit.
Vergleichbar mit Linux cgroups: gruppiert Tasks, setzt Ressourcen-Limits.
6.2 Drei Performance-Stellräder¶
Reduction-Limit Instruktionen vor Preemption
Timer-Wheel Timing-Granularität und Reichweite
Ressourcen-Limits Max Tasks, Max Memory
6.3 Registrierung über run()¶
Die run()-Funktion liest Blueprint-Refinements und Engine-Defaults, erzeugt einen EngineDescriptor, leitet daraus eine EngineRuntimeConfig ab und registriert diese beim Scheduler. Die Engine selbst berührt den Scheduler nicht.
EngineRuntimeConfig enthält mindestens:
Tasks speichern nur die engineId. Worker dereferenzieren diese ID gegen die Scheduler-interne Engine-Tabelle.
7. Intrinsic-ABI¶
7.1 16-Bit-Adressierung¶
Intrinsics sind nummeriert, nicht benannt. Die Nummer ist der stabile ABI-Vertrag.
0x0000–0x00FF memory handle_alloc, handle_free, arena_*
0x0100–0x01FF atomic atomic_load, atomic_store, atomic_cas
0x0200–0x02FF sync mutex_lock, mutex_unlock, sem_*
0x0300–0x03FF io file_read, file_write, socket_*
0x0400–0x04FF vm spawn, await, cancel, send, receive
0x0500–0x05FF time clock_now, sleep, timer_*
0x0600–0xFEFF reserved Zukünftige Erweiterungen
0xFF00–0xFFFF user Plugin-definierte Intrinsics
High-Byte = Gruppe (256 Gruppen). Low-Byte = Intrinsic innerhalb der Gruppe (256 pro Gruppe).
7.2 Stabilitätsgarantien¶
Nie entfernen Nur deprecaten. Nummer bleibt für immer.
Signatur einfrieren Argumente und Rückgabetyp ändern sich nie.
Reservierte Bereiche Platz für Erweiterungen ohne Umnummerierung.
User-Bereich 0xFF__ für Plugin-Intrinsics.
7.3 Opcode-Promotion¶
Jade-Intrinsics (0x0000–0xFEFF) können im Release-Build zu nativen Opcodes promoviert werden. User-Intrinsics (0xFF__) gehen immer durch die Dispatch-Tabelle.
Debug-Build (jade build --debug):
Codegen emittiert: INTRINSIC 0x0100 → Dispatch-Tabelle
Vorteil: Abfangbar, profilierbar, mockbar
Release-Build (jade build --release):
Codegen emittiert: ATOMIC_LOAD R3, R4 → nativer Opcode
Vorteil: Kein Table-Lookup, inline im Switch
Die VM (D-Binary) hat beides kompiliert. Der Bytecode bestimmt welcher Pfad genommen wird. Ein VM-Binary, zwei Bytecode-Varianten.
User-Intrinsics haben keine nativen Opcodes weil sie zur VM-Compile-Zeit unbekannt sind.
7.4 Intrinsic-Aufruf¶
Kein JadeValue, kein Marshalling. Intrinsic-Handler sind D-Funktionen im selben Prozess. Register enthalten rohe Werte, Handles sind Indizes in die Handle-Tabelle. Ein Array-Zugriff, ein Funktionsaufruf.
Schnelle Intrinsics (atomic, handle): Synchron, ~5-10ns, kein Yield
Blockierende Intrinsics (I/O, sleep): Yield, Task suspendiert, ~0ns für den Worker
7.5 Capability-Filter¶
Nicht jede Engine darf jeden Intrinsic aufrufen. Eine sandboxed Engine sieht einen eingeschränkten Intrinsic-Satz. Der Scheduler prüft bei jedem Aufruf die Capabilities.
8. Speicheranforderungen¶
Pro Task: ~300–600 Bytes (RegisterFile + Metadata)
Pro Worker: ~4 KB (Local Queue + State)
Pro Engine: ~2–4 KB (Timer-Wheel + Config)
Bei 10.000 aktiven Tasks: ~3–6 MB Scheduler-Overhead.
Vergleich: Go-Goroutines ~2–8 KB, Erlang-Prozesse ~2.5 KB, Rust-Tokio ~256–512 Bytes.
9. D-Implementierungshinweise¶
Local Queue: Chase-Lev Work-Stealing Deque (lock-free, ~150 SLOC D).
Dispatch: final switch auf enum Op : u16 — Jump Table, O(1), ~1-3ns.
Timer-Wheel: Drei Arrays, Cursor, Kaskadier-Funktion (~200 SLOC D).
Memory: std.experimental.allocator — composable Allocators für Arenas, Handle-Pools, Bytecode-Speicher.
Threads: core.thread für Worker, core.atomic für Lock-Free-Strukturen, core.sys.linux.epoll für I/O-Polling.
Inkrementeller Aufbau:
V1: Ein Worker, eine Queue, Reduction-Counter, epoll. (~500 SLOC)
V2: N Worker, Work-Stealing. (+300 SLOC)
V3: Globales Timer-Wheel. (+200 SLOC)
V4: Pro-Engine Timer-Wheels, SchedulerConfig. (+300 SLOC)
V5: Opcode-Promotion, Capability-Filter, Metrics. (+400 SLOC)
10. Invarianten¶
S-1 Fairness Kein Task wird dauerhaft ausgehungert.
S-2 Kein globaler Tick Scheduler ist event-getrieben.
S-3 Transparente Async Yield transparent im imperativen Stil; Färbung lebt im Effect-Stil.
S-4 Kooperative Suspend. Nur an Yield-Points und Preemption-Points.
S-5 Determ. Cleanup Handle-basiert, kein GC, kein Finalizer.
S-6 Engine-lokales Timing Timer-Wheels gehören Engines.
S-7 Adaptiver Back-Press. Korrigierter Drift als Default.
S-8 Proportionale Ressourcen Null Last = null Verbrauch.
S-9 Stabile ABI Intrinsic-IDs ändern sich nie.