8.1. Coöperatieve gelijktijdigheid

Het planningsmodel van asyncio is coöperatief, niet preëmptief. Dit onderscheid is het allerbelangrijkste mentale model waarop de rest van dit onderdeel voortbouwt, dus het is de moeite waard om het vast te leggen voordat er code verschijnt.

8.1.1. Preëmptief versus coöperatief

Een preëmptieve planner – het soort dat een desktopbesturingssysteem gebruikt om veel programma’s tegelijk draaiende te houden – kan op elk moment het stuk code dat momenteel wordt uitgevoerd pauzeren en overschakelen naar een ander. De draaiende code hoeft niets bijzonders te doen; de planner onderbreekt hem. Dat maakt preëmptieve planning erg flexibel (geen enkel stuk code kan de andere uithongeren door traag te zijn), maar het betekent ook dat elke gedeelde variabele zorgvuldig beschermd moet worden, omdat de overschakeling overal kan plaatsvinden – zelfs halverwege het schrijven van een waarde, of midden in het lezen van een lijst.

Een coöperatieve planner kan alleen wisselen tussen stukken code op punten waar het momenteel draaiende stuk expliciet de controle teruggeeft. Bij asyncio zijn die punten elke await en elke aanroep van een coroutine die intern teruggeeft (meestal asyncio.sleep()). Tussen twee awaits heeft de draaiende coroutine de CPU helemaal voor zichzelf.

Daar volgen twee gevolgen uit:

  • Een coroutine die nooit een await uitvoert, wordt nooit gepauzeerd. Als een coroutine in een strakke lus zit zonder await erin, monopoliseert hij de planner en boekt niets anders vooruitgang. De oplossing is om await asyncio.sleep_ms(0) (of een andere wachtende aanroep) uit te voeren op een zinvol punt in de lus.

  • Gedeelde toestand is veilig tussen awaits. Twee coroutines kunnen elkaar niet doorkruisen halverwege een operatie die geen await bevat. Het soort corruptie dat ontstaat wanneer preëmptie midden in een meerstapsupdate plaatsvindt – het ene stuk code dat een waarde leest terwijl een ander hem half aan het wijzigen is – kan hier eenvoudigweg niet voorkomen. Coördinatie tussen coroutines is nog steeds nodig wanneer meerdere ervan een resource moeten delen over awaits heen, maar het probleem van doorkruising midden in een regel is hier niet van toepassing.

8.1.2. De drie lagen

Elk asyncio-script is opgebouwd uit dezelfde drie lagen. De volgende twee pagina’s behandelen ze in detail; dit zijn de labels om in gedachten te houden tijdens het lezen.

  • Coroutines – functies gedeclareerd met async def, elk een op zichzelf staande eenheid werk die awaitt waar dat gepast is. Het Python-overzicht introduceerde de sleutelwoorden async/await; in asyncio is dat hoe een coroutine teruggeeft aan de planner.

  • Tasks – een wrapper die asyncio.create_task() om een coroutine plaatst om hem gelijktijdig met de huidige te plannen. De applicatie maakt doorgaans een handvol tasks voor de langlopende taken (de snapshot-lus, de netwerkclient, de UART-lezer, …).

  • De event loop – de motor eronder die bijhoudt welke coroutines wachten en welke klaar zijn om te draaien, en die bij elke await tussen tasks wisselt. De applicatie schrijft de lus niet; hij geeft een coroutine op het hoogste niveau door aan asyncio.run() en de lus stuurt vanaf daar alles aan.

Wanneer de applicatie op die manier wordt beschreven – als een kleine verzameling coroutines samengesteld door een event loop – wordt gelijktijdigheid een eigenschap van de vorm van het programma, en niet iets dat de applicatie stap voor stap moet beheren.