Skip to content

Commit 897fa23

Browse files
sergey-miryanovStanFromIrelandgvanrossumkumaraditya303
authored
GH-100108: Add async generators best practices section (#141885)
Co-authored-by: Stan Ulbrych <89152624+StanFromIreland@users.noreply.github.com> Co-authored-by: Guido van Rossum <gvanrossum@gmail.com> Co-authored-by: Kumar Aditya <kumaraditya@python.org>
1 parent 879c85f commit 897fa23

File tree

1 file changed

+222
-0
lines changed

1 file changed

+222
-0
lines changed

Doc/library/asyncio-dev.rst

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,3 +248,225 @@ Output in debug mode::
248248
File "../t.py", line 4, in bug
249249
raise Exception("not consumed")
250250
Exception: not consumed
251+
252+
253+
Asynchronous generators best practices
254+
======================================
255+
256+
Writing correct and efficient asyncio code requires awareness of certain pitfalls.
257+
This section outlines essential best practices that can save you hours of debugging.
258+
259+
260+
Close asynchronous generators explicitly
261+
----------------------------------------
262+
263+
It is recommended to manually close the
264+
:term:`asynchronous generator <asynchronous generator iterator>`. If a generator
265+
exits early - for example, due to an exception raised in the body of
266+
an ``async for`` loop - its asynchronous cleanup code may run in an
267+
unexpected context. This can occur after the tasks it depends on have completed,
268+
or during the event loop shutdown when the async-generator's garbage collection
269+
hook is called.
270+
271+
To avoid this, explicitly close the generator by calling its
272+
:meth:`~agen.aclose` method, or use the :func:`contextlib.aclosing`
273+
context manager::
274+
275+
import asyncio
276+
import contextlib
277+
278+
async def gen():
279+
yield 1
280+
yield 2
281+
282+
async def func():
283+
async with contextlib.aclosing(gen()) as g:
284+
async for x in g:
285+
break # Don't iterate until the end
286+
287+
asyncio.run(func())
288+
289+
As noted above, the cleanup code for these asynchronous generators is deferred.
290+
The following example demonstrates that the finalization of an asynchronous
291+
generator can occur in an unexpected order::
292+
293+
import asyncio
294+
work_done = False
295+
296+
async def cursor():
297+
try:
298+
yield 1
299+
finally:
300+
assert work_done
301+
302+
async def rows():
303+
global work_done
304+
try:
305+
yield 2
306+
finally:
307+
await asyncio.sleep(0.1) # immitate some async work
308+
work_done = True
309+
310+
311+
async def main():
312+
async for c in cursor():
313+
async for r in rows():
314+
break
315+
break
316+
317+
asyncio.run(main())
318+
319+
For this example, we get the following output::
320+
321+
unhandled exception during asyncio.run() shutdown
322+
task: <Task finished name='Task-3' coro=<<async_generator_athrow without __name__>()> exception=AssertionError()>
323+
Traceback (most recent call last):
324+
File "example.py", line 6, in cursor
325+
yield 1
326+
asyncio.exceptions.CancelledError
327+
328+
During handling of the above exception, another exception occurred:
329+
330+
Traceback (most recent call last):
331+
File "example.py", line 8, in cursor
332+
assert work_done
333+
^^^^^^^^^
334+
AssertionError
335+
336+
The ``cursor()`` asynchronous generator was finalized before the ``rows``
337+
generator - an unexpected behavior.
338+
339+
The example can be fixed by explicitly closing the
340+
``cursor`` and ``rows`` async-generators::
341+
342+
async def main():
343+
async with contextlib.aclosing(cursor()) as cursor_gen:
344+
async for c in cursor_gen:
345+
async with contextlib.aclosing(rows()) as rows_gen:
346+
async for r in rows_gen:
347+
break
348+
break
349+
350+
351+
Create asynchronous generators only when the event loop is running
352+
------------------------------------------------------------------
353+
354+
It is recommended to create
355+
:term:`asynchronous generators <asynchronous generator iterator>` only after
356+
the event loop has been created.
357+
358+
To ensure that asynchronous generators close reliably, the event loop uses the
359+
:func:`sys.set_asyncgen_hooks` function to register callback functions. These
360+
callbacks update the list of running asynchronous generators to keep it in a
361+
consistent state.
362+
363+
When the :meth:`loop.shutdown_asyncgens() <asyncio.loop.shutdown_asyncgens>`
364+
function is called, the running generators are stopped gracefully and the
365+
list is cleared.
366+
367+
The asynchronous generator invokes the corresponding system hook during its
368+
first iteration. At the same time, the generator records that the hook has
369+
been called and does not call it again.
370+
371+
Therefore, if iteration begins before the event loop is created,
372+
the event loop will not be able to add the generator to its list of active
373+
generators because the hooks are set after the generator attempts to call them.
374+
Consequently, the event loop will not be able to terminate the generator
375+
if necessary.
376+
377+
Consider the following example::
378+
379+
import asyncio
380+
381+
async def agenfn():
382+
try:
383+
yield 10
384+
finally:
385+
await asyncio.sleep(0)
386+
387+
388+
with asyncio.Runner() as runner:
389+
agen = agenfn()
390+
print(runner.run(anext(agen)))
391+
del agen
392+
393+
Output::
394+
395+
10
396+
Exception ignored while closing generator <async_generator object agenfn at 0x000002F71CD10D70>:
397+
Traceback (most recent call last):
398+
File "example.py", line 13, in <module>
399+
del agen
400+
^^^^
401+
RuntimeError: async generator ignored GeneratorExit
402+
403+
This example can be fixed as follows::
404+
405+
import asyncio
406+
407+
async def agenfn():
408+
try:
409+
yield 10
410+
finally:
411+
await asyncio.sleep(0)
412+
413+
async def main():
414+
agen = agenfn()
415+
print(await anext(agen))
416+
del agen
417+
418+
asyncio.run(main())
419+
420+
421+
Avoid concurrent iteration and closure of the same generator
422+
------------------------------------------------------------
423+
424+
Async generators may be reentered while another
425+
:meth:`~agen.__anext__` / :meth:`~agen.athrow` / :meth:`~agen.aclose` call is in
426+
progress. This may lead to an inconsistent state of the async generator and can
427+
cause errors.
428+
429+
Let's consider the following example::
430+
431+
import asyncio
432+
433+
async def consumer():
434+
for idx in range(100):
435+
await asyncio.sleep(0)
436+
message = yield idx
437+
print('received', message)
438+
439+
async def amain():
440+
agenerator = consumer()
441+
await agenerator.asend(None)
442+
443+
fa = asyncio.create_task(agenerator.asend('A'))
444+
fb = asyncio.create_task(agenerator.asend('B'))
445+
await fa
446+
await fb
447+
448+
asyncio.run(amain())
449+
450+
Output::
451+
452+
received A
453+
Traceback (most recent call last):
454+
File "test.py", line 38, in <module>
455+
asyncio.run(amain())
456+
~~~~~~~~~~~^^^^^^^^^
457+
File "Lib/asyncio/runners.py", line 204, in run
458+
return runner.run(main)
459+
~~~~~~~~~~^^^^^^
460+
File "Lib/asyncio/runners.py", line 127, in run
461+
return self._loop.run_until_complete(task)
462+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^
463+
File "Lib/asyncio/base_events.py", line 719, in run_until_complete
464+
return future.result()
465+
~~~~~~~~~~~~~^^
466+
File "test.py", line 36, in amain
467+
await fb
468+
RuntimeError: anext(): asynchronous generator is already running
469+
470+
471+
Therefore, it is recommended to avoid using asynchronous generators in parallel
472+
tasks or across multiple event loops.

0 commit comments

Comments
 (0)