Skip to content

Async & Structured Concurrency

You already write concurrent code: async/await in TypeScript, goroutines and channels in Go. Python’s asyncio is closest to the Node model — a single-threaded event loop running cooperative tasks — but the modern API (3.11+) added structured concurrency (TaskGroup, asyncio.timeout) that finally makes it competitive with Go’s errgroup + context discipline. This module maps every piece onto what you already know.

The single most important thing to internalize: asyncio is not parallelism. It is one OS thread running an event loop that switches between tasks at await points. This is exactly Node’s model. It is not Go’s model.

ConceptTypeScriptGoPython
Unit of concurrencyasync function / Promisegoroutinecoroutine (async def)
Scheduling modelSingle-threaded event loopM:N green threads on OS threadsSingle-threaded event loop
True parallelism?No (one thread)Yes (GOMAXPROCS threads)No (one thread, GIL)
Yields atevery awaitI/O + scheduler preemptionevery await
CPU-bound workblocks the loopruns in parallelblocks the loop
CancellationAbortControllercontext.ContextCancelledError / Task.cancel()
Structured lifecyclenone (floating promises)none built in (errgroup helps)TaskGroup (3.11+)

This trips up everyone coming from JS. A coroutine doesn’t run when you call it — it returns a coroutine object that runs only when awaited or scheduled.

import asyncio
async def greet() -> str:
print("running greet")
return "hi"
async def main() -> None:
coro = greet() # nothing prints — coro is just an object
print("not run yet")
result = await coro # NOW greet() runs, prints "running greet"
print(result)
asyncio.run(main())
# not run yet
# running greet
# hi

In TypeScript, greet() would have executed immediately and returned a Promise. In Python you get a “coroutine was never awaited” RuntimeWarning if you forget — Python’s gentle reminder that you dropped a promise on the floor.

async is contagious, in Python as in TypeScript. To await something, your function must be async; to call an async function meaningfully, you need an event loop. This is the “function coloring” problem, and it’s real:

  • You can’t call an async def from ordinary sync code without asyncio.run() (at the program edge) — the same way you can’t await outside an async function in JS.
  • Mixing a sync library into an async codebase means either finding an async equivalent (requestshttpx) or pushing the blocking call to a thread (see the blocking trap below).

Go has no function coloring: any function can run in a goroutine, and blocking calls yield automatically. That is genuinely nicer. Python’s bet is that explicit await points make the suspension behavior visible — you can see exactly where your code can be interrupted. Decide per project whether your stack is async (FastAPI, Litestar, async SQLAlchemy) or sync (Django classic, Flask); don’t straddle.

asyncio.run(main()) is the one entry point you need. It creates an event loop, runs the coroutine to completion, and tears the loop down. Call it once, at the top of your program — never inside a coroutine.

async function fetchUser(): Promise<string> {
await new Promise((r) => setTimeout(r, 1000));
return "Alice";
}
async function main(): Promise<void> {
const start = Date.now();
const user = await fetchUser();
console.log(`${user} in ${Date.now() - start}ms`);
}
main();

The example above runs one task. To get a speedup you run independent work concurrently. Three tools, in increasing order of preference: gather, then TaskGroup (the modern default).

Here’s the canonical “fetch two things at once” in all three languages:

async function fetchUser(): Promise<string> {
await new Promise((r) => setTimeout(r, 1000));
return "Alice";
}
async function fetchOrder(): Promise<string> {
await new Promise((r) => setTimeout(r, 1000));
return "Order-42";
}
async function main(): Promise<void> {
const start = Date.now();
const [user, order] = await Promise.all([fetchUser(), fetchOrder()]);
console.log(`${user}, ${order} in ${Date.now() - start}ms`);
// Alice, Order-42 in ~1000ms
}
main();

asyncio.gather(*coros) is Python’s Promise.all: it schedules each coroutine as a task, runs them concurrently on the loop, and returns results in input order. Total time is the slowest branch (~1s), not the sum (~2s).

Structured concurrency: TaskGroup is the default

Section titled “Structured concurrency: TaskGroup is the default”

gather works, but it has the same flaw as Promise.all and bare goroutines: leaks and lost errors. If one coroutine raises while the others are still running, gather returns (or raises) but doesn’t necessarily cancel the siblings, and a fire-and-forget asyncio.create_task whose handle you drop becomes the asyncio version of a floating promise — it can vanish with its exception swallowed.

Go developers solved this with errgroup; Python 3.11 added asyncio.TaskGroup, which gives you the same guarantees as a language primitive. Prefer it over gather for anything non-trivial.

// Promise.all — concurrent, but a rejection doesn't cancel the others;
// they keep running with their results discarded.
const [user, order] = await Promise.all([fetchUser(), fetchOrder()]);

What TaskGroup guarantees — the same contract as Go’s errgroup, but enforced by the async with block:

  1. The block does not exit until every task created in it has finished.
  2. If any task raises, all sibling tasks are cancelled, then the block re-raises.
  3. If the surrounding code is cancelled, all tasks in the group are cancelled.
  4. You cannot leak a task — its lifetime is bound to the async with scope.
