asyncio: A dumpster fire of bad design


I used to love asyncio. For a period of time, nearly all my stuff was written for asyncio. I wrote a Flask-like (mistake IMO) web framework, and a fully async ORM, both built from the ground up for asyncio. I even got in arguments defending it with people.

Then I found curio. And I realised how badly designed asyncio was.

asyncio's API is definitely a user interface, but it's not a good user interface.

Some Explanation

To understand why asyncio's design is bad, we need to explain some things relating to coroutines, coroutine functions, and the generator API.

First, a coroutine is defined by wikipedia as:

computer-program components that generalize subroutines for non-preemptive multitasking, by allowing multiple entry points for suspending and resuming execution at certain locations.

Python's coroutines implement suspending and resuming by the usage of explicit yield points, where the coroutine yields back to the calling function through the usage of the generator API. The generator API works with a few key items, where gen is an instance of a generator:

  • gen.send() - Used to send a value to a generator that is currently yielding.
  • gen.throw() - Used to throw an exception inside a generator that is currently yielding.
  • StopIteration - Used to return a value from a generator.

For now, we're only going to look at gen.send().

The send method on a generator is effectively a way to establish a two-way communication channel between a calling function and a generator. For example, this is a generator function:

def my_func():  
    value = yield 1
    print("Sent a 1, and received a", value)
    print(f"Returning {value} * 2 = {value * 2}")
    return value * 2

The rough flow of such a communication channel is as follows:

  1. The generator is created by calling a generator callable: gen = my_func().
  2. The generator is started by calling gen.send(None). This runs the generator up to the first yield statement, where it stops running. This prints out Starting, and gives us a 1. The generator is now waiting for us to send it a value.
  3. gen.send(2) is called, to give the generator a value of 2. This is stored in the generator's locals as value. The generator prints two lines.
  4. The generator raises StopIteration, with an argument of 4, being 2 * 2.

As you can see, each .send() sent to the generator runs it up to the next yield statement, where the value being yielded is returned from the call. The argument provided to the .send() function is returned from the yield statement. This was just a basic overview; see Jeff Knupp's article for a better explanation.

Another important note: yield from (or await in a 3.5+ coroutine) allow yields to travel down the stack trace, to the first .send() call, and back up to the yielding function. This is very important.

Sin 1) Ignoring the generator API

Coroutines in Python are implemented as generators. asyncio uses these coroutines, so naturally must use the generator API to interact with coroutines.

asyncio throws most of the usefulness of the API out of the window.

asyncio was added to the stdlib in 3.4. PEP 342 -- Coroutines via Enhanced Generators was accepted in 2005. For Python 2.5. That's years and years before 3.4.

With the generator API, async operations involving the event loop (the thing running all of your async codes) can be done with a series of yield operations (this is EXACTLY how curio works!). Consider the following, hypothetical function:

# Some magic number.
_magic_open_connection = 1

def open_connection(ip: str, port: int) -> SomeSocket:  
    Opens a connection to a server asynchronously. 
    return (yield (_magic_open_connection, ip, port))

Then, in your code, you need to open a socket connection:

async def my_function():  
    sock = await open_connection("", 6667)
    ...  # do something with sock

The await statement causes the open_connection to start, and causes the yield statement to be activated, yielding down the stack trace to your event loop function.

This event loop function has a neat little wrapper, that listens to your yields, and acts appropriately.

result = coro.send(to_send)  
if result[0] == _magic_open_connection:  
    to_send = _actually_open_connection_somewhere(*result[1:])

This is a very, very easy way to communicate with the event loop. Curio does this (in what it calls "traps"...). So surely that's how asyncio works, right?

asyncio's solution: passing around the event loop

To communicate with the asyncio event loop, you need to pass it around to EVERY. SINGLE. FUNCTION. that wants to use it, and call a function on the loop. This totally-not-a-global state needs to be passed to every function that wants to use the event loop. If you don't pass it around, you can helpfully get the current event loop with asyncio.get_event_loop. Which is a thread local. So you're gonna break everything if you have to run in a thread (which, you definitely will!) and don't have a reference to the loop.

