Het artikel bespreekt het concept van 'rechtlijnige asynchroniteit' (straight-line asynchrony) via de async/await-syntaxis. De auteur stelt dat er aanzienlijke verschillen bestaan in hoe diverse programmeertalen en runtimes dit implementeren, wat leidt tot verschillende uitvoerresultaten voor hetzelfde stuk code.
Om deze verschillen te systematiseren, introduceert de auteur negen ontwerpsdimensies, onderverdeeld in drie categorieën:
- Start van het leven: Betreft Eagerness (lazy vs. eager uitvoering) en Suspension (garanties over onderbreking).
- Einde van het leven: Bevat Extent (levensduur van taken), Reference Strength (sterke vs. zwakke referenties), Destruction (hoe taken worden opgeruimd) en Propagation (afhandeling van uitzonderingen).
- Annulering: Richt zich op Awareness (reageren op annulering), Direction (richting van communicatie in de taakgrafiek) en Persistence (duur van de annulering).
Het artikel concludeert dat deze ontwerpkeuzes bepalen hoe een taal zich gedraagt (bijv. het verschil tussen Swift en Trio in het voorbeeld). Om dit preciezer te analyseren, is er een formeel semantisch model ontwikkeld dat de uitvoering van asynchrone programma's via een abstracte machine-staat kan traceren.
Een verkenning van de ontwerpruimte van Async/Await
Veel programmeertalen bieden tegenwoordig de trefwoorden async/await aan om gelijktijdigheid (concurrency) uit te drukken. De ontwerpfilosofie is consistent: programma's met gelijktijdigheid eruit laten zien als normale, opeenvolgende code (zie: Python, Rust of Swift). We beschrijven dit paradigma, dat async/await omvat, als rechtlijnige asynchroniteit (straight-line asynchrony), in tegenstelling tot het gebruik van event loops of callbacks.
De taalontwerpen voor rechtlijnige asynchroniteit zijn al meer dan 15 jaar in ontwikkeling. In dit project wilden we begrijpen: hoe sterk verschilt async/await tussen verschillende talen? Het korte antwoord is dat de verschillen veel groter zijn dan we hadden verwacht. We hebben een paper geschreven, "A Design Space Exploration of Async/Await", om dit uit te leggen.
Ken je async/await echt?
Om te laten zien hoe sterk moderne talen kunnen uiteenlopen, volgt hier een klein async-programma in pseudocode. Eén functie schrijft naar een logboek, en een andere functie start die logschrijving als een achtergrondtaak en gaat direct verder.
async fn write_to_log():
print("A")
// simuleer een trage logschrijving
await sleep(2)
print("B")
async fn fire_and_forget():
task = spawn write_to_log()
// return zonder de task te awaiten
async fn main():
await fire_and_forget()
await sleep(1)
print("C")
Wat verwacht je dat dit programma print? Er is eigenlijk geen enkel juist antwoord, omdat je waarschijnlijk gelijk hebt voor een specifieke taal. Voor zeven moderne async-runtimes is het gedrag namelijk verschillend. Voor een programma waarvan de enige taak het schrijven van een logregel op de achtergrond is, zijn er vier verschillende uitkomsten. In de paper tonen we zelfs aan dat over de zeven runtimes heen geen twee dezelfde output produceren voor drie variaties van dit eenvoudige programma.
Waarom deze verschillen?
Wie video's van programmeer-influencers kijkt, heeft wellicht de termen "cold" of "hot" async-functieaanroepen gehoord. Het idee is dat "hot starts" een taak teruggeven die onmiddellijk in de runtime wordt uitgevoerd, terwijl "cold starts" een inert object teruggeven dat niets doet totdat er een await op wordt toegepast.
Het verschil tussen hot en cold functies is wat wij een async-ontwerpsdimensie noemen: een ontwerpbeslissing die de observeerbare semantiek van de programma-uitvoering beïnvloedt (in tegenstelling tot zaken die puur over prestaties gaan). We noemen deze specifieke dimensie "Eagerness". In de paper identificeren we negen van deze dimensies in moderne implementaties van rechtlijnige asynchroniteit. Deze negen dimensies zijn onderverdeeld in drie categorieën die ruwweg corresponderen met de levenscyclus van een taak: Start van het leven, Einde van het leven en Annulering.
1. Start van het leven (Start of Life)
- Eagerness (Drang): Hoe een async-functieaanroep wordt geëvalueerd.
- Lazy: Evalueert tot een coroutine zonder verder uit te voeren. (Python, Rust)
- Eager: Evalueert in de huidige thread en wordt als taak gepland bij
await. (C#, JavaScript)
- Suspension (Onderbreking): Garanties over het feit of
await-punten de uitvoering onderbreken.
- Static:
Await-punten garanderen een onderbreking. (JavaScript)
- Dynamic: Geen garanties bij het awaiten van taken. (C#, Swift, Tokio, Smol, Asyncio, Trio)
2. Einde van het leven (End of Life)
- Extent (Omvang): Het standaardtijdsinterval waarin een taak kan bestaan.
- Indefinite: Taken kunnen standaard bestaan tot het einde van de runtime. (JavaScript, C#, Tokio, Smol, Asyncio)
- Dynamic: Taken kunnen standaard bestaan tot het einde van hun spawn-scope. (Swift, Trio)
- Reference Strength (Referentiekracht): (Voor Indefinite Extent) Het type referentie naar een taak dat door de runtime wordt vastgehouden.
- Strong: De runtime houdt een sterke referentie vast. (JavaScript, C#, Tokio)
- Weak: De runtime houdt een zwakke referentie vast. (Asyncio, Smol)
- Destruction (Vernietiging): Hoe een taak wordt opgeruimd aan het einde van zijn omvang.
- Awaited: De taak wordt gewacht tot voltooiing. (JavaScript, Trio)
- Cancelled: De taak wordt geannuleerd en daarna eventueel gewacht. (Swift, Tokio, Smol, Asyncio)
- Terminated: Het programma stopt. (C#)
- Propagation (Propagatie): Wat er gebeurt met uitzonderingen (exceptions) in taken die niet zijn gewacht.
- Destructive: Uitzonderingen worden opnieuw geworpen door afhankelijke taken. (Trio)
- Never: De uitzondering blijft binnen de taak. (JavaScript, C#, Tokio, Smol, Asyncio, Swift)
3. Annulering (Cancellation)
- Awareness (Bewustzijn): Of een taak in staat is te reageren op annulering.
- Unaware: De taak kan niet reageren op annulering. (Rust)
- Aware: De taak kan reageren op annulering. (Asyncio, Trio, Swift)
- Direction (Richting): Hoe annulering wordt gecommuniceerd via de taakgrafiek.
- Top-Down: Beginnend bij de root-taak, gecommuniceerd van afhankelijken naar afhankelijkheden. (Rust)
- Bottom-Up: Beginnend bij de afhankelijkheden van de root en gecommuniceerd naar afhankelijken. (Asyncio, Trio)
- Simultaneous: Tegelijkertijd naar alle transitieve afhankelijkheden. (Swift)
- Persistence (Persistentie): (Voor Aware Cancellation) Hoe lang een annulering van een taak duurt.
- Transient: Een taak kan annulering negeren en normaal doorgaan. (Asyncio)
- Persistent: Een taak kan annulering negeren, maar blijft geannuleerd. (Trio, Swift)
Toepassing op het voorbeeld
Twee van deze assen zijn bijzonder relevant voor ons voorbeeldprogramma. Talen met Dynamic Extent staan niet toe dat taken langer leven dan de functies waarin ze zijn aangemaakt. In tegenstelling tot de andere talen hebben Swift en Python+Trio gekozen voor Dynamic Extent. Dit betekent dat binnen de functie fireandforget, de taak die is gekoppeld aan writetolog niet langer kan bestaan dan de functie fireandforget.
Hoewel Swift en Trio beide Dynamic Extent gebruiken, verschillen ze in hun keuze voor Destruction. Aan het einde van de scope van de functie fireandforget gebruikt Swift Cancelled Destruction en annuleert de taak, terwijl Trio Awaited Destruction gebruikt en netjes wacht tot writetolog is voltooid. De keuzes voor Extent en Destruction verklaren waarom Swift "AC" print en Trio "ABC" print.
Elke ontwerpsdimensie brengt afwegingen met zich mee op het gebied van prestaties, geheugengebruik, ergonomie, semantiek, etc. Er zijn geen juiste of onjuiste antwoorden; elke taal heeft zijn eigen ontwerpfilosofie. Maar met zoveel beslissingen wordt het uitleggen van de output van zelfs kleine programma's behoorlijk complex.
Formele Semantiek
Om onze ontwerpruimte preciezer te maken, hebben we deze vertaald naar een formele semantiek op een kerncalculus van asynchrone programma's. Dit model stelt ons in staat om exact uit te leggen waarom het voorbeeldprogramma divergeert door de uitvoering te traceren.
Dit formele model kan worden gebruikt om verschillende uitvoerresultaten te verklaren via een trace van de abstracte machine-staat. Elke stap in de trace is een small-step reduction, gelabeld met de regels die worden toegepast. De meeste regels werken identiek in elke runtime; de specifieke ontwerpsbeslissingen vormen echter vertakkingen in de route naar het uiteindelijke resultaat.
Legenda voor de formele model-trace:
- a = Asyncio
- c = C#
- j = JavaScript
- k = Tokio
- m = Smol
- r = Trio
- s = Swift
Een verkenning van de ontwerpruimte van Async/Await
Veel programmeertalen bieden tegenwoordig de trefwoorden async/await aan om gelijktijdigheid (concurrency) uit te drukken. De ontwerpfilosofie is consistent: programma's met gelijktijdigheid eruit laten zien als normale, opeenvolgende code (zie: Python, Rust of Swift). We beschrijven dit paradigma, dat async/await omvat, als rechtlijnige asynchroniteit (straight-line asynchrony), in tegenstelling tot het gebruik van event loops of callbacks.
De taalontwerpen voor rechtlijnige asynchroniteit zijn al meer dan 15 jaar in ontwikkeling. In dit project wilden we begrijpen: hoe sterk verschilt async/await tussen verschillende talen? Het korte antwoord is dat de verschillen veel groter zijn dan we hadden verwacht. We hebben een paper geschreven, "A Design Space Exploration of Async/Await", om dit uit te leggen.
Ken je async/await echt?
Om te laten zien hoe sterk moderne talen kunnen uiteenlopen, volgt hier een klein async-programma in pseudocode. Eén functie schrijft naar een logboek, en een andere functie start die logschrijving als een achtergrondtaak en gaat direct verder.
async fn write_to_log():
print("A")
// simuleer een trage logschrijving
await sleep(2)
print("B")
async fn fire_and_forget():
task = spawn write_to_log()
// return zonder de task te awaiten
async fn main():
await fire_and_forget()
await sleep(1)
print("C")
Wat verwacht je dat dit programma print? Er is eigenlijk geen enkel juist antwoord, omdat je waarschijnlijk gelijk hebt voor een specifieke taal. Voor zeven moderne async-runtimes is het gedrag namelijk verschillend. Voor een programma waarvan de enige taak het schrijven van een logregel op de achtergrond is, zijn er vier verschillende uitkomsten. In de paper tonen we zelfs aan dat over de zeven runtimes heen geen twee dezelfde output produceren voor drie variaties van dit eenvoudige programma.
Waarom deze verschillen?
Wie video's van programmeer-influencers kijkt, heeft wellicht de termen "cold" of "hot" async-functieaanroepen gehoord. Het idee is dat "hot starts" een taak teruggeven die onmiddellijk in de runtime wordt uitgevoerd, terwijl "cold starts" een inert object teruggeven dat niets doet totdat er een await op wordt toegepast.
Het verschil tussen hot en cold functies is wat wij een async-ontwerpsdimensie noemen: een ontwerpbeslissing die de observeerbare semantiek van de programma-uitvoering beïnvloedt (in tegenstelling tot zaken die puur over prestaties gaan). We noemen deze specifieke dimensie "Eagerness". In de paper identificeren we negen van deze dimensies in moderne implementaties van rechtlijnige asynchroniteit. Deze negen dimensies zijn onderverdeeld in drie categorieën die ruwweg corresponderen met de levenscyclus van een taak: Start van het leven, Einde van het leven en Annulering.
1. Start van het leven (Start of Life)
- Eagerness (Drang): Hoe een async-functieaanroep wordt geëvalueerd.
- Lazy: Evalueert tot een coroutine zonder verder uit te voeren. (Python, Rust)
- Eager: Evalueert in de huidige thread en wordt als taak gepland bij
await. (C#, JavaScript)
- Suspension (Onderbreking): Garanties over het feit of
await-punten de uitvoering onderbreken.
- Static:
Await-punten garanderen een onderbreking. (JavaScript)
- Dynamic: Geen garanties bij het awaiten van taken. (C#, Swift, Tokio, Smol, Asyncio, Trio)
2. Einde van het leven (End of Life)
- Extent (Omvang): Het standaardtijdsinterval waarin een taak kan bestaan.
- Indefinite: Taken kunnen standaard bestaan tot het einde van de runtime. (JavaScript, C#, Tokio, Smol, Asyncio)
- Dynamic: Taken kunnen standaard bestaan tot het einde van hun spawn-scope. (Swift, Trio)
- Reference Strength (Referentiekracht): (Voor Indefinite Extent) Het type referentie naar een taak dat door de runtime wordt vastgehouden.
- Strong: De runtime houdt een sterke referentie vast. (JavaScript, C#, Tokio)
- Weak: De runtime houdt een zwakke referentie vast. (Asyncio, Smol)
- Destruction (Vernietiging): Hoe een taak wordt opgeruimd aan het einde van zijn omvang.
- Awaited: De taak wordt gewacht tot voltooiing. (JavaScript, Trio)
- Cancelled: De taak wordt geannuleerd en daarna eventueel gewacht. (Swift, Tokio, Smol, Asyncio)
- Terminated: Het programma stopt. (C#)
- Propagation (Propagatie): Wat er gebeurt met uitzonderingen (exceptions) in taken die niet zijn gewacht.
- Destructive: Uitzonderingen worden opnieuw geworpen door afhankelijke taken. (Trio)
- Never: De uitzondering blijft binnen de taak. (JavaScript, C#, Tokio, Smol, Asyncio, Swift)
3. Annulering (Cancellation)
- Awareness (Bewustzijn): Of een taak in staat is te reageren op annulering.
- Unaware: De taak kan niet reageren op annulering. (Rust)
- Aware: De taak kan reageren op annulering. (Asyncio, Trio, Swift)
- Direction (Richting): Hoe annulering wordt gecommuniceerd via de taakgrafiek.
- Top-Down: Beginnend bij de root-taak, gecommuniceerd van afhankelijken naar afhankelijkheden. (Rust)
- Bottom-Up: Beginnend bij de afhankelijkheden van de root en gecommuniceerd naar afhankelijken. (Asyncio, Trio)
- Simultaneous: Tegelijkertijd naar alle transitieve afhankelijkheden. (Swift)
- Persistence (Persistentie): (Voor Aware Cancellation) Hoe lang een annulering van een taak duurt.
- Transient: Een taak kan annulering negeren en normaal doorgaan. (Asyncio)
- Persistent: Een taak kan annulering negeren, maar blijft geannuleerd. (Trio, Swift)
Toepassing op het voorbeeld
Twee van deze assen zijn bijzonder relevant voor ons voorbeeldprogramma. Talen met Dynamic Extent staan niet toe dat taken langer leven dan de functies waarin ze zijn aangemaakt. In tegenstelling tot de andere talen hebben Swift en Python+Trio gekozen voor Dynamic Extent. Dit betekent dat binnen de functie fireandforget, de taak die is gekoppeld aan writetolog niet langer kan bestaan dan de functie fireandforget.
Hoewel Swift en Trio beide Dynamic Extent gebruiken, verschillen ze in hun keuze voor Destruction. Aan het einde van de scope van de functie fireandforget gebruikt Swift Cancelled Destruction en annuleert de taak, terwijl Trio Awaited Destruction gebruikt en netjes wacht tot writetolog is voltooid. De keuzes voor Extent en Destruction verklaren waarom Swift "AC" print en Trio "ABC" print.
Elke ontwerpsdimensie brengt afwegingen met zich mee op het gebied van prestaties, geheugengebruik, ergonomie, semantiek, etc. Er zijn geen juiste of onjuiste antwoorden; elke taal heeft zijn eigen ontwerpfilosofie. Maar met zoveel beslissingen wordt het uitleggen van de output van zelfs kleine programma's behoorlijk complex.
Formele Semantiek
Om onze ontwerpruimte preciezer te maken, hebben we deze vertaald naar een formele semantiek op een kerncalculus van asynchrone programma's. Dit model stelt ons in staat om exact uit te leggen waarom het voorbeeldprogramma divergeert door de uitvoering te traceren.
Dit formele model kan worden gebruikt om verschillende uitvoerresultaten te verklaren via een trace van de abstracte machine-staat. Elke stap in de trace is een small-step reduction, gelabeld met de regels die worden toegepast. De meeste regels werken identiek in elke runtime; de specifieke ontwerpsbeslissingen vormen echter vertakkingen in de route naar het uiteindelijke resultaat.
Legenda voor de formele model-trace:
- a = Asyncio
- c = C#
- j = JavaScript
- k = Tokio
- m = Smol
- r = Trio
- s = Swift