Article #001: asyncio - the flaws

This blog post describes some problems with asyncio that I recently discovered. Knowing about these issues can help you avoid spending days trying to solve non-obvious errors.

Each section is titled with the method whose flaws it addresses.

[1] asyncio.create_task (≥3.7)

If you use asyncio.create_task without storing a reference to the created object, the task may be garbage collected during its execution. This is because asyncio internally stores created tasks as weak references, which does not increase the reference counter.

This behavior is unclear for many and was not initially documented in Python 3.7 or fixed later. The Python 3.10 documentation proposes an official workaround that we should all follow. My role here is to advertise this issue.

However, the fact remains that the asyncio.create_task interface could be more intuitive. Many people view a task as something similar to a thread (although their creation differs, as you do not need to store a reference to a thread object).


Recommendations:

  1. Store references to created tasks using asyncio.create_task.

[2] asyncio.gather (≥3.5)

Firstly, note that the asyncio.gather function returns a special future that does not return True from .cancelled() after being cancelled. This is a known, unresolved bug, which is documented at: Issue #85071. Therefore, relying on this function is not recommended.

Secondly, asyncio.gather has inconsistent error handling. While CancelledError is propagated to inner tasks, an exception in one of the inner tasks does not cause the cancellation of all other tasks. This behavior is unexpected and makes the interface difficult to use. In order to cancel those tasks on your own, you must create them separately. Passing bare coroutines can be risky as you won’t have a reference to cancel those coroutines once they are converted to tasks inside asyncio.gather.

I would say that asyncio.gather should not be used without a proper try-except block.


Recommendations:

  1. <py3.11: do not rely on the .cancelled() method of the future object returned by asyncio.gather.
  2. <py3.11: pass tasks to asyncio.gather instead of coroutine objects.
  3. <py3.11: use asyncio.gather with a try-except block to cancel gathered tasks in case of an error.
  4. ≥py3.11: use asyncio.TaskGroup instead of asyncio.gather.

[3] asyncio.wait_for (≥3.5,<3.12)

asyncio.wait_for swallows asyncio.CancelledError on a very rare race condition, causing unexpected code execution continuation. This behavior is known as Issue #86296 in the CPython repository.

Fortunately, it was fixed very recently in Python 3.12.

Unrelated trivia about asyncio.wait_for is that some people considered its implementation to be too complex. As an alternative, they suggested replacing its internal implementation with asyncio.timeout: Issue #96764.


Recommendations:

  1. <py3.11: use asyncio.wait instead of asyncio.wait_for (as it also supports timeout= parameter, so the same goal can be achieved).
  2. =py3.11: use asyncio.timeout instead of asyncio.wait_for.