Pain point in asyncio: Potentially-failing tasks

The most valuable tool for debugging Python code is the exception. Usually, if we do nothing at all, we get exceptions logged to the console, and we have to do some work to suppress them. That’s true of synchronous code, threaded code, subprocesses, but not asyncio tasks. Example code:

import threading, multiprocessing, asyncio, time

# This task takes some time, but then fails due to a bug.
# GOAL: Be able to spin this off, but still see its exception
# traceback.
def delayed_boom():
	time.sleep(1)
	None.sense

async def adelayed_boom():
	await asyncio.sleep(1)
	None.sense

# Synchronous code is easy. You call it, it crashes.
def main_sync():
	print("Starting sync!")
	delayed_boom()
	print("Ending.") # Won't happen

# Threaded code is also easy. You spawn a thread, it crashes.
def main_thread():
	print("Starting thread!")
	threading.Thread(target=delayed_boom).start()
	# input("Press Enter to end -> ")
	time.sleep(3)
	print("Ending.")

# Multiprocessing is easy, though you have more things to take
# care of, such as ensuring that stuff is importable.
def main_process():
	print("Starting subprocess!")
	multiprocessing.Process(target=delayed_boom).start()
	# input("Press Enter to end -> ")
	time.sleep(3)
	print("Ending.")

async def main_async_bad():
	print("Starting async!")
	task = asyncio.create_task(adelayed_boom())
	await asyncio.sleep(3) # it's too fiddly to wait for input
	print("Ending.")
	# The above code just never awaits the task, very bad. If we await it
	# here, we at least see the exception *eventually*, but we won't see
	# it in a timely manner.
	# await task

# ------ this is a lot of boilerplate for a Python script ------
import traceback
def handle_errors(task):
	try:
		exc = task.exception()
		if exc: traceback.print_exception(exc)
	except asyncio.exceptions.CancelledError:
		pass

all_tasks = []

def task_done(task):
	all_tasks.remove(task)
	handle_errors(task)

def spawn(awaitable):
	task = asyncio.create_task(awaitable)
	all_tasks.append(task)
	task.add_done_callback(task_done)
	return task
# ------ end boilerplate ------

async def main_async_good():
	print("Starting async!")
	spawn(adelayed_boom())
	await asyncio.sleep(3)
	print("Ending.")

if __name__ == "__main__": # guard against multiprocessing
	# main_sync()
	main_thread()
	main_process()
	# asyncio.run(main_async_bad())
	asyncio.run(main_async_good())

In each case (other than main_sync()), it should spawn a task, but main runs for longer. Think like a long-running GUI app or server, and it triggers some kind of asynchronous operation. What happens when that task fails? In most cases, the exception shows up just fine. But for asyncio, that requires a blop of extra handling, for two reasons: firstly, as noted in the docs for create_task, tasks have to be retained; and without some kind of “done” trigger that checks for the exception, that still gets abandoned.

(I considered using a TaskGroup but that doesn’t help - if one of the tasks fails, it brings down the entire app.)

I propose that something akin to spawn() be added as a top-level function in asyncio, and that it become the recommended way to start tasks without waiting for them. This will bring async I/O up to parity with threads and processes in terms of “debuggable by default”. My suspicion is that issues like these may very well have been caused in part by exceptions not being seen.

5 Likes

I would rather we steer folks towards using TaskGroups and give them a convenient/ergonomic way of wrapping their tasks so errors are logged instead of propagated (cancelling the entire taskgroup).

1 Like

I would be fine with that, so long as the ultimate goal can still be achieved. Notably, though, tasks have to be able to themselves spawn tasks, so I would probably have to end up making the TaskGroup global.

This depends on the requirements of your program but sure, if your tasks need to spawn other tasks that will outlive them, having access to a taskgroup with a broader scope is a good way of going about it. You can enter a TaskGroup in the root task and pass it around. The benefit is that task lifetimes become more explicit, and you ensure no tasks outlive the root task (unless explicitly accounted for).

I can definitely get behind this. It doesn’t solve the fundamental problem, but I can definitely accept a solution that is tg.spawn(task()) rather than asyncio.spawn(task()). Or tg.create_task(task()) but with some variant form of TaskGroup (or parameter to it) that will have it treat tasks as independent, and not cancel them when one fails. Any of those would work, but I want something that people get pushed towards by the examples.

