De overstap van JavaScript naar Python voelt als verhuizen naar een stad met dezelfde verkeersborden, maar andere verkeersregels. De syntaxis ziet er vriendelijk en vertrouwd uit. Je ziet async en await gewoon in de grammatica staan, dus je gaat ervan uit dat het mentale model naadloos wordt overgenomen. Dat is niet zo. Eén gewoon patroon uit JavaScript ruïneert stilletjes je Python-prestaties zonder te crashen, zonder een foutmelding te loggen en zonder op te vallen tijdens een snelle code review.
Hoe JavaScript je leert om te starten en te vergeten
In JavaScript is een aanroep van een async-functie 'eager'. Zodra je de functie aanroept, maakt de engine een Promise aan en begint het werk direct. De event loop is al volop aan de gang. Daarom schrijven JavaScript-ontwikkelaars van nature code zoals deze:
const userPromise = fetchUser(id);
const ordersPromise = fetchOrders(id);
const user = await userPromise;
const orders = await ordersPromise;
Beide netwerkverzoeken zijn al in uitvoering voordat de eerste await wordt bereikt. De eerste await pauzeert de huidige functie totdat fetchUser is opgelost, maar fetchOrders is al op de achtergrond bezig sinds de vorige regel. Tegen de tijd dat je de orders-variabele nodig hebt, kan het tweede verzoek al voltooid zijn. Dit patroon voelt zo natuurlijk in JavaScript dat veel ontwikkelaars het niet eens als een concurrency-truc zien. Het is gewoon hoe async werkt.
De Python-verrassing: een koude coroutine
Python hanteert een ander contract. Wanneer je in Python een async def-functie aanroept, start je geen werk. Je ontvangt een coroutine-object. Zie het als een recept op papier. De ingrediënten staan erop, de stappen zijn duidelijk, maar er staat nog niets in de oven. Zolang er niets expliciet die coroutine door de event loop stuurt, blijft deze inert.
Hier zit de valkuil. Een JavaScript-engineer die een gebruiker en diens bestellingen nodig heeft, zou dit in Python kunnen schrijven:
user_coro = fetch_user(id)
orders_coro = fetch_orders(id)
user = await user_coro
orders = await orders_coro
Het ziet er concurrency-achtig uit. Het voelt als concurrency. Maar het is volledig sequentieel.
De eerste regel wijst een inactieve coroutine toe aan user_coro. De tweede regel wijst een andere inactieve coroutine toe aan orders_coro. Wanneer de uitvoering await user_coro bereikt, start Python eindelijk de eerste taak en voert deze volledig uit. Pas nadat fetch_user klaar is, bereikt de interpreter await orders_coro en start de tweede taak. Je totale uitvoeringstijd is de som van beide I/O-operaties, niet de langste ervan. Je hebt ze niet parallel uitgevoerd. Je hebt ze na elkaar uitgevoerd, met extra stappen.
Waarom deze bug onzichtbaar is
Dit is het soort prestatie-achteruitgang dat maandenlang onopgemerkt blijft. De code is geldige Python. Het haalt de type checkers. Het geeft de juiste resultaten. Het draait alleen op halve snelheid, of zelfs nog trager. Omdat er geen stack trace en geen waarschuwing is, kijken engineeringteams vaak eerst overal anders naar. Ze voegen Redis-caches toe, upgraden database-tiers of wisselen van hostingregio. De echte boosdoener is een subtiele mismatch in de verwachtingen over wat await eigenlijk doet.
Drie manieren om Python daadwerkelijk gelijktijdig te laten draaien
Om dit op te lossen, moet je de event loop van Python vertellen om het werk onmiddellijk in te plannen. Je hebt iets actievers nodig dan een ruwe coroutine. Je hebt een Task nodig.
1. asyncio.create_task
De meest directe vertaling van het JavaScript-patroon is om je coroutine in een Task te wikkelen. Een Task wordt op de event loop gepland zodra je hem aanmaakt. Het is het dichtstbijzijnde Python-equivalent van een JavaScript Promise in beweging.
user_task = asyncio.create_task(fetch_user(id))
orders_task = asyncio.create_task(fetch_orders(id))
user = await user_task
orders = await_orders_task
Nu zijn zowel fetch_user als fetch_orders al in uitvoering voordat de eerste await wordt bereikt. Wanneer je await user_task bereikt, pauzeer je alleen totdat die specifieke Task is voltooid, maar de andere Task blijft gewoon doorgaan. Als fetch_orders als eerste klaar is, wacht het resultaat simpelweg in orders_task tot je erom vraagt.
Pas wel op. Als je een Task aanmaakt en deze nooit met await aanroept, geeft Python een foutmelding over een vernietigde 'pending task'. Je moet je resultaten nog steeds ophalen.
2. asyncio.gather
Als je meerdere coroutines hebt die allemaal moeten worden afgerond voordat je verdergaat, regelt asyncio.gather de boilerplate voor je. Het plant intern elke coroutine in als een Task en wacht ze gezamenlijk af.
user, orders = await asyncio.gather(fetch_user(id), fetch_orders(id))
Dit is beknopt en leesbaar. Het is ideaal wanneer de operaties onafhankelijk zijn en je een enkele regel wilt die uitdrukt: "voer dit allemaal uit, en geef me dan elk resultaat." Het behoudt ook de volgorde van de argumenten in de geretourneerde lijst of tuple, zelfs als de onderliggende taken in een andere volgorde worden voltooid.
3. asyncio.TaskGroup
Python 3.11 introduceerde TaskGroup, wat gestructureerde concurrency naar de standaardbibliotheek brengt. In plaats van handmatig taken aan te maken, gebruik je een context manager die ervoor zorgt dat elke aangemaakte taak correct wordt afgerond. Als één taak een uitzondering veroorzaakt, worden de andere automatisch geannuleerd.
async with asyncio.TaskGroup() as tg:
user_task = tg.create_task(fetch_user(id))
orders_task = tg.create_task(fetch_orders(id))
user = user_task.result()
orders = orders_task.result()
Dit patroon is uitstekend voor complexe workflows. Het elimineert het risico dat een Task onbeheerd achterblijft, en het groepeert de levenscyclus van gerelateerde operaties onder één logische paraplu. Als je codebase draait op Python 3.11 of nieuwer, is dit vaak de schoonste architectuur voor fan-out concurrency.
Het mentale model: await betekent "voer dit nu uit"
De belangrijkste les is taalkundig. In JavaScript kun je await lezen als "ondertussen". Je start werk op, doet andere dingen en pauzeert pas wanneer je de waarde nodig hebt. In Python betekent await "stuur deze coroutine naar het volgende pauzemoment of naar voltooiing". Als de coroutine nog niet is ingepland, is await degene die dit doet. Daarom kun je niet twee ruwe coroutines starten en ze later met await afwachten. Je hebt de event loop in de tussentijd niets te doen gegeven.
Zie Python coroutines als generatorfuncties. Het aanroepen van de generator zorgt niet voor iteratie. Je moet eroverheen loopen, next() aanroepen of het doorgeven aan een consumer. Async werkt op dezelfde manier. asyncio.create_task is de consumer die zegt: "zet dit nu direct op de event loop". De daaropvolgende await wacht simpelweg op het voltooiingssignaal.
Eén concrete gewoonte die helpt: telkens wanneer je de aanroep van een async functie toewijst aan een variabele zonder await, vraag jezelf dan af of je deze hebt ingepland. Als de rechterkant niet is ingekapseld in create_task, gather of TaskGroup, dan wordt deze niet uitgevoerd. Het is slechts een recept dat op het aanrecht ligt.
Kernpunt
De async runtime van Python is krachtig, maar vereist expliciete intentie. De taal start geen achtergrondwerk simpelweg omdat je een functie aanroept. Als je uit de JavaScript-wereld komt, controleer dan elke plek waar je een coroutine in een variabele opslaat om deze later met await af te wachten. Tenzij je het eerst hebt gepromoveerd naar een Task, heb je sequentiële code geschreven in een async jasje. Start het werk met een Task en wacht vervolgens op de resultaten. Dat is hoe je Python async transformeert van een stille bottleneck naar een echt concurrency-instrument.
