Tasks, gather & Concurrency
Tasks turn coroutines into concurrent work: `create_task`, `gather` and `TaskGroup` schedule many coroutines on one event loop so their I/O waits overlap — the difference between code that takes the sum of its waits and code that takes the longest.
Learning objectives
- › Distinguish a coroutine from a Task and explain when each makes progress
- › Run work concurrently with `create_task`, `gather` and `TaskGroup`
- › Handle errors, timeouts and cancellation across many tasks
- › Recognise and fix the serial-`await`-in-a-loop anti-pattern
Prerequisites
Version notes
- · `asyncio.create_task` is the modern way to schedule a coroutine (3.7+); older code used `asyncio.ensure_future`/`loop.create_task`.
- · `asyncio.TaskGroup` (3.11+) provides structured concurrency and replaces most manual `gather` + cancellation bookkeeping.
- · The `asyncio.timeout()` context manager arrived in 3.11; on earlier versions use `asyncio.wait_for(coro, timeout)`.
- · `asyncio.to_thread` (3.9+) is a convenient wrapper over `run_in_executor` for offloading blocking calls.
- · Since 3.8, `asyncio.CancelledError` inherits from `BaseException`, so a bare `except Exception` will not accidentally swallow cancellation.
Simple explanation
What it is. A Task is a coroutine the event loop has been told to run concurrently. Awaiting a coroutine directly just runs it in place; wrapping coroutines in Tasks (via `create_task`, `gather`, or a `TaskGroup`) lets many of them make progress while each one waits on I/O.
Why it exists. async/await only buys concurrency if multiple operations are in flight at once. A coroutine you simply `await` one after another is no faster than sequential blocking code — you have to schedule work as Tasks to overlap the waiting.
The problem it solves. Tasks turn a pile of independent I/O operations — API calls, DB queries, file reads — into concurrent work on a single thread, cutting total latency from the sum of the waits down to roughly the longest one.
How it works. `asyncio.create_task(coro)` schedules a coroutine and returns a Task handle you can await, cancel or inspect. `asyncio.gather(*coros)` runs many and collects their results in order. `asyncio.TaskGroup` (3.11+) does the same with structured lifetimes and automatic sibling cancellation on error. Deadlines come from `asyncio.timeout`/`wait_for`, and `task.cancel()` requests cancellation by raising `CancelledError` at the task's next await point.
Analogy: Ordering at a restaurant. `await`ing each call in a loop is ordering one dish, waiting for it to arrive and be eaten, then ordering the next. Tasks are giving the whole table's order at once so the kitchen cooks everything together — still one waiter (one thread), but the waiting overlaps.
Common misconceptions
- "Creating a task runs it immediately to completion." — It only schedules it; the task advances when you `await` something (or otherwise yield to the loop). If the program exits without awaiting it, it may never finish.
- "`gather` runs things in threads or on multiple CPUs." — It is still one thread and one loop; it overlaps *waiting*, not CPU work, so CPU-bound tasks do not speed up.
- "A loop of `await service()` calls is concurrent because it is async." — It is serial; each `await` completes before the next starts. Use `gather`/`TaskGroup` to overlap them.
- "`task.cancel()` kills the task instantly." — It requests cancellation by raising `CancelledError` at the next await point; the task can still run cleanup before it stops.
Common alternatives
- `asyncio.gather` for a fixed set of coroutines when you just want the results
- `asyncio.TaskGroup` (3.11+) for structured concurrency with clean error and cancellation semantics
- `asyncio.as_completed` to process results as each one finishes
- `run_in_executor`/`asyncio.to_thread` to push blocking or CPU-bound work off the loop
Interactive visualization
1async def work(name, secs):2 await asyncio.sleep(secs)3 return name45async def main():6 t1 = asyncio.create_task(work("A", 2))7 t2 = asyncio.create_task(work("B", 1))8 return await asyncio.gather(t1, t2)910asyncio.run(main())
Event loop tasks
Run main() on the loop
asyncio.run starts the event loop with the main coroutine.
Code examples
import asyncio
async def square(n):
await asyncio.sleep(0.01) # pretend this is I/O
return n * n
async def main():
# schedule the coroutines as tasks so they run concurrently
tasks = [asyncio.create_task(square(n)) for n in range(4)]
results = await asyncio.gather(*tasks)
print(results) # order matches submission order
asyncio.run(main())[0, 1, 4, 9]
Note: `create_task` schedules a coroutine on the loop right away; `gather` waits for all of them and returns results in the order the awaitables were passed, not the order they finished.
Behind the scenes
- ▸A coroutine object is inert — it does nothing until it is awaited or wrapped in a Task. `asyncio.create_task` (and `TaskGroup.create_task`) wrap it in a Task and schedule it on the loop immediately, so it can make progress while you await other things.
- ▸`await coro` drives the coroutine inline within the current task; `await task` yields to the loop and lets already-scheduled tasks run. That is precisely why a loop of `await`s is serial while `gather` is concurrent.
- ▸`gather` wraps each argument in a Task, waits for all, and returns results positionally. By default the first exception propagates to the caller (the other tasks keep running unless you cancel them); `return_exceptions=True` returns exceptions in the result list instead.
- ▸`TaskGroup` (3.11) is structured concurrency: the `async with` block does not exit until every child task is done, and if any task raises, the others are cancelled and the errors surface together as an `ExceptionGroup`.
- ▸`task.cancel()` schedules a `CancelledError` to be raised inside the task at its next await point; the task may catch it to clean up but should normally re-raise. `asyncio.timeout`/`wait_for` enforce deadlines using this same cancellation mechanism.
- ▸The loop is single-threaded: any synchronous or CPU-bound call — a tight loop, `time.sleep`, a blocking `requests.get` — freezes every task until it returns. Offload such work with `run_in_executor` or `asyncio.to_thread`.
Interview insights
Key definitions
- · Task: a coroutine scheduled on the event loop to run concurrently, wrapped in a handle you can await, cancel or query.
- · Structured concurrency: a model (`asyncio.TaskGroup`) where child tasks are bound to a block's lifetime — all finish before it exits, and one failure cancels the rest.
What is the difference between awaiting a coroutine directly and creating a Task?
Concise answer: `await coro` runs the coroutine to completion before moving on — sequential. `asyncio.create_task(coro)` schedules it on the loop immediately and returns a handle, so it runs concurrently with whatever you do next until you await its result.
Detailed answer: A coroutine object does nothing on its own. When you `await` it, its steps run within the current task, so a series of awaits is serial. A Task wraps the coroutine and hands it to the loop as an independent unit of work; it advances whenever the current task yields control (at an await). Create several tasks and then await them (or use gather/TaskGroup) to overlap their I/O waits.
Common mistake: Thinking `create_task` runs the coroutine to completion right away. It only schedules it; progress happens at await points.
Follow-ups:
- · What happens to a task you create but never await?
- · How do you await many tasks at once?
How do `gather` and `TaskGroup` differ, especially for error handling?
Concise answer: `gather` collects results positionally; by default the first exception propagates while the other tasks keep running, and `return_exceptions=True` returns errors in the list. `TaskGroup` (3.11+) cancels all siblings when one fails and raises an ExceptionGroup, guaranteeing nothing is left running.
Detailed answer: Use `gather` when you want a simple list of results and are happy to manage failures yourself; note that on error it does not cancel the remaining tasks, which can leak work. `TaskGroup` provides structured concurrency: the block waits for every child, and a failure cancels the rest so you never orphan tasks — the trade-off is that errors arrive grouped as an `ExceptionGroup` that you handle with `except*`.
Common mistake: Assuming `gather` cancels the other coroutines when one raises. It does not unless you pass return_exceptions or cancel them manually.
Follow-ups:
- · How do you catch one branch's error from a TaskGroup?
- · When is return_exceptions=True the right choice?
How do timeouts and cancellation work in asyncio?
Concise answer: `asyncio.timeout(secs)` (3.11+) or `asyncio.wait_for(coro, secs)` cancel the wrapped work if a deadline passes, raising TimeoutError. Cancellation itself works by raising `CancelledError` inside the task at its next await point — via `task.cancel()`.
Detailed answer: Timeouts are built on cancellation: when the deadline fires, the loop cancels the inner task, which raises `CancelledError` at its current await, unwinds any context managers/finally blocks, and then TimeoutError is raised to the caller. Because cancellation surfaces as an exception, a task can catch it to release resources, but it should re-raise so cancellation actually takes effect. Since 3.8 `CancelledError` inherits from `BaseException`, so `except Exception` will not swallow it by accident.
Common mistake: Catching `CancelledError` and not re-raising, which silently defeats cancellation and timeouts.
Follow-ups:
- · What is the difference between asyncio.timeout and wait_for?
- · Why should cleanup code avoid awaiting for a long time during cancellation?
Knowledge check
You have three coroutines that each `await asyncio.sleep(1)`, and you run them with `for c in coros: await c`. Roughly how long does it take?
What does `asyncio.create_task(coro)` do?
With `asyncio.gather(a(), b())` and no `return_exceptions`, `a()` raises. What happens?
Score: 0/3