0%

Hoofdstuk 11

Een voorproefje van async

Waarom de meeste WebSocket-servers async zijn

De meeste -servers die je buiten deze cursus tegenkomt, zijn geschreven met async def. Je scorebord niet, met opzet: met threads kon elk eerder hoofdstuk één verbinding tegelijk volgen met het soort dat je al kende. Dit hoofdstuk is een voorproefje van de andere stijl, geen cursus erin. Je leest en draait een beetje asyncio, herschrijft één handler als , en ziet waar het voorproefje ophoudt. asyncio goed leren vraagt een eigen cursus: Async Python, die alleen Python in Practice nodig heeft.

Deze les schrijft geen code. Ze vraagt wat je server uitgeeft terwijl zijn kijkers wachten, en wat de andere stijl daarvoor terugvraagt.

Wat je server uitgeeft

Sinds hoofdstuk 7 draait je scorebord op de synchrone server van de bibliotheek uit hoofdstuk 6, die elke verbinding een eigen thread geeft. De handler draait in die thread van de tot de close. De handler van een kijker brengt zijn leven door in de die je in hoofdstuk 10 afmaakte: wachten op de outbox, sturen wat die bevat, weer wachten.

Een run op een lokale computer, nog niet op de site, telde de threads in een serverproces. Zonder verbonden kijker draaide de synchrone server 2 threads: de hoofdthread en die van serve_forever. Met 20 stille kijkers die vanuit een ander proces verbonden waren, draaide hij er 62. Dat zijn er 60 meer voor 20 kijkers, drie per kijker: de thread waarin je handler draait, een die de bibliotheek gebruikt om de verbinding te lezen, en een voor haar . Dus 2 + 3 x 20 = 62. Een andere computer geeft andere getallen; het patroon, threads die met kijkers meegroeien, is waar het om gaat.

Je synchrone scorebord heeft 20 kijkers die allemaal op de volgende score wachten. Wat doet elk van hun handlerthreads?

Een kijker wacht vooral

Hoeveel van die tijd is werk? Neem een quizavond van twee uur met 40 scores, en stel dat het sturen van één snapshot naar één kijker ongeveer een milliseconde duurt. Dit blok doet de rekensom voor één kijker:

Try it

Het drukt busy 0.04 s of 7200 s: 0.0006 % af. De milliseconde is een ruwe schatting, en tien keer zoveel zou de kijker nog steeds bijna de hele avond stil laten. Wachten is de normale toestand van een kijker, zoals hoofdstuk 7 ontdekte toen het kijkers voor het eerst tussen scores door verbonden hield. Een thread per kijker wordt betaald gedurende alle 7200 seconden, terwijl het werk optelt tot minder dan een tiende seconde.

Waarom doet het ertoe dat een kijker ruim minder dan één procent van een quiz bezig is?

Eén lus, veel wachten

Je hebt de andere vorm al gezien. In Networks with Python bediende hoofdstuk 9 veel clients vanuit één thread met selectors: één aanroep, select, wachtte tot een willekeurige klaar was, en de lus bediende alleen de sockets die klaar waren voordat hij weer wachtte. Elke verbinding hield haar eigen toestand bij in een , omdat geen thread die voor die verbinding vasthield.

Een maakt dat idee algemeen. Hij houdt elke wachtende verbinding geparkeerd, en als een ervan verder kan, omdat er bytes binnenkwamen of er een snapshot klaarstaat om te sturen, draait hij de handler van die verbinding tot die handler weer moet wachten. Daarna gaat hij door naar de volgende die verder kan. Handlers op een lus worden geschreven als coroutines: functies die halverwege kunnen stoppen, de lus iets anders laten draaien, en later worden hervat waar ze stopten. Dat is wat async def definieert, en les 2 laat zien hoe. De standaard event loop van Python woont in de asyncio, en de bibliotheek websockets heeft er een server op gebouwd, websockets.asyncio.

Dezelfde lokale run telde ook de threads van die server: 1 met 20 stille kijkers, en 1 zonder. De afbeelding zet de twee vormen voor twintig kijkers boven elkaar. Tel de threads in elk, en zoek de ene kijker die iets doet.

Met een thread per verbinding hebben twintig kijkers 2 + 3 x 20 = 62 threads nodig die vooral wachten; met één event loop staan dezelfde twintig kijkers geparkeerd en draait de ene thread van de lus de ene kijker die verder kan.

In de bovenste helft heeft elk van twintig kijkers drie threads (die van zijn handler, een lezer en een keepalive), plus de twee van de server, en bijna allemaal wachten ze: 2 + 3 x 20 = 62 threads. In de onderste helft staan dezelfde twintig kijkers geparkeerd, en draait één thread de kijker die verder kan, hier kijker 3, voordat hij terugkeert naar de lus. Dat is in totaal 1 thread.

De prijs

De lus heeft één thread, dus hij draait één handler tegelijk. Dat werkt alleen als elke handler de controle teruggeeft aan de lus wanneer hij moet wachten. Een handler die op de oude manier wacht, met een aanroep die blokkeert zoals time.sleep of de Event.wait van de outbox, houdt de enige thread bezet, en elke andere kijker wacht mee tot die aanroep terugkeert. Les 3 laat dat gebeuren zien, met een telling.

Geen van beide stijlen is dus gewoon beter. Voor de vijf clients die deze cursus tegelijk heeft bediend, kosten threads weinig en kun je over één verbinding tegelijk redeneren. Een service met duizenden kijkers die vooral wachten, is de gebruikelijke reden om naar een lus over te stappen, omdat zoveel threads veel meer zouden kosten dan het werk dat ze doen. Dat is het gangbare , niet iets wat deze cursus heeft gemeten.

Welke situatie is de sterkste reden om het scorebord naar een event loop te verhuizen?

In de volgende les schrijf je je eerste coroutines en draai je ze met asyncio.run, op de server.