This is horrible. This makes code 10x uglier, you have to pass loop to every function that wants to interact (unless you know your code will NEVER run in a different thread), pass it in the constructor to every single class that uses the loop, and so on.

This might be fine in other languages, but the generator API exists for this purpose. It is a communication channel to a parent function, running inside the loop. This is even an example in PEP 342: 3. A simple co-routine scheduler or trampoline that lets coroutines call other coroutines by yielding the coroutine they wish to invoke.

Sin 2) Shitty network I/O

asyncio is an asynchronous I/O framework. It is terrible at doing async network I/O.

There are 3 main ways to interact with the network:

  1. Streams
  2. Protocols
  3. Raw loop functions

You could jury-rig something else with futures, but that's not covered here.


The highest level of interaction. Here's some example code:

import asyncio

def tcp_echo_client(message, loop):  
    reader, writer = yield from asyncio.open_connection('', 8888,

    print('Send: %r' % message)

    data = yield from
    print('Received: %r' % data.decode())

    print('Close the socket')

message = 'Hello World!'  
loop = asyncio.get_event_loop()  
loop.run_until_complete(tcp_echo_client(message, loop))  

You have two objects to deal with. This is a lesser gripe, given that sometimes streams could be read or write-only. That said, raising NotImplementedError is probably a better solution.

Additionally, writing is not async. Is all your data sent when you write? Nope!

    n = self._sock.send(data)
except (BlockingIOError, InterruptedError):  

If writing fails, then asyncio silently discards the error, instead of waiting to write; then it tells the loop to handle it some time later, and fucks off back to your code. This could very well mean when you go to read, the server hasn't actually received your data.

The work around, of course, is to call writer.drain(). Why it doesn't do this automatically, who knows?

Also, you can't close a stream. Not properly.

def close(self):  
    """Close the transport.

    Buffered data will be flushed asynchronously.  No more data
    will be received.  After all buffered data is flushed, the
    protocol's connection_lost() method will (eventually) called
    with None as its argument.
    raise NotImplementedError

There is .abort(), but this dumps all your data. Fuck you if you wanted a clean close.


Protocols are class based networking with asyncio. Some key points:

  1. They use callbacks.

  2. These callbacks are sync.

Here's an example, from the docs:

import asyncio

class EchoClientProtocol(asyncio.Protocol):  
    def __init__(self, message, loop):
        self.message = message
        self.loop = loop

    def connection_made(self, transport):
        print('Data sent: {!r}'.format(self.message))

    def data_received(self, data):
        print('Data received: {!r}'.format(data.decode()))

    def connection_lost(self, exc):
        print('The server closed the connection')
        print('Stop the event loop')

loop = asyncio.get_event_loop()  
message = 'Hello World!'  
coro = loop.create_connection(lambda: EchoClientProtocol(message, loop),  
                              '', 8888)

Note the usage of sync callbacks. You want to read a specific amount of bytes? Nope, sorry. You want to call something async from your callbacks? Enjoy implementing your own weird async primitives setup. I'm convinced protocols were deliberately gimped to try and make you use streams.

Loop socket functions.

Just read the doc entry. They're not nice.

Sin 3) Bad thread compatibility

asyncio's thread compatibility is mediocre at best, annoyingly bad at worst.

Event loops themselves aren't thread-safe. That's fair, that's an easy way to avoid potential woes with threads and event loops. But that's not the issue with thread compatibility.

As explained a few paragraphs above, CPU bound work (aka blocking functions) simply cannot run in the same thread as asyncio. This breaks everything. Instead what you are meant to do is run the function in an executor, provided by concurrent.futures.

What is annoying:

  • Once you're in thread-land, you're basically SOL if you want to interact with async-land.

  • Even the call to enter thread-land is bad: loop.run_in_executor(executor, callable). None is almost always passed as an argument to executor. It would be better served as a keyword argument, rather than the first argument.

Contrast asyncio's thread support with curio's async threads; async threads have the ability to communicate properly with async-land via the AWAIT function. asyncio provides no such compatibility.

An even worse mechanism is that a lot of functions simply don't work inside a thread, unless you use the _threadsafe method. You have to use stock threading synchronisation primitives otherwise; if you want to wait for something in a coroutine that a thread is dealing with, you have to either block or deal with wrapping anything that touches async-land with a loop.call_soon_threadsafe.

Given that threads are such a key (even required!) way to do a lot of things in an async world, it's very surprising (and annoying) that asyncio hasn't got a good way to handle them.

Sin 4) No async file I/O

Yeah, async file I/O is hard, especially cross platform (although asyncio doesn't care about that enough, see below). That said, asyncio is a framework for async I/O, not async networking.

The easy way to use async file I/O is using threads; they can save in a thread whilst the rest of your code continues. aiofiles does this, so why isn't it included in stock asyncio?

  • The designers didn't want to wrap anything in threads. Nope, this isn't an excuse. The base event loop uses a thread executor (or, heck, even a process executor) for DNS stuff (see: BaseEventLoop.getaddrinfo).

  • async files are hard to ensure causality with (a yield can stop writing). Nope, this isn't an excuse. Streams or protocols don't respect causality, and they already exist.

Sin 5) Arbitrary splitting of the API

The asyncio module has some useful (sometimes) utility functions, that aren't really bound to a loop. These deserve to be in the module.

The event loop object has functions that interact with the selector, the network, etc. These deserve to be in the event loop.

The asyncio module also has functions which strictly interact with the event loop, such as higher-level API functions (such as asyncio.open_connection). These do not deserve to be at the module level.

Functions such as open_connection should be on the event loop. You have to pass it anyway (reader, writer = await asyncio.open_connection("", 7654, loop=loop)), and since it's strictly interacting with the event loop it only makes sense that it should be inside the event loop. One could argue that it's so that the event loop only has to create objects it owns, but that's simply not true because it heavily uses asyncio.Future and asyncio.Task objects, or the implementation equivalent.

General API badness

The asyncio API has a lot of general badness. I'll list some things that I couldn't cleanly fit into the other sections.

  • asyncio.ensure_future. This ensures an object, be it a coroutine, is an instance of an asyncio.Future. It also schedules it on the current loop to execute. What?

    • Made doubly as dumb because asyncio.Future objects aren't even the task wrappers; that's asyncio.Task; you're meant to be able to create futures that don't wrap a task.
  • The existence of most of the asyncio. functions. You have to call the loop for everything async, except for these functions, which are only available in the asyncio module, but you still have to pass a loop to them. So instead of self.loop.whatever() now you have asyncio.whatever(loop=self.loop).

    • Although, at least this is consistent - the utility functions are in the asyncio namespace, whereas the network functions are on the loop object.
  • Lack of a proper task manager. Both gather and wait exist; gather forces you to wait for all of them, wait forces you to wait for some. But only sometimes. Why do both functions even exist separately? See curio's TaskGroup and trio's Nursery for good usages of task groups.

  • as_completed, which is meant to be used like the code block below. No, you can't async for it. That would be too easy.

        for f in as_completed(fs):
            result = yield from f  # The 'yield from' may raise.
            # Use result.
  • You can NOT yield to the event loop outside of a 3.4 coroutine. This means if you're in a tight loop, you have to call await asyncio.sleep(0) (!!!) every so often to let the loop process everything.

  • On Windows, with the stock event loops, you can have UDP or subprocesses, but not both.


The issues above show that, in my opinion, asyncio is a horribly designed library. Why it's the stdlib async implementation is another big question, because it is seriously faulted and shouldn't be there.

Extra reading

Some other articles on this topic, too:
I don't understand Python's Asyncio - Armin Ronacher
Some thoughts on asynchronous API design in a post-async/await world - Nathaniel J. Smith


Written on 02 December 2017