async, await, gather, go
Typhon’s async story is explicit, not inferred. A function is async because you said so. The keywords:
async def/await— Python’s standard coroutine machinery.gather:— parallel awaits (lowers toTaskGroup).go— fire-and-forget spawn (lowers totyphon_runtime.tasks.spawn).
async def and await
async def fetch(url: str) -> bytes: body = await http.get(url) return bodyStatic checks:
- A function declared
asyncwith noawaitistyc::async_without_await(warning). - A sync function calling an
async defwithoutawaitistyc::missing_await(hard error).
gather:
A block that awaits multiple expressions in parallel:
async def f(uid: int) -> Dashboard: gather: user = fetch_user(uid) posts = fetch_posts(uid) notifs = fetch_notifs(uid) return Dashboard(user=user, posts=posts, notifs=notifs)async with asyncio.TaskGroup() as _tg: _t_user = _tg.create_task(fetch_user(uid)) _t_posts = _tg.create_task(fetch_posts(uid)) _t_notifs = _tg.create_task(fetch_notifs(uid))user = _t_user.result()posts = _t_posts.result()notifs = _t_notifs.result()Bindings inside gather: are single-assignment (no let/mut required — desugarer wraps the whole block as a fresh scope).
Lowering
TaskGroup is the safe default: any task’s failure cancels the siblings.
Best-effort variant
gather(strategy="best-effort"): user = fetch_user(uid) posts = fetch_posts(uid)Lowers to asyncio.gather(..., return_exceptions=True). Each binding’s type becomes T | BaseException — the checker reflects that.
Automatic gather (opt-in)
[strictness]auto-gather = trueWhen on, the analyser rewrites straight-line runs of independent name = await callee(...) statements into a TaskGroup, but only when:
- Every callee is an
async defcarrying the@gatherabledecorator. - LHS bindings don’t alias.
- The statements are contiguous and independent.
@gatherableasync def fetch_user(uid: int) -> User: ...
@gatherableasync def fetch_posts(uid: int) -> list[Post]: ...
async def load(uid: int) -> tuple[User, list[Post]]: let user = await fetch_user(uid) # rewritten… let posts = await fetch_posts(uid) # …into a TaskGroup return user, postsWithout @gatherable, the rewrite doesn’t fire. There’s no diagnostic when callees are undecorated; you simply get sequential execution.
gather_opportunity advice (on by default)
Independent of auto-gather, tyc check and tyc build surface a tyc::gather_opportunity advice (Hint severity, on by default) wherever 2+ adjacent independent awaited calls appear inside an async def, suggesting you wrap them in an explicit gather: block so they run concurrently in an asyncio.TaskGroup:
async def load(users: UserApi, posts_api: PostApi, uid: int) -> tuple[User, list[Post]]: let user = await users.get(uid) # 💡 these two awaits let posts = await posts_api.for_user(uid) # could run concurrently return (user, posts)Unlike auto-gather, this advice is callee-agnostic: it fires for awaited method calls on imported clients (await client.get_user(...)) — the shape auto-gather never touches — because the suggested fix, an explicit gather: block, works for any awaitable with no decorator. Independence is decided by static data flow: a run breaks the moment a later await references a name bound earlier (including via the callee’s receiver b = await a.next(), keyword args, comprehensions, walrus, slices, or f-strings), and a single non-matching statement between two awaits ends the run. It never rewrites — concurrency is a behaviour change you opt into — and being advice-level it never blocks a build.
When auto-gather is also on, runs it folds into a TaskGroup are gone before this pass runs, so they aren’t double-reported.
Knob:
[strictness]suggest-gather = true # defaulttyc explain gather_opportunity documents it offline.
go
Fire-and-forget spawn:
go send_welcome(user)Lowers to:
typhon_runtime.tasks.spawn(send_welcome(user))The runtime helper holds a strong reference to the task and clears it from a done-callback. This prevents the common Python footgun where a bare asyncio.create_task(...) whose handle is dropped can be garbage-collected mid-flight (the event loop holds only weak references).
Capturing the handle
go send_welcome(user) -> email_task# later:await email_taskThe -> handle form binds the spawn result so you can join later.
The spawned call must produce a coroutine. go on a function the checker
knows to be synchronous (a plain def, a sync alias, a callable parameter
typed as returning a non-awaitable, or a plain undecorated def imported by
name from another project .ty module) is rejected with tyc::type_mismatch
(“go needs a coroutine, but work() returns None”) — spawn would raise
TypeError on it at runtime.
Free-threaded variant
go always lowers through typhon_runtime.tasks.spawn (an asyncio task held in a strong-ref registry) — there is no ThreadPoolExecutor lowering for go, including when [python] free-threaded = true.
Free-threaded parallelism
Independent of async/await, [python] free-threaded = true unlocks build-time parallelism rewrites and two advice lints — everything below requires a 3.13t / 3.14t / 3.15t target (see [python]).
Auto-parallel comprehensions
[strictness]auto-parallel = trueA list / set / dict comprehension whose element is a pure call is rewritten to typhon_runtime.parallel.map_pure(lambda x: <elt>, <source>):
ys: list[int] = [transform(x) for x in xs]ys: list[int] = typhon_runtime.parallel.map_pure(lambda x: transform(x), xs)Eligible shapes: the baseline [f(x) for x in xs]; a filtered [f(x) for x in xs if COND] (a pure COND runs sequentially over the source, the element map runs in parallel); extra call arguments that are literals or let-bound loop invariants ([f(x, k) for x in xs] — a mut-bound name is never captured); and nested pure calls ([g(f(x)) for x in xs]). Every widening is semantics-preserving because the element, its captured arguments, and any filter are all side-effect-free. [strictness] parallel-min-size (default 64) suppresses the rewrite below the threshold when the iterable size is statically known.
Auto-parallel integer reductions
[strictness]auto-parallel = trueauto-parallel-reductions = trueA canonical accumulation loop with a plain int accumulator folds into a parallel reduction:
mut total: int = 0for x in xs: total += square(x)total: int = 0total += sum(typhon_runtime.parallel.map_pure(lambda x: square(x), xs))Integer addition is associative and commutative and Python ints are exact, so summing partial results in any order is identical to the sequential loop. float accumulators are never eligible — reordering IEEE-754 addition changes the result, so a mut total: float loop is left alone (and surfaces tyc::parallel_opportunity explaining why). The iterable must also be provably bounded and effect-free to materialise — a list/tuple/set literal, a name annotated list[...] / tuple[...] / set[...] / frozenset[...] in the loop’s scope, or a builtin range(...) call (parallelising a range loop materialises it — an inherent cost of the map-based design) — because map_pure runs list(ITER) before evaluating any element; a call result or unannotated name could be an unbounded or stateful iterator, where materialising diverges from the sequential loop, so those never rewrite.
Execution backend
[strictness]parallel-backend = "threads" # or "interpreters"map_pure runs on a ThreadPoolExecutor by default (order-preserving; escapes the GIL on a free-threaded build, serialises but stays correct on stock CPython). parallel-backend = "interpreters" first tries a PEP 734 InterpreterPoolExecutor (Python 3.14+), falling back transparently to the thread pool on ImportError / AttributeError (older runtimes) or when the mapped function can’t be pickled across the interpreter boundary (probed with pickle.dumps before any pool is created — a lambda or closure never pickles, so the fallback fires whatever exception pickling raises, while exceptions raised by the mapped function still propagate normally). Order is preserved on every path. Since the auto-parallel rewrites emit lambdas, rewritten call sites always run on the thread pool under this backend today; the interpreters pool benefits hand-written map_pure calls passing top-level named functions.
Parallelism advice lints
Two advice-level lints, both gated by [strictness] suggest-parallel (default true) and both silent unless free-threaded = true:
tyc::parallel_opportunitynudges a comprehension or integer accumulator loop that would be rewritten if the relevant knob were on — or afloataccumulator loop that matches every reduction condition except the requiredintannotation.tyc::shared_mut_across_tasksflags ago-spawned same-module function that writes module-level mutable state (aglobalassignment or a write to a module-levelmutbinding) — under free-threaded Python the spawned task runs concurrently with the spawner, so an unguarded write is a data race.
# [python] free-threaded = truemut hits: int = 0
async def record() -> None: global hits hits = hits + 1 # 💡 tyc::shared_mut_across_tasks
async def serve() -> None: go record()Neither lint rewrites anything — parallel execution and shared-state guarding are both behaviour changes the author opts into. Set suggest-parallel = false to silence both project-wide.
Lifecycle
asyncio.run(main()) is the standard entry point. Typhon’s primitives compose normally:
import asyncio
async def main_async(argv: list[str]) -> int: gather: a = fetch_a() b = fetch_b() go background_task() return 0
def main() -> None: sys.exit(asyncio.run(main_async(sys.argv)))import asyncio is auto-injected
The desugar pass inserts import asyncio when any gather: or go lowering needs it. You don’t have to add the import yourself (though it’s harmless to).
Common mistakes
Forgetting await
async def main() -> None: let body: str = fetch("...") # ❌ coroutine, not strtyc::missing_await. Add await.
async with no await
async def helper() -> int: return 42tyc::async_without_await (warning). Drop async or await something.
asyncio.create_task for fire-and-forget
asyncio.create_task(send_welcome(user)) # ⚠️ task may be GC'dUse go send_welcome(user).
Blocking I/O inside async
async def load() -> bytes: with open("data.bin", "rb") as f: return f.read() # blocks the event loopTyphon doesn’t catch this (Python-wide hazard). Use aiofiles or asyncio.to_thread(...).
Where next
- Async and Concurrency (tour) — teaching page.
gather:→ TaskGroup (lowering) — exactly what is emitted.go→ spawn registry (lowering) — strong-ref details.- Concurrent Data Fetching (recipe) — worked pattern.
[strictness]— everyauto-parallel*/suggest-*knob referenced above.