asyncio.gatherasyncio.TaskGroup (3.11+)
Stylefunctional, returns a result listblock-scoped, structured
Result orderpreserves input orderread each task’s .result()
On first errorsiblings keep running (unless return_exceptions)siblings cancelled
Multiple errorsonly the first surfacesbundled into an ExceptionGroup
Leak safetycan leak if you mix in stray create_taskscope-bound, can’t leak
Use whenquick fan-out of homogeneous work you fully controlthe default for anything real

One more gather mode worth knowing: return_exceptions=True makes it behave like Promise.allSettled — every result comes back, exceptions included as values rather than raised. Useful when you genuinely want partial results and no cancellation.

results = await asyncio.gather(*tasks, return_exceptions=True)
for r in results:
if isinstance(r, Exception):
print("failed:", r)
else:
print("ok:", r)

Because a TaskGroup can have several tasks fail at once, it raises an ExceptionGroup (a 3.11 language feature) bundling all of them. You unpack it with the new except* syntax:

try:
async with asyncio.TaskGroup() as tg:
tg.create_task(might_fail_a())
tg.create_task(might_fail_b())
except* ValueError as eg:
print(f"{len(eg.exceptions)} ValueError(s):", eg.exceptions)
except* ConnectionError as eg:
print(f"{len(eg.exceptions)} connection error(s):", eg.exceptions)

Go flattens to a single first error; Python keeps them all. Each except* block runs for whichever exception types matched, with the rest left in the group.

Cancellation in asyncio is cooperative, just like Go’s context: a cancelled task receives a CancelledError raised at its next await point. Code between awaits runs to completion — cancellation can’t interrupt a tight CPU loop with no suspension points (the asyncio analogue of a goroutine that never checks ctx.Done()).

ConceptTypeScriptGoPython
Cancellation tokenAbortController / AbortSignalcontext.Contextthe Task itself / CancelledError
Request cancellationcontroller.abort()cancel()task.cancel()
Observe cancellationcheck signal.aborted<-ctx.Done()CancelledError raised at await
TimeoutAbortSignal.timeout(ms)context.WithTimeoutasync with asyncio.timeout(s):
Deadline propagationmanual (pass the signal)ctx flows down the call treeimplicit (cancels the awaited task tree)
Cleanup on cancelfinallydefertry/finally (+ shield if needed)

Python 3.11 added asyncio.timeout(), a context manager that’s far cleaner than the old asyncio.wait_for. It cancels whatever is running inside the block when the deadline passes:

// AbortSignal.timeout cancels fetch after 2s
const res = await fetch(url, { signal: AbortSignal.timeout(2000) });

When the deadline fires, asyncio raises CancelledError inside the block to unwind do_work, then converts it to a TimeoutError at the async with. You catch TimeoutError; the inner code sees normal cancellation. There’s also asyncio.timeout_at(loop.time() + 2) for absolute deadlines — the equivalent of context.WithDeadline.

Treat CancelledError like Go’s ctx.Done(): clean up and stop. Don’t swallow it — re-raise so the cancellation propagates.

async def worker() -> None:
try:
while True:
await do_one_unit() # cancellation lands at an await point
except asyncio.CancelledError:
await flush_buffers() # cleanup, like `defer`
raise # ALWAYS re-raise — don't suppress cancellation

For producer/consumer and bounded concurrency, asyncio ships async-aware versions of the primitives you know — all of which suspend rather than block the thread.

asyncio.Queue — producer/consumer (≈ Go channels)

Section titled “asyncio.Queue — producer/consumer (≈ Go channels)”

An asyncio.Queue is the closest thing to a Go channel: producers put, consumers get, both suspend when the queue is full/empty. The differences: a Python queue has no close(), so you signal completion with a sentinel or task_done()/join(); and there’s no select statement (reach for asyncio.wait with FIRST_COMPLETED, or just structure with tasks).

func main() {
jobs := make(chan int, 100)
results := make(chan string, 100)
var wg sync.WaitGroup
for w := 1; w <= 3; w++ { // fan-out: 3 workers
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := range jobs { // range until closed
results <- fmt.Sprintf("worker %d did job %d", id, j)
}
}(w)
}
for j := 1; j <= 9; j++ { jobs <- j }
close(jobs)
go func() { wg.Wait(); close(results) }()
for r := range results { fmt.Println(r) }
}

The shape maps cleanly: jobs.join() + task_done() replaces close(jobs) + range, and cancelling the workers inside the TaskGroup replaces the wg.Wait(); close(results) goroutine. Because the workers are created in the group, the cancellation is clean and nothing leaks.

When you launch N tasks but only want K running at once (rate-limiting an upstream, capping DB connections), use a Semaphore — identical in spirit to Go’s buffered-channel-as-semaphore.

sem := make(chan struct{}, 5) // at most 5 in flight
for _, u := range urls {
go func(u string) {
sem <- struct{}{} // acquire
defer func() { <-sem }() // release
fetch(u)
}(u)
}

