Jactl Continuations en Virtual Threads in Java 8
Introductie
Jactl is een veilige, inbedbare scripttaal voor Java-applicaties. Toen ik begon met de ontwikkeling van Jactl, wilde ik een scripttaal die compileerde naar bytecode voor optimale prestaties, veilig was zodat applicaties precies konden bepalen wat scripts wel en niet konden doen, en bovenal de uitvoeringsdraad (execution thread) niet blokkeerde bij het uitvoeren van langdurige, blokkerende operaties.
Ten tijde van de ontwikkeling bestonden Java 21 en Virtual Threads nog niet. Event-driven, reactieve applicaties (zoals die gebaseerd op Vert.x) waren destijds de standaard voor Java-applicaties met een hoge doorvoersnelheid. Daarnaast was er een behoefte aan een scripttaal die zou werken op applicaties die nog gebruikmaakten van Java 8 of Java 11.
Opmerking: In nieuwere versies van Java kunnen applicaties die Virtual Threads willen gebruiken in plaats van een event-based architectuur, het ingebouwde async-mechanisme van Jactl uitschakelen door de vlag JactlContext.async(false) te configureren.
Een reactieve applicatie is een applicatie waarbij een pool van event-loop threads events uit een wachtrij verwerkt. De gouden regel is dat events nooit mogen blokkeren; dit zou namelijk een van de event-loop threads pauzeren, waardoor deze geen nieuwe events kan verwerken totdat de blokkerende operatie is voltooid. Een blokkerende operatie is een situatie waarin de thread niet langer actief code verwerkt, maar wacht op het resultaat van een operatie, zoals een databaseverzoek of een remote procedure call. Als blokkerende operaties op een event-loop thread voorkomen, ontstaan er uiteindelijk situaties waarin alle threads wachten op langdurige operaties en er geen events meer worden verwerkt.
Mijn doel was een scripttaal te creëren die vanuit een event-loop thread kon worden aangeroepen, maar die bij elke blokkerende operatie zijn status kon opslaan en kon terugkeren, om zo de thread vrij te maken voor andere events. Zodra het resultaat van de langdurige operatie beschikbaar was, zou het script worden hervat vanaf het punt waar het was gestopt.
In Java 21 en later bieden Virtual Threads dezelfde functionaliteit: ze bewaren de call stack met alle lokale variabelen en laten de thread ander werk uitvoeren. Zodra de blokkerende operatie voltooid is, wordt de call stack hersteld en gaat het programma verder.
Bij Jactl was het doel uitsluitend de uitvoeringsstatus van de Jactl-code op te slaan, niet de status van de Java-code die het Jactl-script aanriep. Aangezien de Java-applicatie event-based is, wordt het script voltooid als een nieuw event op een event-loop thread. Een completion callback wordt aangeroepen zodra het script klaar is, wat terugkoppelt naar de Java-applicatie met het resultaat van het script. De door de applicatie aangeboden callback kan alle status bevatten die de applicatie nodig heeft.
Continuations
In Java 8 is er geen manier om de call stack te bewaren, noch in Java, noch in JVM-bytecode. Daarom moest er een ander mechanisme worden gebruikt om hetzelfde resultaat te bereiken.
Stel dat we een script hebben dat een functie (of methode) moet aanroepen die een langdurige operatie uitvoert. Voor het voorbeeld gaan we ervan uit dat de functie een bepaalde tijd moet sleep() voordat deze verdergaat.
Er zal een Java call stack zijn met een stack frame voor elke geneste methode-aanroep, gevolgd door Jactl stack frames voor elke geneste Jactl-functieaanroep. Het bovenste stack frame is dan dat van de sleep()-functie zelf. Elk stack frame houdt bij waar in de code de functie-aanroep plaatsvindt, samen met de waarden van de lokale variabelen.
Om de uitvoeringsstatus vast te leggen, is gekozen voor de volgende aanpak: er wordt een uitzondering (exception) gegooid aan het begin van een langdurige operatie zoals sleep(). In elke Jactl-methode/functie wordt code gegenereerd die deze uitzondering opvangt, de status opslaat en een nieuwe uitzondering gooit die is gekoppeld aan de zojuist opgevangen uitzondering.
De klasse voor de uitzondering die wordt gegooid heet Continuation, omdat een continuation een representatie is van de uitvoeringsstatus van een programma. De implementatie van de sleep()-functie ziet er ongeveer als volgt uit:
public static Object sleep(long timeMs) {
Continuation continuation = new Continuation();
scheduleEvent(timeMs, () -> continuation.continueExecution());
throw continuation;
}
Terwijl deze uitzondering de call stack afwikkelt (unwinds), vangt de gegenereerde code voor elk Jactl stack frame deze op. Er wordt een eigen Continuation aangemaakt om vast te leggen waar het script was (de locatie van de aanroep waar het op wachtte) en de waarden van de lokale variabelen. Vervolgens wordt een nieuwe Continuation gegooid die is geketend aan de vorige.
Tegen de tijd dat de uitzondering de onderkant van de Jactl call stack bereikt, is de oorspronkelijke call stack vervangen door een keten van Continuation-objecten. Elk object representeert een frame en samen leggen ze de volledige uitvoeringsstatus van het script vast.
Opmerking: Voor wie ervaring heeft met performance tuning van Java-applicaties, roept het gooien van uitzonderingen direct gedachten op over de kosten hiervan. In werkelijkheid zit de kostprijs van het gooien van een uitzondering echter grotendeels in het genereren van de stack trace. Zolang er een uitzondering wordt gegooid die de stack trace niet invult, is het proces zeer efficiënt.
Async Functies
De Jactl-compiler weet welke globale functies langdurige operaties kunnen uitvoeren en een Continuation-object kunnen gooien. Deze functies worden async functies genoemd. De compiler houdt bij welke methoden en functies deze async functies aanroepen en markeert deze op hun beurt ook als async.
Dit proces zet zich voort in de aanroepketen, zodat de compiler op elk moment weet of een aanroep potentieel een Continuation-object kan gooien, zelfs als de ingebouwde globale functie die de eerste Continuation gooit, diep begraven ligt in een reeks geneste aanroepen.
Het aanroepen van Async Methoden/Functies
Wanneer de compiler code genereert die een functie aanroept die als async is gemarkeerd, wordt de aanroep omhuld in een try/catch-blok dat elke gegooid Continuation opvangt. De code in het catch-blok maakt een nieuw Continuation-object aan en slaat hierin een MethodHandle en een locatie op.
De MethodHandle verwijst naar de huidige functie en de locatie is een logische markering die vastlegt waar in de huidige functie de aanroep naar de async functie plaatsvond. Naast de MethodHandle en de locatie, genereert de compiler code om de waarden van de lokale variabelen in scope en eventuele waarden op de lokale stack op te slaan.
Er worden twee aparte arrays gebruikt:
- Een
long[]voor lokale variabelen en stack-waarden die primitives zijn. - Een
Object[]voor alle overige typen.
Elke async functie krijgt impliciet een Continuation-object mee als eerste argument. De eerste keer is dit argument null, maar als de functie werd gepauzeerd vanwege een langdurige operatie en later wordt hervat, wordt deze opnieuw aangeroepen met de Continuation die hij oorspronkelijk gooide.
De gegenereerde code controleert of het continuation-argument niet-null is en gebruikt in dat geval de locatie binnen het Continuation-object om te bepalen waar in de functie de uitvoering moet worden hervat.
Hieronder staat pseudocode die laat zien hoe de gegenereerde code voor een functie die een andere async functie aanroept, eruit zou kunnen zien:
static MethodHandle processOrderHandle = MethodHandles.lookup().findStatic("processOrder");
Object processOrder(Continuation cont, ...) {
Order order;
Widget widget;
int count;
if (cont != null) {
// Hervatten vanaf het punt waar we stopten na het herstellen van lokale variabelen
switch (cont.location) {
case 0:
// Herstel locals
order = cont.objArr[0];
widget = cont.objArr[1];
count = cont.longArr[0];
goto LOCATION_0;
case 1:
...
goto LOCATION_1;
}
}
// Code voor de functie
...
try {
checkInventory(widget, count);
}
catch (Continuation c) {
throw new Continuation(c, processOrderHandle,
0, // de locatie
new long[]{ count },
new Object[]{ order, widget });
}
LOCATION_0:
...
}
Let op dat de Continuation-constructor zichzelf ketent aan de zojuist opgevangen Continuation, zodat de keten begint bij de Continuation van de bovenkant van de stack.
De uitvoering hervatten
Zodra een langdurige operatie is voltooid, wordt het initiële Continuation-object in de keten hervat door de methode continueExecution(Object result) aan te roepen. Deze methode extraheert de MethodHandle en roept deze aan, waarbij de Continuation wordt meegegeven zoals eerder beschreven, zodat de functie lokale variabelen kan herstellen en kan bepalen waar de uitvoering moet worden hervat.
Omdat de call stack niet meer overeenkomt met de oorspronkelijke call stack, keert de functie bij teruggave niet terug naar de oorspronkelijke ouderfunctie, maar naar de Continuation.continueExecution() methode. Deze extraheert vervolgens het volgende Continuation-object in de keten en roept diens MethodHandle aan. Dit gaat door totdat er geen Continuation-objecten meer in de keten zitten en de geregistreerde completion van de applicatie wordt aangeroepen met het uiteindelijke resultaat.
Een volgende Async Functie aanroepen
Tijdens het doorlopen van de keten van Continuation-objecten en het hervatten ervan, kan er opnieuw een async functie worden aangeroepen die een nieuwe Continuation gooit voor een nieuwe langdurige operatie. Dit kan gebeuren vanuit een functie die vele geneste aanroepen verder in de stack zit.
Wanneer dit gebeurt, nemen we de nieuwe keten van continuations en voegen we het restant van de bestaande keten toe aan het einde van die nieuwe keten. Wanneer de nieuwe langdurige operatie is voltooid en de bijbehorende Continuation-keten wordt hervat, bestaat de keten nu uit alle nieuwe continuations gevolgd door de overgebleven oude. Er wordt eerst elke nieuwe continuation hervat en daarna wordt er doorgegaan met de resterende in de oude keten.
Checkpointing van de uitvoeringsstatus
Toen Jactl in staat was de huidige uitvoeringsstatus op te slaan in een keten van continuations, realiseerde ik me dat als deze continuations geserialiseerd konden worden naar een byte-array, ik dit kon gebruiken om de status van een script te checkpointen.
Jactl biedt een checkpoint()-functie waarmee een script zijn status kan vastleggen op belangrijke stappen tijdens de verwerking. Zodra een scriptstatus is gecheckpoint, kan deze status worden opgeslagen op schijf, in een database, of worden gerepliceerd over een netwerk naar een andere applicatie-instantie. De status kan op elk gewenst moment worden hervat als de oorspronkelijke host-applicatie bijvoorbeeld uitvalt.
Voor elk ingebouwd type en elke door de gebruiker gedefinieerde klasse genereert Jactl code om instanties van deze typen op te slaan in een byte-array, samen met andere typen die intern door de Jactl-runtime worden gebruikt. Jactl biedt vervolgens hooks die de applicatie kan gebruiken om deze scriptstatussen te bewaren of te repliceren als onderdeel van een redundantie-oplossing voor applicatiestatus. Er is een overeenkomstige Jactl-mechanisme die de applicatie kan gebruiken om de status te hervatten wanneer dat nodig is.
Benchmarks
Ik heb een benchmark gemaakt met de JMH-bibliotheek om te laten zien hoe het continuation-mechanisme de prestaties beïnvloedt. De SuspendResumeBenchmark gebruikt Vert.x voor de event-scheduling en uitvoering, en benchmarkt een Jactl-script dat batches van 200 orders verwerkt.
Het script ziet er als volgt uit:
var totals = [:]
var itemCount = 0
var grandTotal = 0.0
var topCategory = ''
var topAmount = -1.0
var slept = 0
def checkInventory(widget, count) {
sleep(0) if slept++ < sleepCount
return true
}
def processOrder(order) {
var price = order.price
var qty = order.quantity
var category = order.category
return unless checkInventory(order.category, order.quantity)
var discount = 0.0
if (qty >= 100) { discount = 0.20 }
else if (qty >= 50) { discount = 0.10 }
else if (qty >= 20) { discount = 0.05 }
var lineTotal = price * qty * (1.0 - discount)
if (totals[category] == null) {
totals[category] = 0.0
}
totals[category] = totals[category] + lineTotal
grandTotal = grandTotal + lineTotal
itemCount = itemCount + 1
if (totals[category] > topAmount) {
topAmount = totals[category]
topCategory = category
}
}
for (order in orders) {
processOrder(order)
}
'Processed ' + itemCount + ' orders. Grand total: ' + grandTotal + '. Top category: ' + topCategory
Voor elke batch wordt processOrder() aangeroepen voor elke order, wat vervolgens checkInventory() aanroept (die altijd true retourneert). De checkInventory()-functie roept de eerste n keer sleep(0) aan, zodat we de overhead van het pauzeren en hervatten van een script vanuit een geneste call stack kunnen meten. De aanroep naar sleep(0) pauzeert het script door een Continuation te gooien, maar omdat de slaaptijd 0 is, wordt er onmiddellijk een resume-event ingepland om de uitvoering te vervolgen.
De benchmark meet de prestaties wanneer het script 0, 1, 2, 5 en 10 aanroepen naar sleep(0) doet. Uit de resultaten blijkt dat de impact van elke suspend/resume-cyclus vrij klein is. In een scenario uit de echte wereld zal de relatieve impact afhangen van hoeveel werk het script verricht, hoe diep de stack is en het aantal lokale variabelen (inclusief parameters) op elk niveau van de stack. Let op dat de gemeten overhead ook de Vert.x scheduler-overhead bevat.
Conclusie
Voor reactieve applicaties die op oudere versies van Java moeten draaien, biedt het continuation-gebaseerde mechanisme van Jactl voor het afhandelen van blokkerende operaties een handige en efficiënte manier om maatwerk via scripting aan te bieden, zonder dat men zich zorgen hoeft te maken over scripts die event-loop threads blokkeren.
Scripts kunnen worden geschreven met inlined blokkerende operaties op een natuurlijke manier; er is geen noodzaak om de code te vervuilen met async/await of te werken met Futures, Promises of andere mechanismen die programmeertalen in het verleden hebben gebruikt om asynchroon gedrag te beheren. Vanuit het perspectief van het script biedt Jactl een programmeermodel dat equivalent is aan wat Virtual Threads in Java 21 bieden voor Java-programma's.
Met moderne versies van Java kan de continuation-aanpak van Jactl worden uitgeschakeld, waarna Jactl gebruik kan maken van Virtual Threads om ondersteuning te bieden voor blokkerende operaties die de carrier thread niet blokkeren.
Postscriptum
Later werd ik me bewust van andere bibliotheken die een soortgelijk mechanisme gebruiken om continuations te implementeren voor willekeurige Java-code (zie bijvoorbeeld Apache Javaflow en de Java Continuations Library, die niet meer wordt onderhouden). Deze vertrouwen op bytecode-instrumentatie om de juiste instructies in de codebase te plaatsen. Ik heb de implementaties hiervan niet in detail bestudeerd om te weten hoe nauw ze aansluiten bij de Jactl-aanpak, maar ze lijken hetzelfde idee te gebruiken: het gooien van uitzonderingen en het opvangen daarvan in elk stack frame om de lokale status vast te leggen.
Groetjes,