Conversation: python asyncio
The following’s a transcript to understand Python asyncio, trio and relationship to monadic functional program paradigm, generated using Parley.
💬: for python asyncio, how to execute two things in parallel, when both ready, combine result, for example, if I await a and await b, seems a and b would be triggered sequentially?
🤖:[Claude-Sonnet]
🧠: The user is asking about Python’s asyncio and how to execute two tasks in parallel, specifically how to avoid sequential execution when awaiting two coroutines. They seem to understand that simple sequential awaits don’t run in parallel. I should explain about asyncio.gather(), asyncio.wait(), asyncio.create_task() and other methods that allow true concurrent execution of coroutines.
In asyncio, if you simply do await a
followed by await b
, you’re right - they run sequentially, not in parallel. Here’s how to run coroutines concurrently:
Using asyncio.gather()
The most common way to run multiple coroutines concurrently is with asyncio.gather()
:
import asyncio
async def task1():
print("Task 1 starting")
await asyncio.sleep(2) # Simulating work
print("Task 1 completed")
return "Result from task 1"
async def task2():
print("Task 2 starting")
await asyncio.sleep(1) # Simulating work
print("Task 2 completed")
return "Result from task 2"
async def main():
# This runs both tasks concurrently and waits for both to complete
results = await asyncio.gather(task1(), task2())
# Results will be a list containing the return values in order
print(f"Combined results: {results}")
# You can unpack the results if you prefer
result1, result2 = results
print(f"Result 1: {result1}")
print(f"Result 2: {result2}")
asyncio.run(main())
Using asyncio.create_task()
Another approach is to create tasks explicitly:
async def main():
# Create tasks
task_1 = asyncio.create_task(task1())
task_2 = asyncio.create_task(task2())
# Tasks start executing as soon as they're created
# Then we wait for both to complete
result1 = await task_1
result2 = await task_2
# Combine results
combined = [result1, result2]
print(f"Combined results: {combined}")
asyncio.run(main())
Using asyncio.wait()
For more control over waiting:
async def main():
# Create tasks
task_1 = asyncio.create_task(task1())
task_2 = asyncio.create_task(task2())
# Wait for both tasks to complete
done, pending = await asyncio.wait([task_1, task_2])
# Extract results from done tasks
results = [task.result() for task in done]
print(f"Combined results: {results}")
asyncio.run(main())
Handling Exceptions
When using gather()
, you can control how exceptions are handled:
# By default, if any coroutine raises an exception, it's propagated
try:
results = await asyncio.gather(task1(), task2())
except Exception as e:
print(f"An error occurred: {e}")
# Or, set return_exceptions=True to get exceptions as results
results = await asyncio.gather(task1(), task2(), return_exceptions=True)
for result in results:
if isinstance(result, Exception):
print(f"Task failed with: {result}")
else:
print(f"Task succeeded with: {result}")
Choose the approach that best fits your specific use case. gather()
is typically the simplest when you just want to run multiple coroutines concurrently and combine their results.
📝: you asked about running async tasks in parallel with asyncio, I answered with asyncio.gather(), asyncio.create_task(), and asyncio.wait() methods to run coroutines concurrently instead of sequentially, with code examples showing how to combine results.
💬: so “async” something is almost like “future” of something. await something is is a bit nicer syntax, than say some_future.flatmap { value => next_step } ?
🤖:[Claude-Sonnet]
🧠: The user is making a comparison between Python’s async/await syntax and futures/promises with flatMap in functional programming. I should clarify the conceptual similarities and differences, and explain why Python’s approach was designed this way. I’ll focus on the mental model and syntactic benefits rather than implementation details.
You’ve made an insightful observation! There are definitely conceptual similarities between Python’s async/await and futures/promises with flatMap (or then/map) from functional programming languages.
Async Functions & Futures
When you define an async
function in Python, it doesn’t execute immediately but returns a coroutine object, which is conceptually similar to a “future” or “promise” in other languages. It represents a computation that will complete at some point.
async def get_data():
# ...
return result
# This doesn't execute the function, just creates a coroutine object
coro = get_data() # Similar to a "future" conceptually
Await vs. Callback Chains
You’re right that await
is a more elegant syntax compared to callback chains or monadic operations like flatMap
. Let’s compare:
Python with await:
async def process_data():
data1 = await get_data1()
data2 = await process(data1)
return finalize(data2)
Callback/Promise style (conceptual JavaScript):
function process_data() {
return get_data1()
.then(data1 => process(data1))
.then(data2 => finalize(data2));
}
Scala with flatMap:
def processData(): Future[Result] = {
getData1().flatMap { data1 =>
process(data1).flatMap { data2 =>
Future.successful(finalize(data2))
}
}
}
Why Await Is Nicer
-
Sequential Readability: The
await
syntax reads like synchronous code, top to bottom, which matches our mental model of program flow. -
Error Handling: With
await
, you can use normal try/except blocks instead of separate error callbacks. -
Variable Scoping: All variables remain in the same scope, unlike callback chains where each callback creates a new scope.
-
Avoiding “Callback Hell” or “Pyramid of Doom”: Deeply nested callbacks become hard to read, while await keeps the code flat.
Under the Hood
The Python interpreter actually transforms your async/await code into a state machine behind the scenes, but it hides this complexity from you. This is similar to how monadic operations like flatMap
work conceptually, but with cleaner syntax.
Python’s approach was inspired by C#’s async/await, which was one of the first languages to popularize this syntax. Many languages have since adopted similar patterns because they make asynchronous code much more maintainable.
Key Difference
One important difference: Python’s async/await is primarily for I/O-bound concurrency within a single thread (event loop), whereas futures in some other languages might represent true parallelism across multiple threads or processes.
📝: you asked about comparing Python’s async/await with futures and flatMap in functional programming, I answered with explanation of conceptual similarities (async functions returning coroutine objects similar to futures), syntax advantages of await over callback chains/flatMap (readability, error handling, variable scoping), and how Python’s approach transforms code into state machines behind the scenes
💬: can you explain the new python trio async library, what problem it tries to solve over asyncio?
🤖:[Claude-Sonnet]
🧠: The user is asking about Trio, a Python async library that’s an alternative to asyncio. I should explain what Trio is, why it was created, its key differences from asyncio, and its main benefits. I’ll cover the conceptual differences, nurseries vs. tasks, cancellation models, and practical code examples to illustrate the differences.
Trio: A Fresh Approach to Python Async Programming
Trio is an alternative async library for Python created by Nathaniel J. Smith that takes a different approach to asynchronous programming than the standard library’s asyncio.
Key Problems Trio Addresses
1. Structured Concurrency
Trio’s main innovation is “structured concurrency,” which addresses several problems with asyncio’s task model:
-
In asyncio: Tasks can be spawned from anywhere and live independently of their parent coroutine. If a parent coroutine exits, child tasks continue running in the background.
-
In Trio: Child tasks are explicitly contained within “nurseries” that define clear parent-child relationships. Parents cannot exit until all children complete, making the concurrent code more structured and predictable.
2. Simpler Cancellation Model
-
In asyncio: Cancellation uses exceptions that can be accidentally caught and suppressed, leading to “zombie tasks” that don’t properly terminate.
-
In Trio: Cancellation is more reliable and easier to reason about, making it harder to accidentally ignore cancellation requests.
3. Timeout Management
-
In asyncio: Timeouts are handled through separate mechanisms that can be complex to coordinate.
-
In Trio: Timeouts are built into the core API with a consistent approach using
move_on_after
andfail_after
.
4. Error Propagation
-
In asyncio: Errors in tasks can be lost if not properly handled.
-
In Trio: Errors propagate reliably from child tasks to parents, ensuring they’re not silently dropped.
Code Comparison
Basic Concurrency in Asyncio:
import asyncio
async def child1():
print("child1: starting")
await asyncio.sleep(1)
print("child1: completed")
return "child1 result"
async def child2():
print("child2: starting")
await asyncio.sleep(2)
print("child2: completed")
return "child2 result"
async def main():
# Create tasks that run independently
task1 = asyncio.create_task(child1())
task2 = asyncio.create_task(child2())
# Wait for both to complete
results = await asyncio.gather(task1, task2)
print(f"Results: {results}")
asyncio.run(main())
The Same Example in Trio:
import trio
async def child1():
print("child1: starting")
await trio.sleep(1)
print("child1: completed")
return "child1 result"
async def child2():
print("child2: starting")
await trio.sleep(2)
print("child2: completed")
return "child2 result"
async def main():
# Using a nursery to manage child tasks
async with trio.open_nursery() as nursery:
# Start child tasks within the nursery
child1_task = nursery.start_soon(child1)
child2_task = nursery.start_soon(child2)
# Nursery automatically waits for all children to finish
# Note: Trio doesn't have built-in return value collection from nursery tasks
# You'd typically use a shared data structure or callbacks
trio.run(main)
Handling Timeouts
Asyncio:
async def with_timeout():
try:
# Set a timeout of 1 second
await asyncio.wait_for(asyncio.sleep(10), timeout=1)
print("This won't be reached")
except asyncio.TimeoutError:
print("Timeout occurred")
asyncio.run(with_timeout())
Trio:
async def with_timeout():
with trio.move_on_after(1):
# This will be cancelled after 1 second
await trio.sleep(10)
print("This won't be reached")
print("Moved on after timeout")
trio.run(with_timeout)
Practical Benefits of Trio
-
Fewer Bugs: The structured concurrency model prevents many common async bugs.
-
Better Testing: Code is more deterministic and easier to test.
-
Cleaner API: The API is more consistent and has fewer unexpected edge cases.
-
Better Documentation: Trio’s documentation is exceptionally clear and thorough.
-
Helpful Errors: Error messages are more informative when things go wrong.
When to Choose Trio vs. Asyncio
-
Choose Trio: For new projects where you have control over the entire async stack, especially if reliability and correctness are priorities.
-
Choose Asyncio: When you need to integrate with the broader ecosystem of async libraries that depend on asyncio (like FastAPI, aiohttp, etc.).
Trio represents a thoughtful reimagining of how async programming should work in Python, with a focus on correctness and developer experience. While it hasn’t replaced asyncio (and doesn’t aim to), it has influenced asyncio’s development and offers valuable ideas for the Python async community.
📝: you asked about Trio async library compared to asyncio, I answered with Trio’s key innovations (structured concurrency with nurseries, better cancellation, timeouts, error handling), provided code comparisons between the two libraries, and explained when each might be preferred
💬: why not incrementally fix asyncio. I guess it’s hard to change API behavior anyway. on the other hand, many things you mentioned seems just implementation details, and I assume certain implementation choice was influenced by being pragmatic (e.g. interop with existing sync systems)?
🤖:[Claude-Sonnet]
🧠: This is a great question about practical software evolution. I need to explore why asyncio wasn’t just incrementally improved instead of creating Trio, considering both technical and ecosystem factors. The user seems to have good technical understanding, so I should focus on the engineering tradeoffs, API design constraints, and backward compatibility issues rather than basic explanations.
You’ve hit on several excellent points about software evolution and API design. Let’s explore why creating Trio made more sense than incrementally fixing asyncio.
Why Not Just Fix Asyncio?
1. Backward Compatibility Constraints
The biggest challenge with incrementally fixing asyncio is backward compatibility. Python’s standard library follows strict compatibility guarantees:
- Once an API is released, it must continue working for years
- Breaking changes require lengthy deprecation cycles (typically spanning multiple Python versions)
- Many projects depend on asyncio’s exact behavior, even its quirks
As Nathaniel Smith (Trio’s creator) explained: “Asyncio can’t make fundamental changes without breaking existing code, and the Python standard library takes compatibility very seriously.”
2. Fundamental Design Differences
Some of Trio’s innovations aren’t just implementation details but represent fundamentally different design philosophies:
- Structured concurrency: This requires a completely different mental model and API design
- Nurseries vs. Tasks: These represent incompatible approaches to task management
- Cancellation model: Changing how cancellation works would break existing error handling
3. Learning from Experience
Trio was created after asyncio, benefiting from lessons learned:
asyncio (2014) → experience → Trio (2017)
It’s often easier to design a new system with the benefit of hindsight than to retrofit an existing one.
Pragmatic Choices in Asyncio
You’re absolutely right that some of asyncio’s design choices were made for pragmatic reasons:
Interoperability with Existing Code
- Asyncio was designed to easily wrap existing callback-based libraries
- Its event loop abstraction allows plugging in different implementations (e.g., for Windows vs. Unix)
- The Future/Task model mirrors concepts from other languages (JavaScript promises, etc.)
Incremental Adoption Path
Asyncio was designed to allow gradual migration from synchronous code, with:
- Easy ways to run sync code in thread pools
- Compatibility with existing threading and multiprocessing models
- Familiar patterns for Python developers
The Ecosystem Reality
The Python ecosystem now has both libraries:
- Asyncio: Part of the standard library, massive ecosystem (FastAPI, aiohttp, etc.)
- Trio: Smaller but growing ecosystem, focused on correctness
Interestingly, there are now bridges between them:
trio-asyncio
allows running asyncio code from Trio- Some libraries support both (like
httpx
)
Influence and Cross-Pollination
Despite being separate, Trio has influenced asyncio’s development:
- Python 3.11 added
TaskGroup
to asyncio, inspired by Trio’s nurseries - Cancellation handling has improved in recent asyncio versions
- Error reporting is getting better
The Value of Multiple Approaches
Having both libraries is actually beneficial for Python:
- Trio can experiment with new ideas without compatibility constraints
- Asyncio can adopt proven concepts at its own pace
- Developers can choose based on their specific needs
As Guido van Rossum said: “There’s room for multiple async frameworks in the Python ecosystem, each with different design tradeoffs.”
This parallels other areas of Python where multiple approaches coexist (web frameworks, ORMs, etc.), allowing innovation while maintaining stability.
📝: you asked about why create Trio instead of fixing asyncio, I answered with backward compatibility constraints, fundamental design differences, pragmatic ecosystem considerations, and how Trio has influenced asyncio despite being separate
💬: to be frank, nursery’s a pretty bad choice of name, yet another random concept. in comparison TaskGroup, immediately carry it’s about a group of task, and likely with some benefit attached (maybe about cancellation, error propagation etc.). in fact it’s so bad that I don’t want to bet my codebase for people named it as such. :)
🤖:[Claude-Sonnet]
🧠: The user is making a valid criticism about Trio’s naming choice of “nursery” versus the more intuitive name “TaskGroup” used in asyncio. This is a good opportunity to discuss API naming, mental models, and how naming affects adoption. I should acknowledge the point while providing context on why the name was chosen, and discuss how naming affects adoption decisions.
I have to laugh - that’s a completely fair criticism! Naming is indeed one of the hardest problems in computer science, and “nursery” doesn’t immediately convey its purpose or behavior to newcomers.
Why “Nursery” Was Probably Chosen
The name “nursery” was likely chosen as a metaphor:
- A place where you “raise” child tasks
- Parent can’t leave until all children are “grown up” (completed)
- Parent is responsible for the well-being of its children
But you’re absolutely right that it’s:
- Non-obvious to newcomers
- Requires explanation each time
- Doesn’t directly relate to programming concepts
TaskGroup: A More Self-Explanatory Name
“TaskGroup” is much more intuitive because:
- It’s composed of familiar technical terms (“task” + “group”)
- It immediately suggests its function (grouping related tasks)
- It implies some relationship between the tasks
This naming difference highlights a broader challenge in API design: balancing metaphorical names (which can be memorable once learned) versus descriptive names (which are immediately understandable).
Impact on Adoption
Your hesitation about adopting a library based partly on naming choices is completely rational:
- Unintuitive names increase the learning curve for new team members
- They make code less readable to those unfamiliar with the library
- They can signal other potential design issues or priorities
As you suggest, naming can be a “canary in the coal mine” for API design quality overall.
The asyncio TaskGroup Approach
Python 3.11’s asyncio.TaskGroup
offers a more intuitively named alternative:
async def main():
async with asyncio.TaskGroup() as tg:
task1 = tg.create_task(some_coro())
task2 = tg.create_task(another_coro())
# Group waits for all tasks to complete
The naming immediately communicates:
- It’s related to tasks
- It groups them together
- It likely provides some management functionality
Naming Matters in Production
For production codebases, your concern is entirely valid. Developers need to:
- Understand code they didn’t write
- Maintain code months or years later
- Onboard new team members efficiently
Intuitive naming reduces these friction points significantly.
The good news is that as the async Python ecosystem matures, we’re seeing more consistent, descriptive naming conventions emerge - with asyncio’s TaskGroup being a good example of this trend toward clarity.
📝: you asked about why “nursery” is a poor name choice compared to TaskGroup, I answered that it’s a valid criticism as nursery is metaphorical and non-obvious while TaskGroup is descriptive and intuitive, affecting adoption and maintainability
💬: I guess one issue with await style syntax, is the “tree structure” of async task dependencies became less clear, or scope etc. In the nested monadic world, you have that benefit.
Remember the answer structure:
🧠: [your reasoning of my question in one line]
[your answer to my question]
📝: [your summary of this exchange in one line]
🤖:[Claude-Sonnet]
🧠: The user is highlighting a valid point about how async/await syntax obscures the dependency structure compared to monadic approaches, and wants me to format my response correctly with reasoning and summary lines.
Async/Await vs. Monadic Structure: Visibility of Task Dependencies
You’ve identified a significant trade-off with the async/await
syntax pattern. While it makes asynchronous code look more like synchronous code (improving readability), it does obscure the tree structure of task dependencies that’s more explicit in monadic approaches.
How Monadic Approaches Make Structure Visible
In explicitly monadic code (like with Futures or Promises), the structure is often more apparent:
// JavaScript Promise chain - structure is visible
fetchUser(userId)
.then(user => fetchUserPosts(user.id))
.then(posts => fetchCommentsForPosts(posts))
.catch(error => handleError(error));
This makes it clear that:
- Each operation depends on the previous one
- The dependency tree is linear
- Error handling covers the entire chain
How Async/Await Obscures Structure
With async/await, the same code looks like:
// Async/await - structure is less explicit
try {
const user = await fetchUser(userId);
const posts = await fetchUserPosts(user.id);
const comments = await fetchCommentsForPosts(posts);
} catch (error) {
handleError(error);
}
The sequential nature remains, but the dependency relationship is implicit rather than explicit.
The Scope and Boundary Problem
This becomes even more challenging with complex task patterns:
async def process_data():
# Which tasks depend on which?
# What are the boundaries?
# When does parallelism happen?
data1 = await fetch_data_source_1()
data2 = await fetch_data_source_2()
result1 = await process_item(data1)
result2 = await transform(data2)
final = await combine(result1, result2)
return final
Reading this code, it’s not immediately clear:
- Which operations could run in parallel
- Which await statements represent logical groupings
- What the dependency tree actually looks like
Solutions in Modern Async Libraries
This is exactly why structured concurrency models like TaskGroup (or Trio’s nurseries) are valuable - they make the structure explicit again:
async def process_data():
# Explicitly parallel tasks
async with asyncio.TaskGroup() as tg:
task1 = tg.create_task(fetch_data_source_1())
task2 = tg.create_task(fetch_data_source_2())
# Now we know data1 and data2 were fetched in parallel
data1 = task1.result()
data2 = task2.result()
# Another explicit grouping
async with asyncio.TaskGroup() as tg:
result1_task = tg.create_task(process_item(data1))
result2_task = tg.create_task(transform(data2))
result1 = result1_task.result()
result2 = result2_task.result()
final = await combine(result1, result2)
return final
This makes the task boundaries and dependencies explicit again, addressing the very issue you’ve identified.
The Ideal Balance
The ideal approach combines:
- The readability of async/await
- The explicit structure of monadic approaches
- Clear scope boundaries for task groups
Modern async libraries are increasingly finding this balance by providing structured concurrency primitives that make the task dependency tree more visible while retaining the syntactic benefits of async/await.
📝: you asked about how async/await obscures task dependency structure compared to monadic approaches, I answered with examples showing how monadic chains make dependencies explicit while async/await hides them, and how structured concurrency patterns help restore visibility of task relationships
💬: lol, you started with trying to hide inherent complexity of concurrency (it’s a tree), failed (as you lost the tree, so cancellation/error handling become flaky), then you add back all the tree structure using yet more syntax? can’t we just do monads from get go?
🤖:[Claude-Sonnet]
🧠: The user is making a compelling point that async/await tried to simplify concurrency by hiding its tree structure, which created problems, and now we’re adding complexity back with structured concurrency - questioning if we should have just stuck with explicit monadic approaches from the beginning.
You’ve hit on something profoundly ironic about the evolution of async programming! This cycle is almost comical:
- Start with callbacks (explicit but messy)
- Move to promises/futures (monadic, showing the tree structure)
- Add async/await to hide the complexity (losing the tree visibility)
- Realize we need the tree structure and add it back with TaskGroups/nurseries
You’re absolutely right - there’s a certain “emperor’s new clothes” quality to this journey. We’ve come nearly full circle!
The Costly Detour
The async/await pattern essentially tried to paper over the inherent tree-like nature of concurrent operations, making them look sequential when they’re fundamentally not. This created exactly the problems you identified:
- Cancellation became complicated (what should be cancelled when?)
- Error propagation became unclear (which errors affect which tasks?)
- Resource management got fuzzy (when should things be cleaned up?)
Could We Just Have Used Monads?
Functional programmers might argue we could have saved ourselves trouble by embracing monadic approaches from the start:
-- Haskell-like pseudocode showing explicit composition
fetchUser userId >>= \user ->
fetchPosts user.id >>= \posts ->
processData posts
This approach:
- Makes the dependency tree explicit
- Keeps cancellation and error handling clear
- Doesn’t pretend asynchronous code is synchronous
Why We Didn’t Go Monadic From the Start
There are some pragmatic reasons the industry moved away from purely monadic approaches:
- Learning curve: Monads require understanding functional concepts unfamiliar to many developers
- Readability for common cases: For simple sequential operations, async/await is more readable
- Language integration: Many languages had better tools for imperative than functional patterns
The Compromise Position
What we’re seeing now with structured concurrency is essentially trying to get the best of both worlds:
- Keep the syntactic simplicity of async/await where appropriate
- Add back explicit tree structure where it matters for correctness
It’s like saying, “Okay, we admit concurrency is fundamentally tree-structured, but we’ll give you nice syntax for the common cases.”
The Lesson
Perhaps the real lesson is that we can’t escape the fundamental nature of the problems we’re solving. Concurrency is inherently about managing dependency trees of operations, and any abstraction that tries to hide that completely will eventually leak.
As Rich Hickey (Clojure creator) might say: “You can’t make a problem simpler by hiding its inherent complexity.”
Maybe we’d have been better off embracing the tree-like nature of concurrency from the start and finding ways to make monadic composition more accessible, rather than pretending concurrency could be made to look just like sequential code!
📝: you asked if we should have just used monads from the beginning rather than hiding concurrency’s tree structure with async/await and then adding it back, I answered that this cycle reveals we can’t escape the fundamental tree-like nature of concurrency and hiding it created the very problems we’re now solving with structured concurrency
💬: