En konceptuell översikt av asyncio
¶
Den här artikeln HOWTO syftar till att hjälpa dig att bygga en stabil mental modell av hur asyncio
i grunden fungerar, och hjälper dig att förstå hur och varför bakom de rekommenderade mönstren.
Du kanske är nyfiken på några viktiga asyncio
-koncept. Du kommer att kunna svara på dessa frågor i slutet av den här artikeln:
Vad händer bakom kulisserna när ett objekt är väntat?
Hur skiljer
asyncio
mellan en uppgift som inte behöver CPU-tid (t.ex. en nätverksförfrågan eller filläsning) och en uppgift som behöver CPU-tid (t.ex. beräkning av n-faktoriell)?Hur man skriver en asynkron variant av en operation, t.ex. en asynkron sömn eller databasförfrågan.
Se även
Guiden som inspirerade till den här HOWTO-artikeln, av Alexander Nordin.
Denna djupgående YouTube tutorial-serie om
asyncio
skapad av Python core team-medlem, Łukasz Langa.500 rader eller mindre: A Web Crawler With asyncio Coroutines av A. Jesse Jiryu Davis och Guido van Rossum.
En konceptuell översikt del 1: den höga nivån¶
I del 1 går vi igenom de viktigaste byggstenarna på hög nivå i asyncio
: händelseslingan, coroutine-funktioner, coroutine-objekt, tasks och await
.
Händelseslinga¶
Allt i asyncio
sker i förhållande till händelseslingan. Den är stjärnan i showen. Den är som en orkesterdirigent. Den är bakom kulisserna och hanterar resurser. Viss makt ges uttryckligen till den, men mycket av dess förmåga att få saker gjorda kommer från respekt och samarbete från dess arbetsbin.
I mer tekniska termer innehåller händelseslingan en samling jobb som ska köras. Vissa jobb läggs till direkt av dig och andra indirekt av asyncio
. Händelseslingan tar ett jobb från sin backlog av arbete och anropar det (eller ”ger det kontroll”), liknande att anropa en funktion, och sedan körs det jobbet. När det pausas eller slutförs återgår kontrollen till händelseslingan. Händelseslingan väljer sedan ett annat jobb från sin pool och anropar det. Du kan genomgående tänka på samlingen av jobb som en kö: jobb läggs till och bearbetas sedan ett i taget, i allmänhet (men inte alltid) i ordning. Denna process upprepas på obestämd tid och händelseslingan fortsätter i all oändlighet. Om det inte finns fler jobb som väntar på att utföras är händelseslingan tillräckligt smart för att vila och undvika att slösa CPU-cykler i onödan, och kommer tillbaka när det finns mer arbete att göra.
Ett effektivt utförande bygger på att jobben delar med sig väl och samarbetar; ett girigt jobb kan ta kontrollen och låta de andra jobben svälta, vilket gör den övergripande händelseslingan ganska värdelös.
import asyncio
# This creates an event loop and indefinitely cycles through
# its collection of jobs.
event_loop = asyncio.new_event_loop()
event_loop.run_forever()
Asynkrona funktioner och coroutines¶
Detta är en grundläggande, tråkig Python-funktion:
def hello_printer():
print(
"Hi, I am a lowly, simple printer, though I have all I "
"need in life -- \nfresh paper and my dearly beloved octopus "
"partner in crime."
)
När du anropar en vanlig funktion anropas dess logik eller kropp:
>>> hello_printer()
Hej, jag är en liten, enkel skrivare, även om jag har allt jag behöver i livet -
färskt papper och min kära älskade bläckfisk som medbrottsling.
async def, i motsats till bara en vanlig def
, gör detta till en asynkron funktion (eller ”coroutine-funktion”). När den anropas skapas och returneras ett coroutine-objekt.
async def loudmouth_penguin(magic_number: int):
print(
"I am a super special talking penguin. Far cooler than that printer. "
f"By the way, my lucky number is: {magic_number}."
)
När async-funktionen loudmouth_penguin
anropas utförs inte utskriftssatsen, utan i stället skapas ett coroutine-objekt:
>>> loudmouth_penguin(magic_number=3)
<coroutine object loudmouth_penguin at 0x104ed2740>
Termerna ”coroutine-funktion” och ”coroutine-objekt” sammanfattas ofta som coroutine. Det kan vara förvirrande! I den här artikeln hänvisar coroutine specifikt till ett coroutine-objekt, eller mer exakt, en instans av types.CoroutineType
(native coroutine). Observera att coroutines också kan existera som instanser av collections.abc.Coroutine
– en distinktion som är viktig för typkontroll.
En coroutine representerar funktionens kropp eller logik. En coroutine måste startas explicit; det räcker inte med att skapa en coroutine för att starta den. Coroutinen kan pausas och återupptas vid olika punkter inom funktionens kropp. Denna förmåga att pausa och återuppta är det som möjliggör asynkront beteende!
Coroutines och coroutine-funktioner byggdes genom att utnyttja funktionaliteten i generators och generator functions. Kom ihåg att en generatorfunktion är en funktion som yield
s, som den här:
def get_random_number():
# This would be a bad random number generator!
print("Hi")
yield 1
print("Hello")
yield 7
print("Howdy")
yield 4
...
I likhet med en coroutine-funktion körs inte en generatorfunktion när den anropas. Istället skapas ett generatorobjekt:
>>> get_random_number()
<generator object get_random_number at 0x1048671c0>
Du kan gå vidare till nästa yield
i en generator genom att använda den inbyggda funktionen next()
. Med andra ord, generatorn körs och pausas sedan. Till exempel:
>>> generator = get_random_number()
>>> next(generator)
Hej
1
>>> next(generator)
Hallå
7
Tasks¶
Grovt sett är tasks coroutines (inte coroutine-funktioner) knutna till en händelseslinga. En task upprätthåller också en lista över callback-funktioner vars betydelse kommer att framgå när vi diskuterar await
. Det rekommenderade sättet att skapa uppgifter är via asyncio.create_task()
.
När du skapar en uppgift schemaläggs den automatiskt för utförande (genom att lägga till ett callback för att köra den i händelseslingans att göra-lista, dvs. en samling jobb).
Eftersom det bara finns en händelseslinga (i varje tråd) tar asyncio
hand om att associera uppgiften med händelseslingan åt dig. Därför finns det inget behov av att specificera händelseslingan.
coroutine = loudmouth_penguin(magic_number=5)
# This creates a Task object and schedules its execution via the event loop.
task = asyncio.create_task(coroutine)
Tidigare skapade vi händelseslingan manuellt och ställde in den på att köras för evigt. I praktiken är det rekommenderat att använda (och vanligt att se) asyncio.run()
, som tar hand om hanteringen av händelseslingan och ser till att den medföljande coroutinen avslutas innan den går vidare. Till exempel följer många asynkrona program den här inställningen:
import asyncio
async def main():
# Perform all sorts of wacky, wild asynchronous things...
...
if __name__ == "__main__":
asyncio.run(main())
# The program will not reach the following print statement until the
# coroutine main() finishes.
print("coroutine main() is done!")
Det är viktigt att vara medveten om att själva uppgiften inte läggs till i händelseslingan, utan endast en återuppringning till uppgiften. Detta har betydelse om det uppgiftsobjekt du skapade samlas in innan det anropas av händelseslingan. Tänk till exempel på det här programmet:
1async def hello():
2 print("hello!")
3
4async def main():
5 asyncio.create_task(hello())
6 # Other asynchronous instructions which run for a while
7 # and cede control to the event loop...
8 ...
9
10asyncio.run(main())
Eftersom det inte finns någon referens till det uppgiftsobjekt som skapades på rad 5, kan det möjligen rensas bort innan händelseslingan anropar det. Senare instruktioner i korutinen main()
återlämnar kontrollen till händelseslingan så att den kan anropa andra jobb. När händelseslingan så småningom försöker köra uppgiften kan det misslyckas och upptäcka att uppgiftsobjektet inte finns! Detta kan också hända även om en korutin behåller en referens till en uppgift men slutförs innan uppgiften är klar. När korutinen avslutas går lokala variabler ut ur scope och kan bli föremål för skräpinsamling. I praktiken arbetar asyncio
och Pythons skräpinsamlare hårt för att se till att sådant inte händer. Men det är ingen anledning att vara vårdslös!
await¶
await
är ett nyckelord i Python som vanligtvis används på två olika sätt:
await task
await coroutine
På ett avgörande sätt beror beteendet hos await
på vilken typ av objekt som väntas.
Att invänta en uppgift innebär att kontrollen överförs från den aktuella uppgiften eller coroutinen till händelseslingan. I samband med att kontrollen överlåts händer några viktiga saker. Vi kommer att använda följande kodexempel för att illustrera:
async def plant_a_tree():
dig_the_hole_task = asyncio.create_task(dig_the_hole())
await dig_the_hole_task
# Other instructions associated with planting a tree.
...
I det här exemplet föreställer vi oss att händelseslingan har överfört kontrollen till starten av coroutinen plant_a_tree()
. Som vi såg ovan skapar coroutinen en uppgift och väntar sedan på den. Instruktionen await dig_the_hole_task
lägger till en återuppringning (som kommer att återuppta plant_a_tree()
) till objektet dig_the_hole_task
lista över återuppringningar. Och sedan lämnar instruktionen över kontrollen till händelseslingan. En tid senare kommer händelseslingan att överföra kontrollen till dig_the_hole_task
och uppgiften kommer att avsluta vad den behöver göra. När uppgiften är klar kommer den att lägga till sina olika återuppringningar till händelseslingan, i det här fallet ett anrop för att återuppta plant_a_tree()
.
Generellt sett gäller att när den väntade uppgiften är klar (gräva_hålet_uppgiften
) läggs den ursprungliga uppgiften eller coroutinen (plantera_ett_träd()
) tillbaka till händelseslingans att-göra-lista för att återupptas.
Detta är en grundläggande men ändå tillförlitlig mental modell. I praktiken är kontrollöverlämningarna något mer komplexa, men inte mycket. I del 2 går vi igenom de detaljer som gör detta möjligt.
Till skillnad från uppgifter ger inte väntan på en coroutine tillbaka kontrollen till händelseslingan! Att först paketera in en coroutine i en uppgift och sedan vänta på den skulle innebära att kontrollen övergår. Beteendet för await coroutine
är i praktiken detsamma som att anropa en vanlig, synkron Python-funktion. Tänk på detta program:
import asyncio
async def coro_a():
print("I am coro_a(). Hi!")
async def coro_b():
print("I am coro_b(). I sure hope no one hogs the event loop...")
async def main():
task_b = asyncio.create_task(coro_b())
num_repeats = 3
for _ in range(num_repeats):
await coro_a()
await task_b
asyncio.run(main())
Den första satsen i coroutinen main()
skapar task_b
och schemalägger den för utförande via händelseslingan. Sedan inväntas coro_a()
upprepade gånger. Kontrollen överförs aldrig till händelseslingan, vilket är anledningen till att vi ser utdata från alla tre coro_a()
-uppmaningarna före coro_b()
:s utdata:
I am coro_a(). Hi!
I am coro_a(). Hi!
I am coro_a(). Hi!
I am coro_b(). I sure hope no one hogs the event loop...
Om vi ändrar await coro_a()
till await asyncio.create_task(coro_a())
ändras beteendet. Coroutinen main()
överlämnar kontrollen till händelseslingan med detta uttalande. Händelseslingan fortsätter sedan genom sitt eftersläpande arbete, anropar task_b
och sedan den uppgift som omsluter coro_a()
innan den återupptar coroutinen main()
.
I am coro_b(). I sure hope no one hogs the event loop...
I am coro_a(). Hi!
I am coro_a(). Hi!
I am coro_a(). Hi!
Detta beteende hos await coroutine
kan göra många människor förvirrade! Det exemplet belyser hur användning av endast await coroutine
oavsiktligt kan ta kontroll från andra uppgifter och effektivt stoppa händelseslingan. asyncio.run()
kan hjälpa dig att upptäcka sådana händelser via flaggan debug=True
som följaktligen aktiverar debug mode. Bland annat loggas alla coroutines som monopoliserar exekveringen i 100 ms eller längre.
Konstruktionen innebär att viss konceptuell klarhet kring användningen av await
avsiktligt byts ut mot förbättrad prestanda. Varje gång en uppgift väntar måste kontrollen skickas hela vägen upp i anropsstacken till händelseslingan. Det kanske låter obetydligt, men i ett stort program med många await
och en djup anropsstack kan detta leda till en meningsfull prestandaförlust.
En konceptuell översikt del 2: skruvar och muttrar¶
Del 2 går in i detalj på de mekanismer som asyncio
använder för att hantera kontrollflödet. Det är här det magiska händer. Du kommer från detta avsnitt att veta vad await
gör bakom kulisserna och hur du skapar dina egna asynkrona operatörer.
Det inre arbetet med coroutines¶
asyncio
utnyttjar fyra komponenter för att skicka runt kontrollen.
coroutine.send(arg)
är den metod som används för att starta eller återuppta en coroutine. Om coroutinen pausades och nu återupptas, kommer argumentet arg
att skickas in som returvärdet för yield
-satsen som ursprungligen pausade den. Om coroutinen används för första gången (i motsats till att återupptas) måste arg
vara None
.
1class Rock:
2 def __await__(self):
3 value_sent_in = yield 7
4 print(f"Rock.__await__ resuming with value: {value_sent_in}.")
5 return value_sent_in
6
7async def main():
8 print("Beginning coroutine main().")
9 rock = Rock()
10 print("Awaiting rock...")
11 value_from_rock = await rock
12 print(f"Coroutine received value: {value_from_rock} from rock.")
13 return 23
14
15coroutine = main()
16intermediate_result = coroutine.send(None)
17print(f"Coroutine paused and returned intermediate value: {intermediate_result}.")
18
19print(f"Resuming coroutine and sending in value: 42.")
20try:
21 coroutine.send(42)
22except StopIteration as e:
23 returned_value = e.value
24print(f"Coroutine main() finished and provided value: {returned_value}.")
yield, som vanligt, pausar exekveringen och återlämnar kontrollen till den som anropar. I exemplet ovan anropas yield
på rad 3 av ... = await rock
på rad 11. Mer allmänt anropar await
metoden __await__()
för det givna objektet. await
gör också en annan mycket speciell sak: den sprider (eller ”skickar vidare”) alla yield
som den får upp i anropskedjan. I det här fallet är det tillbaka till ... = coroutine.send(None)
på rad 16.
Coroutinen återupptas via anropet coroutine.send(42)
på rad 21. Coroutinen tar vid där den ”gav upp” (eller pausade) på rad 3 och utför de återstående satserna i dess kropp. När en coroutine är klar utlöser den ett StopIteration
-undantag med returvärdet i attributet value
.
Det utdraget ger följande resultat:
Börjar coroutine main().
Väntar på rock...
Coroutine pausade och returnerade mellanvärde: 7.
Återupptar coroutine och skickar in värde: 42.
Rock.__await__ återupptas med värde: 42.
Coroutine mottog värde: 42 från rock.
Coroutine main() avslutades och gav värde: 23.
Det är värt att stanna upp ett ögonblick här och se till att du följde de olika sätten som kontrollflödet och värdena skickades. Många viktiga idéer täcktes och det är värt att se till att din förståelse är fast.
Det enda sättet att ge upp (eller effektivt överlåta kontrollen) från en coroutine är att await
ett objekt som yield
s i dess __await__
metod. Det kanske låter konstigt för dig. Du kanske tänker:
1. What about a
yield
directly within the coroutine function? The coroutine function becomes an async generator function, a different beast entirely.2. What about a yield from within the coroutine function to a (plain) generator? That causes the error:
SyntaxError: yield from not allowed in a coroutine.
This was intentionally designed for the sake of simplicity – mandating only one way of using coroutines. Initiallyyield
was barred as well, but was re-accepted to allow for async generators. Despite that,yield from
andawait
effectively do the same thing.
Futures¶
En future är ett objekt som är avsett att representera en beräknings status och resultat. Termen är en blinkning till idén om något som fortfarande ska komma eller ännu inte har hänt, och objektet är ett sätt att hålla ett öga på detta något.
En framtid har några viktiga attribut. Ett är dess tillstånd som kan vara antingen ”pending”, ”cancelled” eller ”done”. Ett annat är dess resultat, som sätts när tillståndet övergår till done. Till skillnad från en coroutine representerar en future inte den faktiska beräkningen som ska göras, utan istället statusen och resultatet av den beräkningen, ungefär som en statuslampa (röd, gul eller grön) eller indikator.
asyncio.Task
subklassar asyncio.Future
för att få dessa olika möjligheter. I föregående avsnitt sades att uppgifter lagrar en lista över återanrop, vilket inte var helt korrekt. Det är faktiskt klassen Future
som implementerar den här logiken, som Task
ärver.
Futures kan också användas direkt (inte via tasks). Tasks markerar sig själva som färdiga när deras coroutine är klar. Futures är mycket mer mångsidiga och markeras som klara när du säger till. På så sätt är de ett flexibelt gränssnitt där du kan skapa dina egna villkor för väntan och återupptagning.
En hemmagjord asyncio.sleep¶
Vi ska gå igenom ett exempel på hur du kan utnyttja en framtid för att skapa din egen variant av asynkron sömn (async_sleep
) som efterliknar asyncio.sleep()
.
Detta utdrag registrerar några uppgifter med händelseslingan och väntar sedan på en coroutine som är insvept i en uppgift: async_sleep(3)
. Vi vill att den uppgiften ska avslutas först efter att tre sekunder har gått, men utan att hindra andra uppgifter från att köras.
async def other_work():
print("I like work. Work work.")
async def main():
# Add a few other tasks to the event loop, so there's something
# to do while asynchronously sleeping.
work_tasks = [
asyncio.create_task(other_work()),
asyncio.create_task(other_work()),
asyncio.create_task(other_work())
]
print(
"Beginning asynchronous sleep at time: "
f"{datetime.datetime.now().strftime("%H:%M:%S")}."
)
await asyncio.create_task(async_sleep(3))
print(
"Done asynchronous sleep at time: "
f"{datetime.datetime.now().strftime("%H:%M:%S")}."
)
# asyncio.gather effectively awaits each task in the collection.
await asyncio.gather(*work_tasks)
Nedan använder vi en future för att möjliggöra anpassad kontroll över när den uppgiften ska markeras som klar. Om future.set_result()
(metoden som ansvarar för att markera den framtiden som klar) aldrig anropas, kommer den här uppgiften aldrig att avslutas. Vi har också tagit hjälp av en annan uppgift, som vi kommer att se om en stund, som kommer att övervaka hur mycket tid som har gått och följaktligen anropa future.set_result()
.
async def async_sleep(seconds: float):
future = asyncio.Future()
time_to_wake = time.time() + seconds
# Add the watcher-task to the event loop.
watcher_task = asyncio.create_task(_sleep_watcher(future, time_to_wake))
# Block until the future is marked as done.
await future
Nedan kommer vi att använda ett ganska naket objekt, YieldToEventLoop()
, för att yield
från __await__
för att överlåta kontrollen till händelseslingan. Detta är effektivt detsamma som att ringa asyncio.sleep(0)
, men detta tillvägagångssätt ger mer tydlighet, för att inte tala om att det är något fusk att använda asyncio.sleep
när man visar hur man implementerar det!
Som vanligt cyklar händelseslingan genom sina uppgifter, ger dem kontroll och får tillbaka kontrollen när de pausar eller avslutas. Uppgiften watcher_task
, som kör coroutinen _sleep_watcher(...)
, kommer att anropas en gång per hel cykel av händelseslingan. Vid varje återupptagande kommer den att kontrollera tiden och om inte tillräckligt mycket tid har gått, kommer den att pausa igen och lämna tillbaka kontrollen till händelseslingan. Så småningom kommer tillräckligt med tid att ha gått och _sleep_watcher(...)
kommer att markera framtiden som klar och sedan själv också avsluta genom att bryta sig ur den oändliga while
-slingan. Med tanke på att denna hjälpuppgift bara anropas en gång per cykel av händelseslingan, skulle du ha rätt att notera att denna asynkrona sömn kommer att sova minst tre sekunder, snarare än exakt tre sekunder. Observera att detta också gäller för asyncio.sleep
.
class YieldToEventLoop:
def __await__(self):
yield
async def _sleep_watcher(future, time_to_wake):
while True:
if time.time() >= time_to_wake:
# This marks the future as done.
future.set_result(None)
break
else:
await YieldToEventLoop()
Här är programmets fullständiga utdata:
$ python custom-async-sleep.py
Beginning asynchronous sleep at time: 14:52:22.
I like work. Work work.
I like work. Work work.
I like work. Work work.
Done asynchronous sleep at time: 14:52:25.
Du kanske tycker att denna implementering av asynkron sömn var onödigt komplicerad. Och det var den också. Exemplet var avsett att visa mångsidigheten hos futures med ett enkelt exempel som kan efterliknas för mer komplexa behov. Som referens kan du implementera det utan futures, så här:
async def simpler_async_sleep(seconds):
time_to_wake = time.time() + seconds
while True:
if time.time() >= time_to_wake:
return
else:
await YieldToEventLoop()
Men det är allt för nu. Förhoppningsvis är du redo att med större självförtroende dyka in i asynkron programmering eller kolla in avancerade ämnen i resten av dokumentationen
.