Async I/O is anomalous in this way. Every other way of spawning tasks, you get your exceptions. I want the obvious default to be as good for asyncio.

The catch is you need to treat asyncio.create_task as a low-level primitive, pretend it doesn’t exist, and use TaskGroups. Then, asyncio has the best error handling story. The threading scenario, where you spawn something into the void of space and maybe later something prints out into stdout is objectively weaker.

Also, you don’t need to wait for a Python release to get the behavior you want. We can use the magic of :magic_wand: function composition :magic_wand: today.

Maybe something like this:

import asyncio
import traceback
from collections.abc import Coroutine
from typing import Any


async def log_and_swallow[T](coro: Coroutine[Any, Any, T]) -> T | None:
    try:
        return await coro
    except Exception as exc:
        traceback.print_exception(exc)
        return None


async def adelayed_boom():
    await asyncio.sleep(1)
    None.sense


async def main_async_taskgroup():
    print("Starting async!")
    async with asyncio.TaskGroup() as tg:
        task = tg.create_task(log_and_swallow(adelayed_boom()))
        await asyncio.sleep(3)  # it's too fiddly to wait for input
        print("Ending.")


if __name__ == "__main__":  # guard against multiprocessing
    asyncio.run(main_async_taskgroup())

1 Like

Well, yes, I know this - that’s why I posted the above code, lifted from an actual project (and slightly simplified for the post).

I would then end up making a helper function, and we’re right back to where I was: need to make helper functions in order for asyncio to be as good as everything else. That’s what I’m talking about: For someone who has years of experience with asyncio, it’s a minor nuisance that I have to deploy boilerplate in every asyncio project; but for someone who’s brand new and just picking up the docs, there is a badly suboptimal experience. If you read the docs for create_task, it tells you to retain a reference to it - if you didn’t, the task might be abandoned. Already not good. But even then, you’re going to miss out on your tracebacks, or at best get them massively delayed.

I’m not sure why you consider that weaker. That’s exactly how normal Python code works, unless you choose otherwise: exceptions get logged to stderr. It’s what we expect. Running an embedded service spawned from systemd? Unexpected exceptions will terminate the process, and you’ll find something in the service’s journal. Running it from the terminal? Oh dear, it crashed… there’s the traceback, right there. It’s what stderr’s designed for.

Small change: I would catch ALL exceptions, not just subclasses of Exception, in that handler. Otherwise, leakage of unusual ones will still result in suboptimal display. So I would except BaseException, but possibly then special-case KeyboardInterrupt and SystemExit, at which point we’re definitely getting into more than just a trivial wrapper.

Not really. In ordinary Python code, when an exception is raised it needs to be handled in the caller. You wouldn’t expect to call a function and then have it just print out something if it encounters an exception, you’d expect to have to deal with it by catching it. Just printing out an error to stdout is objectively weaker than making you handle the exception, right?

Anway, I could see an argument for adding a helper function like this (but more sophisticated) to asyncio.

“Needs to be handled”? But if it ISN’T handled, you get an exception traceback on stderr. That’s what I mean by “debuggable by default”. Try uncommenting main_sync() in my example program - with no threads, subprocesses, or event loops, all that happens is that main calls another function, that function sleeps, then crashes, and you get a nice easy traceback to examine. No effort, and it’s giving you all the information.

If you know about a specific exception, you catch it. That’s fine. That all works perfectly normally. But if you aren’t planning on dealing with an exception, ignore it, and you should get them printed out. That’s how we normally expect Python to treat us.

For context, I use asyncio occasionally, but my use is very much “casual”, and I would not call myself an expert. Most of my use could just as easily be done using threads - and often is, when I hit speedbumps like this with asyncio. But I’d like to use asyncio - although if I’m honest, I can’t really articulate why I feel like that.

I agree with Chris here. The Zen says “Errors should never pass silently”, and forgetting to catch and handle an exception is very definitely an error that shouldn’t “pass silently”. And adding infrastructure like log_and_swallow might well address the issue, but again, forgetting to add that boilerplate is an error that shouldn’t pass silently.

IMO, this is the biggest reason asyncio has a reputation for being difficult. The fundamentals are subtly, but significantly, different from all of Python’s other concurrency models. And far too often you seem to either end up having to build your own equivalent abstractions from low-level components[1], or getting told “you should be using different abstractions” without those abstractions being available in the stdlib…


  1. Another pet peeve of mine - why isn’t there an async worker pool like concurrent.futures? ↩︎