All N tasks are scheduled at once, but async with sem: lets only 5 past the gate at a time; the rest suspend (not block) until a permit frees up. This is the exact pattern the Concurrent HTTP Fetcher exercise builds on.

gather vs as_completed — order vs latency

Section titled “gather vs as_completed — order vs latency”

When you want results as they finish rather than in submission order, use asyncio.as_completed. It’s the streaming counterpart to gather:

async def main() -> None:
coros = [fetch(u) for u in urls]
# Yields each result the moment its coroutine completes — fastest first.
for earliest in asyncio.as_completed(coros):
result = await earliest
print("done:", result)
WantUse
All results, input order, fail-fast cancellationTaskGroup
All results, input order, one shotasyncio.gather
All results regardless of failures (allSettled)gather(..., return_exceptions=True)
Results streamed as they completeasyncio.as_completed
First to finish wins (Promise.race)asyncio.wait(..., return_when=FIRST_COMPLETED)

Async context managers & iterators (briefly)

Section titled “Async context managers & iterators (briefly)”

Two pieces of async syntax you’ll use constantly:

  • async with — an async context manager. Same job as with (acquire/release a resource), but __aenter__/__aexit__ can await. You’ve already seen it: TaskGroup, timeout, Semaphore, and every async client (httpx.AsyncClient, DB sessions) are async context managers.
  • async for — iterate an async generator, awaiting each item. This is how you stream rows from a DB cursor or chunks from an HTTP response.
async def tail(path: str): # an async generator
async with aiofiles.open(path) as f:
async for line in f: # await each line
yield line.rstrip()
async def main() -> None:
async for line in tail("app.log"):
print(line)

Streaming with async generators — backpressure, aiostream, queues as pipelines — is the focus of Module 06: Streams & Background Work. Here, just recognize the syntax.

asyncio only gives you the loop and primitives; you need async-aware libraries to get any benefit. The blocking requests library (you’ll see it everywhere) stalls the loop. The modern choice is httpx, whose AsyncClient is a drop-in async HTTP client with a requests-like API.

import asyncio
import httpx
async def fetch_json(client: httpx.AsyncClient, url: str) -> dict:
resp = await client.get(url, timeout=5.0)
resp.raise_for_status()
return resp.json()
async def main() -> None:
# One client, reused — connection pooling, HTTP/2, keep-alive.
async with httpx.AsyncClient() as client:
async with asyncio.TaskGroup() as tg:
tasks = [
tg.create_task(fetch_json(client, f"https://httpbin.org/anything/{i}"))
for i in range(5)
]
for t in tasks:
print(t.result()["url"])
asyncio.run(main())

One thread, one loop: a blocking call freezes the entire service. CPU-bound work (image resizing, a big JSON parse, password hashing with argon2), a sync library with no async version, or time.sleep — any of these blocks every other coroutine for its full duration. There’s no scheduler to preempt it the way Go would.

The escape hatch is to push that work onto a thread (or process) pool so the loop keeps spinning:

// In Go you barely think about this — the scheduler runs the blocking
// call on its own OS thread and keeps other goroutines going.
hash := argon2.IDKey(pw, salt, 1, 64*1024, 4, 32) // just call it

Rules of thumb:

  • I/O that has an async library (HTTP, Postgres, Redis) → use the async library (httpx, asyncpg, redis.asyncio). Don’t thread it.
  • Blocking I/O with no async option (some SDK, a sync DB driver) → asyncio.to_thread(fn, *args). Threads are fine here; the GIL releases during I/O.
  • CPU-bound workloop.run_in_executor(ProcessPoolExecutor(), ...) for true parallelism, because the GIL serializes CPU-bound threads. (More in Module 16.)
PatternTypeScriptGoPython (asyncio)
Mark asyncasync function(any func + go)async def
Await oneawait p<-ch / blocking callawait coro
Entry pointtop-level await / main()func main()asyncio.run(main())
Non-blocking sleepsetTimeout + Promisetime.Sleep (in goroutine)await asyncio.sleep(s)
Run all, orderedPromise.allerrgroup / WaitGroupasyncio.gather
Structured grouperrgroup.WithContextasyncio.TaskGroup
All settledPromise.allSettledper-goroutine error capturegather(..., return_exceptions=True)
First winsPromise.raceselectasyncio.wait(FIRST_COMPLETED)
Stream as doneselect loop on channelsasyncio.as_completed
Channelchan Tasyncio.Queue
Bounded concurrencymanualbuffered channelasyncio.Semaphore
TimeoutAbortSignal.timeoutcontext.WithTimeoutasync with asyncio.timeout(s)
CancelAbortController.abortcancel()task.cancel() / CancelledError
Offload blockingworker thread(automatic)asyncio.to_thread / run_in_executor
Lock— (single thread)sync.Mutexasyncio.Lock (rarely needed)

Put the event loop, TaskGroup, Semaphore, and asyncio.timeout to work in a real, network-bound build.