Why I avoid Python's asyncio (by Egor)

Why I avoid Python's asyncio (by Egor)

Monday, February 3, 2025

Every so often (once or twice per year), I used to run into a situation that made me think "Oh, here's a perfect usecase for asyncio" and tried to use it. Unfortunately, every time I've tried using it on a non-toy project, my experience was exactly the same: I spent up to half a day trying to fix asyncio errors, then said "screw it" and found another way to solve the problem (for example, by using threading). As a result, at this point, I will never ever voluntarily write code that uses asyncio, though I might have to call someone else's occasionally.

In general I've been reasonably successful at delivering projects that involve writing code, and figuring out how to use things - why is my experience so different here? Upon reflection, I think the reason is the terrible design of asyncio, and this blog post explains my reasoning.

The core idea of asyncio, cooperative multitasking and the event loop, is a great one. Here's how it works: the event loop maintains a queue of tasks/coroutines to be executed. It runs continuously, following this basic pattern:

1. Check for pending tasks/events

2. Pick the next task that's ready to run

3. Executes it until it yields control (hits an await)

4. Moves on to the next ready task

That allows Python to handle multiple operations concurrently without using multiple threads by switching between tasks when they're waiting for I/O or other operations to complete.

So if the event loop idea is so cool, why is asyncio such a pain to use?

First of all, the API design is really sloppy. For example, if you try to create an event loop if one already exists, it will throw an error. Why? Surely, if we only want to allow a single event loop per thread, then it should be a singleton or similar, and create_event_loop() should return an existing instance if there is one.

That is just cosmetic however; my biggest problem with asyncio is that typically only some, quite small, parts of one's code really need parallelization; a good design would allow the interfaces of those parts to be agnostic as to whether there is parallelization going on under the hood, and which kind - they would be just a function. This is exactly how it works with threading parallelization, for example - the caller of the function doesn't need to be aware whether parallelization is happening inside.

And just like any other function, you might happen to use it recursively, for example - or pass to it as one of the arguments another function that also happens to use parallelization under the hood.

With asyncio, the above is impossible, for two reasons. First reason is the atrocious syntax: why do I need to insert the async/await keywords at all levels? And if I don't want to rewrite my whole codebase into asyncio (why should I have to?), the alternative is a lot of functions that are identical apart from these keywords (take a look at Langchain or LlamaIndex code, for example). Surely, given the wonderful introspection capabilities of Python, this could have been implemented in a neater way? Via some sort of decorator for example, that introspects the code and tags as async the calls to a function that is declared as async, recursively?

But there is another problem, namely that asyncio only allows one event loop per thread. Why? There is nothing about the event loop concept that demands that. But this alone makes the design I described above (freely composing functions, including recursion, without caring if they parallelize under the hood) impossible. And yes, I'm aware of the existence of the `nest_asyncio` library, and I couldn't make it work with that either (just importing it and calling nest_asyncio.apply() at the top of my application didn't fix the issue, and there is only so much time I was prepared to spend on figuring it out).

So while an event loop is a grand idea, its realization in Python's asyncio is in my opinion a half-baked, unusable mess to be avoided if you value your time and sanity.

Search