4 Likes

With threads and other forms of concurrency, there’s a notable amount of overhead in starting one up (more on some platforms than others, but still overhead), and potentially catastrophic overhead for having too many, so the idea of a “worker pool” is a very good one. Let’s say you have 1,000,000 tasks to do; do you spawn a million threads, or do you spawn a half dozen threads and let each one do a section of the work? The worker pool is a way of abstracting over the more efficient second option, while still feeling like you just say “go do this million tasks”.

With asyncio, you don’t have that tension. Spawning tasks is cheap. You have a million things to do? Spawn a million tasks. Let the system figure out how to schedule them. Maybe having an asyncio.pool() would be convenient for the parallel (you get to pick how you want to schedule things, and by changing module, you change the behaviour), but it wouldn’t need to do anything more than just schedule all the tasks on the same loop.

It would be rather nice to have some sort of hybrid option (“spawn all of these async tasks across X threads”), but that’s a separate concept and would bring with it a lot of additional quirks, since there’d then be two concurrent forms of concurrency.

1 Like

We’re getting off topic here, so feel free to not answer, but this is the whole “different models” issue. Yes, I can spawn a million tasks. But that has different trade-offs. For example, if each task is querying a package on PyPI, that’s a million requests to PyPI. If I don’t get throttled or blocked for that, I probably should. Whereas a thread pool only does as many requests at once as there are workers. I can limit requests in asyncio using a semaphore, but that’s sort of my point - like uncaught exceptions, asyncio works differently, and expectations from more traditional concurrency models don’t transfer properly. And semaphores are a very low level construct, less of an abstraction than a primitive for me to build my own abstraction (a bit like the “log and swallow” helper is an abstraction the user has to build for themselves).

To an expert, this is probably a non-issue. Use the appropriate asyncio tools and understand the basic asyncio behaviour. And ultimately that is the answer, at some level. But not everyone is an expert, and safe defaults and familiar abstractions are a huge help to people not writing expert-level production code. Maybe a task pool isn’t the right abstraction for submitting a million web requests. But “spawn a million tasks” definitely isn’t, so what is the “throttle tasks to stay within certain limits” abstraction? A task pool was just my best guess at the right approach - because asyncio doesn’t offer any others that I can consider. Similarly, maybe a task pool and a “log and swallow” helper is the right abstraction for handling failed tasks. But where’s the guidance that leads people to that conclusion - especially as it’s not even obvious that a solution is needed. People expect uncaught exceptions to appear on stderr, not to be lost because a task got garbage collected.

OT, but the short answer is to use a semaphore.

On the main topic, if a task is meant to run in the background, I’d recommend putting an exception handler inside it. But a task that will eventually be awaited should not handle its own exceptions, otherwise it will confuse the awaiter. Tasks are not the same concept as threads!

Chris, Paul, it feels like we’re talking a little past each other here. First I said:

So we’re not using asyncio.create_task, alright? We’re using TaskGroups.

import asyncio


async def adelayed_boom():
    await asyncio.sleep(1)
    None.sense


async def main():
    async with asyncio.TaskGroup() as tg:
        tg.create_task(adelayed_boom())
        await asyncio.sleep(3)


asyncio.run(main())

When we run it:

❯ python a02.py
  + Exception Group Traceback (most recent call last):
  |   File "/home/tin/pg/pytest-typing/a02.py", line 15, in <module>
  |     asyncio.run(main())
  |     ~~~~~~~~~~~^^^^^^^^
  |   File "/home/tin/.local/share/uv/python/cpython-3.14.0-linux-x86_64-gnu/lib/python3.14/asyncio/runners.py", line 204, in run
  |     return runner.run(main)
  |            ~~~~~~~~~~^^^^^^
  |   File "/home/tin/.local/share/uv/python/cpython-3.14.0-linux-x86_64-gnu/lib/python3.14/asyncio/runners.py", line 127, in run
  |     return self._loop.run_until_complete(task)
  |            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^
  |   File "/home/tin/.local/share/uv/python/cpython-3.14.0-linux-x86_64-gnu/lib/python3.14/asyncio/base_events.py", line 719, in run_until_complete
  |     return future.result()
  |            ~~~~~~~~~~~~~^^
  |   File "/home/tin/pg/pytest-typing/a02.py", line 10, in main
  |     async with asyncio.TaskGroup() as tg:
  |                ~~~~~~~~~~~~~~~~~^^
  |   File "/home/tin/.local/share/uv/python/cpython-3.14.0-linux-x86_64-gnu/lib/python3.14/asyncio/taskgroups.py", line 72, in __aexit__
  |     return await self._aexit(et, exc)
  |            ^^^^^^^^^^^^^^^^^^^^^^^^^^
  |   File "/home/tin/.local/share/uv/python/cpython-3.14.0-linux-x86_64-gnu/lib/python3.14/asyncio/taskgroups.py", line 174, in _aexit
  |     raise BaseExceptionGroup(
  |     ...<2 lines>...
  |     ) from None
  | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
  +-+---------------- 1 ----------------
    | Traceback (most recent call last):
    |   File "/home/tin/pg/pytest-typing/a02.py", line 6, in adelayed_boom
    |     None.sense
    | AttributeError: 'NoneType' object has no attribute 'sense'
    +------------------------------------

There’s no error passing silently here. This is better to me than the threading approach, which swallows and logs by default.

Now, if you’re arguing asyncio.create_task() doesn’t do this; yeah. Just don’t use it in the year of our lord 2026, and use a TaskGroup.

That is true, but it has a different problem: a failure in one task immediately cancels all other tasks. That’s great if the tasks are inherently linked, but it’s again very different from the behaviour of threads or subprocesses, where one of them can fail, print its traceback, and others keep going. (I’m not getting into daemon threads here; assume that there’s a main thread that hasn’t bombed.) So it’s somewhat better, but far from a full solution. It still makes asyncio fundamentally different from every other form of concurrency.

I find the semantics of task groups to be a pretty negative experience, and any place they could possibly have a benefit, you can do better with other options, even if those options require more code.

Attach an error handler for background tasks, await them during shutdown ignoring the result, letting the handler you attached be responsible for that.

Nothing is failing silently here, the model of tasks assumes someone should handle the result, it just happens to also be a lower level tool that leaves that assumption up to the person leveraging it to decide when and how that occurs.

Telling people not to use create_task isn’t helpful advice, create_task is a useful tool.

1 Like

Ok, so now that we’re on the same page, I’m trying to make the case that the task group behavior is the best of the bunch. When errors come up things explode pretty loudly by default, but errors can be selectively ignored (as demonstrated by my small helper function). You’re saying threads behave differently; OK, I’m not contesting this. I’m saying if you disregard threads and (more abstractly) look at just Python code, it behaves similarly - if you call a function and it produces an exception, it explodes pretty loudly (with that exception), and then you can choose to handle that blast. And if you don’t, no functions after this function will run. If you squint, there’s a parallel here.

You’re saying the default behavior is unergonomic for your particular case; OK, I’m totally on-board with making this use-case better supported in the future. But I believe the default is pretty good.

I hope I’ve made my case compelling enough to bring my viewpoint across.

2 Likes

Or the abstractions that do exist have downsides that only make sense in very specific use cases, and people assume those abstractions are appropriate for everyone when they aren’t…

In terms of your footnote that discourse omits from the quote, I think the lack of worker pool makes sense, but I don’t think it making sense means there shouldn’t still be more abstractions that would serve a similar purpose without being a “worker pool”, as there isn’t some corresponding underlying resource to limit for asyncio scheduling.

Having similar APIs (in terms of how the end user interacts with them) available is a positive thing even if their internal nature is rather different, and describing their internal differences and when to reach for a specific one is entirely possible without getting stuck on some technical purity in the process.

2 Likes

It has been known for a pretty long time (2018, njs’s Notes on structured concurrency) that spawning a task and never awaiting it is a code-smell. TaskGroups make it very easy to guarantee all tasks are awaited at some point.

I personally prefer tasks that blow up the entire process, than (daemon) threads that just die with a log message that goes unnoticed for a long time (happened in our production server).

The problem is that examples that use “asyncio.create_task” are all over the places. Even the “never awaited coroutines” section suggest to use those, which IMO is a terrible advice to give:
Developing with asyncio — Python 3.14.3 documentation . Sure creating a coroutine and never awaiting it is not gonna work, but spawning a task and never catching its errors is equally bad.

With TaskGroups the default behavior of the system is to blow up. It’s a sane thing to do for unhandled errors. People who want another behavior can handle their exceptions. There can also be some exception handling around some top-level task group, just like there often are some top-level exception handling in thread/process for cases where it would otherwise die.

3 Likes