Issues with asyncio

Structured Concurrency

Whenever a coroutine function is run, it might or might not spawn some background task. The function seemed to return, but is it still running in the background? There’s no way to know without reading all its source code, transitively. When will it finish? Hard to say.

If an error occurs in a background task, and you don’t handle it manually, then the runtime just… drops it on the floor and crosses its fingers that it wasn’t too important. If you’re lucky it might print something on the console.

Source

When running a coroutine function it always may create a Task and the code execution is forked actually. This Task can be passed around, maybe cancelled, raise exceptions and may even create additional Tasks. All this may be hidden to the caller. Nothing ensures that the Task execution is joined later on.

        flowchart TB
   a[Start] --> b[Task]
   subgraph main
   a --> c[next statement]
   end
   subgraph forked
   b --> d[some operation]
   end
    
        flowchart TB
   a[Start] --> b[forked Task]
   a --> c[next statement]
   b --> join
   c --> join
    
import asyncio

async def coro():
  try:
    await asyncio.sleep(60)
    return 42
  except asyncio.CancelledError:
    print("someone wants to cancel me")
    raise


async def other_coro():
  return asyncio.create_task(coro())

task = asyncio.run(other_coro())
print(task)

Output:

>>> task = asyncio.run(other_coro())
someone wants to cancel me
>>> print(task)
<Task cancelled name='Task-22' coro=<coro() done, defined at <stdin>:1>>

The task for coro was never awaited. It gets canceled when the loop (the asyncio.run function) finishes.

Different async based libraries are addressing this issue:

This issue has been fixed in Python 3.11 with the introduction of TaskGroups.

Before:

async def coro():
    task1 = asyncio.create_task(some_coro(...))
    task2 = asyncio.create_task(another_coro(...))
     # join/await all tasks explicitly
    await asyncio.gather(task1, task2)
    print("Both tasks have completed now.")

With Python 3.11:

async def coro():
    async with asyncio.TaskGroup() as tg:
        task1 = tg.create_task(some_coro(...))
        task2 = tg.create_task(another_coro(...))
    # tasks are joined/awaited automatically
    print("Both tasks have completed now.")

asyncio is not the only async library

Every async library call flow in Python must work like this

async-lib-x -> coroutine -> ... -> coroutine -> async-lib-x

You might know already the reason for this. Every async library needs a loop and the loop needs to track all jobs, tasks, etc. Therefore it needs to track also all I/O to notify waiting coroutines.

So if you are an author of a networking protocol library you need to support async-lib-x explicitly to work correctly with your library.

To name the major ones:

Supporting all of them seems to be a big burden when you only want to get faster code with async/await.

Personal Advice

Just stick with asyncio. It is a standard Python library and is still relatively new. During the years it got improved with every Python release. Most async libraries will only support asyncio and with TaskGroups a major downside is already resolved.

Coloured Functions

In What Color is Your Function? the author argues that every function has a color. There a no functions without a color. He introduced blue and red colored functions. You can call a blue function from within a red one but you can’t call a red function from within a blue one. That means red functions are getting more painful to handle then blue ones. Best would be to just have blue functions overall but sadly some core functions are red. Thus you can’t avoid them.

Of course this is only an analogy of async and sync functions. Let’s take a look at the following table:

If a function like this

wants to call a function like this

does it work?

sync

sync

sync

async

X

async

sync

async

async

Source

Remember the Intermediate Function Issue. It’s still the same with native coroutine functions. You still can’t call async functions from synchronous ones. The async/await syntax didn’t change the situation.

And think about this and asyncio-is-not-the-only-async-library again. Therefore as also mentioned in What Color is Your Python async Library? we have much more then two colors in Python.

And you just can’t implement all these colors under the same namespace:

class SomeClass:
    def same_name(self):       # a function (method)
        return

    def same_name(self):       # a generator function
        yield

    async def same_name(self): # a coroutine function
        await smth()

    async def same_name(self): # an asynchronous generator function
        await smth()
        yield 42

    async def same_name(self): # a coroutine function using a different async lib
        await asynclibx.smth()

and async-lib-x -> coroutine -> ... -> coroutine -> async-lib-y will not work at all too.