Zum Inhalt

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

  1. Local Queue (eigene, lock-free, Chase-Lev Deque)
  2. Global Queue (geteilt, selten benutzt)
  3. Work-Stealing (Hälfte von zufälligem Worker stehlen)
  4. 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.

val h = spawn { file.read("a.yaml") }
val b = file.read("b.yaml")
val a = h.join()

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

Nächster Tick = handler_end + max(0, interval - handler_time)

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:

engineId
reductionLimit
timerWheelConfig
resourceLimits
capabilityMask
overrunPolicy
metricsConfig

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.