Introduction
TaskGroup is a versatile building block for structuring concurrent work. Beyond basic fan-out, it can be combined with batching, timeouts, semaphores, and queues to solve real production problems. This lesson presents four essential TaskGroup patterns that you will use repeatedly in async Python applications.
Key Concepts
- Batching: Processing items in fixed-size groups to control memory usage and concurrency.
- Timeout wrapping: Nesting a TaskGroup inside
asyncio.timeout()to enforce a deadline on all tasks. - Semaphore-limited TaskGroup: Combining a semaphore with a TaskGroup to cap the number of tasks running at once.
- Producer-consumer: A pattern where producers add work to a queue and consumers process it, all managed by a TaskGroup.
Real World Context
An ETL pipeline needs to process 50,000 records from a database. Launching 50,000 concurrent tasks would exhaust memory. Instead, you batch them into groups of 100, use a semaphore to limit active database connections to 10, and wrap everything in a timeout so the pipeline fails fast if the database is unresponsive. Each of these patterns composes cleanly with TaskGroup.
Deep Dive
Batching with TaskGroup
pythonasync def process_batch(items, batch_size=10): results = [] for i in range(0, len(items), batch_size): batch = items[i:i + batch_size] async with asyncio.TaskGroup() as tg: tasks = [tg.create_task(process(item)) for item in batch] results.extend(task.result() for task in tasks) return results
Timeout with TaskGroup
pythonasync def with_timeout(): try: async with asyncio.timeout(5.0): async with asyncio.TaskGroup() as tg: tg.create_task(slow_operation()) tg.create_task(another_operation()) except TimeoutError: print("Operations timed out") # All tasks automatically cancelled
Semaphore-Limited TaskGroup
pythonasync def limited_concurrent(urls, max_concurrent=5): semaphore = asyncio.Semaphore(max_concurrent) async def fetch_limited(url): async with semaphore: return await fetch(url) async with asyncio.TaskGroup() as tg: tasks = [tg.create_task(fetch_limited(url)) for url in urls] return [task.result() for task in tasks]
Producer-Consumer with TaskGroup
pythonasync def producer_consumer(): queue = asyncio.Queue(maxsize=100) async def producer(): for i in range(1000): await queue.put(i) # Signal completion for _ in range(num_consumers): await queue.put(None) async def consumer(): while True: item = await queue.get() if item is None: break await process(item) num_consumers = 5 async with asyncio.TaskGroup() as tg: tg.create_task(producer()) for _ in range(num_consumers): tg.create_task(consumer())
Common Pitfalls
- Launching too many tasks without batching — Creating thousands of tasks at once can exhaust memory and overwhelm external services. Batch work into manageable groups.
- Putting the timeout inside the TaskGroup — The timeout must wrap the TaskGroup, not the other way around. A timeout inside the group does not cancel sibling tasks on expiry.
- Forgetting sentinel values in producer-consumer — Without a signal (like
None) to tell consumers to stop, they will wait forever on the queue and the TaskGroup will never exit.
Best Practices
- Compose patterns together — Combine batching, semaphores, and timeouts as needed. For example, batch by 100, limit to 10 concurrent with a semaphore, and wrap in a 30-second timeout.
- Use
queue.join()for completion tracking — When usingtask_done()andjoin(), you get precise knowledge of when all items have been processed, not just when they have been dequeued.
Summary
- Batch processing with TaskGroup controls memory usage by limiting the number of concurrent tasks per round.
- Wrapping a TaskGroup in
asyncio.timeout()enforces a deadline and automatically cancels all tasks on expiry. - Semaphore-limited TaskGroups cap concurrent access to rate-limited resources.
- Producer-consumer with TaskGroup ensures all producers and consumers have clean lifetimes.
- These patterns compose together for robust, production-ready async workflows.
Code Examples
python
# Retry pattern with TaskGroup
async def fetch_with_fallbacks(primary_url, fallback_urls):
"""Try primary, fall back to alternates."""
async def try_fetch(url):
result = await fetch(url)
if result:
return result
raise FetchError(url)
try:
async with asyncio.TaskGroup() as tg:
task = tg.create_task(try_fetch(primary_url))
return task.result()
except* FetchError:
# Primary failed, try fallbacks
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(try_fetch(url)) for url in fallback_urls]
for task in tasks:
if not task.cancelled() and task.exception() is None:
return task.result()
raise AllFetchesFailed()