Photo by Levi Meir Clancy on Unsplash
Quick Tip: Learning from Golang to make Python TaskGroups easier to use
Async programming is a core part of modern software development, especially in single-threaded languages such as Python and JavaScript. Since becoming available in Python 3.3, and part of the standard library in Python 3.4, asyncio has radically changed how Python code around the world is written an deployed. While hard to imagine, it was very difficult to write non-blocking Python code before this, which meant that teams deploying Python applications had to be very thoughtful about where, when, and how their code would run.
Starting in version 3.11, TaskGroups were added to asyncio
, which made it considerably easier to spawn and manage multiple asyncio.Task
instances without having to do the boilerplate many of us wrote time and time again to keep track of active tasks, await them, or get their result()
or exception()
. Using TaskGroup
is simple, as evidenced by the documentation’s example
async def main():
async with asyncio.TaskGroup() as tg:
task1 = tg.create_task(some_coro(...))
task2 = tg.create_task(another_coro(...))
print(f"Both tasks have completed now: {task1.result()}, {task2.result()}")
Taking advantage of the fact that TaskGroup is a context manager, a simple async with
statement lets us create as many tasks as we want and have all of them awaited when the context manager exits (e.g. __aexit__
).
TaskGroups let us do repetitive, awaitable tasks with ease, such as when you have a large set of items that you need to do atomic/independent operations on concurrently:
async def main():
large_set_of_users: List[str] = await get_lots_of_users()
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(a_concurrent_task(user)) for user in large_set_of_users]
print("All concurrent tasks for the users are now done and were awaited!")
But, there’s a catch in how TaskGroups work. In particular, this line in the documentation jumps out at us:
asyncio.CancelledError
, the remaining tasks in the group are cancelled.Oh, that’s not ideal, in most cases… See, an asyncio.Task
object has three helpful methods for checking if the Task is completed (done()
which returns a bool), and, once it is done, if there is a result (result()
) or an exception (exception()
). As a result, most of us have gotten pretty used to letting our async functions raise exceptions if they encountered such a case, and then our Task handlers check results or exceptions.
Now, I may be wrong about this, but I know that in most of my experience, when I have a set of independent, concurrent tasks, I rarely want to apply a at first Exception, cancel everything
approach. This can result in partially applied states or, worse yet and far more common than any of us care to admit, situations where a few bad apples spoil the whole bunch (of Tasks).
So this is where the quick tip comes in: step outside of Python-thinking for a second, and look at what we can learn from canonical Go code*. In Go, a prevalent pattern is the if err != nil
approach for handling errors (because Go does not have an equivalent of try/except
), so that you wind up with code like this
func main() {
_, err := os.Open("nonexistent_file.txt")
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println("File opened successfully!")
}
As a result, most Go developers automatically have function signatures that look like this: func Open(name string) (*File, error)
. If the function succeeds as expected, error
will be nil
(Go’s version of None
), otherwise it’ll be something that isn’t nil
. If we take a similar approach to our async def
code in Python that we either plan to or think could be used in TaskGroups, we should make our signatures look like this (return some set of things and also an error
result)
async def phone_home(number: str, message: str = "Hello, world!") -> Tuple[str, Exception | None]:
...
so that then this function is TaskGroup safe, and any version of it that becomes a created task within the TaskGroup can have the result checked with some like
response, err = phone_home_tasks[0].result()
if err is not None:
print(f"Oh no, there was an error! {str(err)}")
else:
...
And that’s it, one pattern for making the code you write more easily used in the wild (which can also be used to wrap async code from a library that does not follow this pattern) if you don’t want your entire set of Tasks to cancel at the first sign of trouble. There are other patterns, of course, and there are times you won’t want this, but when you do, I think it is one of the more elegant patterns out there for handling it.
\ I won’t even begin to try and teach Go here, nor even provide a history of it (we’re already pushing the limit of what a Quick Tip
is to most readers), but if you’re interested in those sort of things (and I do hope you are because we should all always be curious and learning) you can [start here](go.dev).*