When Does an asyncio Task Start? Lazy Coroutines, Eager Tasks, and Task Lifetimes
When Does an asyncio Task Start? Lazy Coroutines, Eager Tasks, and Task Lifetimes
Thesis
async def and await look alike in every language that has them, but they do not behave alike. A 2026 design-space study from Brown University (Gray et al., “A Design Space Exploration of Async/Await”) ran one twelve-line fire-and-forget program on seven runtimes and got four different outputs. The differences come from a handful of independent decisions. When does a spawned task start running? Who keeps it alive? What happens to it when its parent finishes?
asyncio answers each of those questions, but some of the answers are surprising, and one of them changes with configuration. Calling a coroutine function is lazy: nothing runs. asyncio.create_task() is scheduled by default: the task starts on a later turn of the loop, after the caller next suspends. Since 3.12, a loop can use an eager task factory, and since 3.14 a single create_task(..., eager_start=True) call can opt in. With eager start the task’s first step runs inside create_task. On the other end of a task’s life, the loop holds only a weak reference to it, asyncio.run() cancels whatever is still pending when main() returns, and only a TaskGroup waits for its children.
This chapter makes each of those rules visible with a program you can run, measures what eager start buys, and shows the bug it introduces into code that assumed a new task “hasn’t started yet”.
Version note: every listing was run on CPython 3.15.0 (released 2026-10-09) and CPython 3.14.8, both from uv (python-build-standalone, Linux x86-64), plus the system 3.13.5 where noted. Output is identical on 3.14.8 and 3.15.0 except for TaskGroup.cancel(), which is new in 3.15. The timings in Case 4 come from a shared 8-core box, so read the ratios, not the milliseconds.
Mental model
- A coroutine object is inert.
f()builds a coroutine and returns it. No line offruns until something drives it.await corodrives it inline: the caller keeps running the coroutine’s code directly, with no trip through the event loop. That is whyawait other_coroutine()is cheap and never lets another task in unlessother_coroutineitself suspends. - A Task is a scheduled driver.
Task.__init__callsloop.call_soon(self.__step), which puts the first step at the back of the loop’s FIFO ready queue. The task’s first line runs on the next loop iteration, and only once the code that is running now suspends. Tasks created in a row therefore start in creation order. - An eager Task runs its first step immediately. With
eager_start=True(3.14+ throughcreate_task; always available on theTaskconstructor since 3.12) or withasyncio.eager_task_factoryinstalled on the loop (3.12+),Task.__init__runs the first step synchronously, provided the loop is already running. If the coroutine finishes without suspending, the task is done beforecreate_taskreturns and the loop never sees it. If it suspends, the rest of the task is scheduled like any other. - The loop does not own your task.
asynciotracks scheduled tasks in aweakref.WeakSet(_scheduled_tasksinLib/asyncio/tasks.py). While a task is parked on a future that nothing else references, nothing keeps it alive. - Who ends a task’s life. When
main()returns,asyncio.run()(throughRunner.close()→_cancel_all_tasks()inLib/asyncio/runners.py) cancels every task that is still pending. ATaskGroupwaits for all its children before itsasync withblock exits. In the Brown paper’s terms, a bare task has indefinite extent with cancelled destruction, and aTaskGroupchild has dynamic extent with awaited destruction.
Worked examples
Case 1: Calling is not running
# lazy_coro.py
import asyncio
async def greet(who):
print(f" greet({who}) body runs")
return f"hello {who}"
async def main():
coro = greet("coro") # nothing runs here
print("1. called greet(), got", type(coro).__name__)
task = asyncio.create_task(greet("task")) # scheduled, not started
print("2. created task, done =", task.done())
print("3. awaiting the coroutine:", await coro)
print("4. awaiting the task:", await task)
greet("forgotten") # a coroutine that is never awaited
asyncio.run(main())python3.15 -u lazy_coro.py 2>&1Output:
1. called greet(), got coroutine
2. created task, done = False
greet(coro) body runs
3. awaiting the coroutine: hello coro
greet(task) body runs
4. awaiting the task: hello task
/workspace/lab-1018am-C/py/lazy_coro.py:17: RuntimeWarning: coroutine 'greet' was never awaited
greet("forgotten") # a coroutine that is never awaited
RuntimeWarning: Enable tracemalloc to get the object allocation traceback
Read lines 2 and 3 together. The task was created before the coroutine was awaited, yet greet(coro) ran first. await coro ran the coroutine inline and never gave the loop a turn. The task had to wait until main hit await task, which actually suspends. The forgotten greet("forgotten") never ran at all. CPython only noticed when the coroutine object was destroyed, and reported it as a RuntimeWarning, which ends up in a log nobody reads.
Case 2: The fire-and-forget program in asyncio
This is the paper’s example, translated directly. fire_and_forget spawns a log write and returns without awaiting it.
# brown_example.py
# The fire-and-forget program from "A Design Space Exploration of Async/Await"
# (Brown CEL, 2026), written for asyncio. Times scaled down 10x.
import asyncio
import sys
async def write_to_log():
print("A")
await asyncio.sleep(0.2) # simulate a slow log write
print("B")
async def fire_and_forget():
asyncio.create_task(write_to_log()) # spawn, return without awaiting
async def main():
if "--eager" in sys.argv:
asyncio.get_running_loop().set_task_factory(asyncio.eager_task_factory)
await fire_and_forget()
await asyncio.sleep(0.1)
print("C")
asyncio.run(main())
print("asyncio.run returned")python3.15 brown_example.py
python3.15 brown_example.py --eagerOutput:
A
C
asyncio.run returned
A
C
asyncio.run returned
asyncio prints AC, the same as JavaScript and Swift in the paper’s table, and the eager factory does not change that. A appears because main suspends in sleep(0.1), which lets the scheduled task start. B never appears because the log write needs 0.2 s and main is done after 0.1 s. Make the cancellation visible:
# brown_cancelled.py
import asyncio
async def write_to_log():
print("A")
try:
await asyncio.sleep(0.2)
print("B")
except asyncio.CancelledError:
print("B never printed: write_to_log was cancelled at shutdown")
raise
async def fire_and_forget():
asyncio.create_task(write_to_log())
async def main():
await fire_and_forget()
await asyncio.sleep(0.1)
print("C")
asyncio.run(main())python3.15 brown_cancelled.pyOutput:
A
C
B never printed: write_to_log was cancelled at shutdown
The task was not abandoned or silently killed. asyncio.run() cancelled it after main returned, so CancelledError was raised at its await and its handler ran. For a log writer that means the log line is lost, and it happens on every shutdown, not just on rare ones.
Give the task a parent that waits for it, and asyncio behaves like Trio’s nurseries:
# brown_taskgroup.py
import asyncio
async def write_to_log():
print("A")
await asyncio.sleep(0.2)
print("B")
async def fire_and_forget():
async with asyncio.TaskGroup() as tg: # the task cannot outlive this block
tg.create_task(write_to_log())
async def main():
await fire_and_forget()
await asyncio.sleep(0.1)
print("C")
asyncio.run(main())python3.15 brown_taskgroup.pyOutput:
A
B
C
Now fire_and_forget does not return until the log write has finished, so the output is ABC. The two versions differ in two words, but they make opposite promises: one says “this task may outlive me and be cancelled”, the other says “this task finishes before I do”.
Case 3: Creation order versus start order
Two children: t1 finishes without ever suspending (a cache hit), and t2 suspends once. Each is run three ways: the default, the loop-wide eager factory, and the per-call eager_start=True keyword added in 3.14.
# start_order.py
import asyncio
import sys
log = []
async def child(name, blocks):
log.append(f"{name}: start")
if blocks:
await asyncio.sleep(0) # first real suspension point
log.append(f"{name}: end")
return name
async def main(mode):
loop = asyncio.get_running_loop()
if mode == "factory":
loop.set_task_factory(asyncio.eager_task_factory)
kw = {"eager_start": True} if mode == "per-task" else {}
t1 = asyncio.create_task(child("t1", blocks=False), **kw)
log.append(f"main: t1 created, done={t1.done()}")
t2 = asyncio.create_task(child("t2", blocks=True), **kw)
log.append(f"main: t2 created, done={t2.done()}")
await asyncio.gather(t1, t2)
log.append("main: gathered")
for mode in ("lazy", "factory", "per-task"):
log.clear()
asyncio.run(main(mode))
print(f"{sys.version.split()[0]} {mode}:")
for line in log:
print(" ", line)python3.15 start_order.pyOutput:
3.15.0 lazy:
main: t1 created, done=False
main: t2 created, done=False
t1: start
t1: end
t2: start
t2: end
main: gathered
3.15.0 factory:
t1: start
t1: end
main: t1 created, done=True
t2: start
main: t2 created, done=False
t2: end
main: gathered
3.15.0 per-task:
t1: start
t1: end
main: t1 created, done=True
t2: start
main: t2 created, done=False
t2: end
main: gathered
In lazy mode main gets all the way to gather before either child runs, and the children run in creation order. In both eager modes, t1 starts and finishes inside its create_task call, so main sees done=True immediately. t2 runs as far as its first await, returns control to main and is scheduled from there. python3.14 start_order.py prints exactly the same three traces.
The two eager spellings differ only in scope. loop.set_task_factory(asyncio.eager_task_factory) changes every create_task on the loop, including the ones inside libraries you call. eager_start=True changes one call. Under the eager factory a single call can also opt out, because the factory takes eager_start=True as a default keyword that the call can override:
# opt_out.py (Python 3.14+)
import asyncio
async def quick():
return "cached"
async def main():
asyncio.get_running_loop().set_task_factory(asyncio.eager_task_factory)
a = asyncio.create_task(quick()) # factory default: eager
b = asyncio.create_task(quick(), eager_start=False) # this one opts out
print("eager task done at creation:", a.done())
print("opted-out task done at creation:", b.done())
print(await a, await b)
asyncio.run(main())python3.15 opt_out.pyOutput:
eager task done at creation: True
opted-out task done at creation: False
cached cached
The keyword route needs 3.14. On 3.13, eager_task_factory exists (since 3.12) but asyncio.create_task(coro, eager_start=True) raises TypeError: create_task() got an unexpected keyword argument 'eager_start' and leaves the coroutine un-awaited. The 3.14 What’s New entry (gh-128307) is the change that made create_task() pass arbitrary keywords through to the task factory.
Case 4: What eager start buys
The case for eager tasks is in the documentation: a coroutine that returns without blocking, such as one that hits a cache, never has to be scheduled on the loop. The benchmark below fans out 100,000 lookups and varies how many of them are cache hits:
# eager_bench.py
import asyncio
import sys
import time
CACHE = {k: k * k for k in range(1000)} # 1,000 hot keys
async def lookup(key):
if key in CACHE: # cache hit: returns without suspending
return CACHE[key]
await asyncio.sleep(0) # cache miss: pretend to do I/O
CACHE[key] = key * key
return CACHE[key]
async def fan_out(n, hit_ratio):
keys = [i % 1000 if (i % 100) < hit_ratio * 100 else 10_000 + i for i in range(n)]
tasks = [asyncio.create_task(lookup(k)) for k in keys]
return len(await asyncio.gather(*tasks))
async def bench(eager, n, hit_ratio):
if eager:
asyncio.get_running_loop().set_task_factory(asyncio.eager_task_factory)
best = float("inf")
for _ in range(5):
for k in [k for k in CACHE if k >= 1000]:
del CACHE[k] # forget the misses between rounds
t0 = time.perf_counter()
await fan_out(n, hit_ratio)
best = min(best, time.perf_counter() - t0)
return best
print(sys.version.split()[0])
for hit in (1.0, 0.9, 0.5, 0.0):
lazy = asyncio.run(bench(False, 100_000, hit))
eager = asyncio.run(bench(True, 100_000, hit))
print(f"hit ratio {hit:4.0%}: lazy {lazy*1000:6.1f} ms eager {eager*1000:6.1f} ms ({lazy/eager:.1f}x)")python3.14 eager_bench.py
python3.15 eager_bench.pyOutput:
3.14.8
hit ratio 100%: lazy 348.2 ms eager 150.5 ms (2.3x)
hit ratio 90%: lazy 355.1 ms eager 177.9 ms (2.0x)
hit ratio 50%: lazy 434.8 ms eager 293.3 ms (1.5x)
hit ratio 0%: lazy 532.4 ms eager 452.7 ms (1.2x)
3.15.0
hit ratio 100%: lazy 356.8 ms eager 149.5 ms (2.4x)
hit ratio 90%: lazy 352.2 ms eager 175.4 ms (2.0x)
hit ratio 50%: lazy 434.4 ms eager 277.0 ms (1.6x)
hit ratio 0%: lazy 501.6 ms eager 429.1 ms (1.2x)
When every lookup is a hit, eager start is about 2.3–2.4× faster. None of the 100,000 tasks goes through the ready queue, and gather finds them all finished already. The gain shrinks as the hit ratio falls, but it does not reach zero. Even a task that always suspends saves one loop iteration, because its first step (up to the await) runs inside create_task instead of in a scheduled callback. The speedup comes from skipping scheduling, so it is largest for many small tasks that rarely block and negligible for a few tasks that each do real I/O.
Case 5: The task that vanished
The create_task documentation has an “Important” box: “The event loop only keeps weak references to tasks. A task that isn’t referenced elsewhere may get garbage collected at any time, even before it’s done.” Here is that happening:
# vanishing_task.py
import asyncio
import gc
import sys
done = []
async def wait_for_signal():
fut = asyncio.get_running_loop().create_future() # only this frame holds it
try:
await fut # nobody will ever set it
finally:
done.append("finally ran")
async def main(keep):
background = set()
task = asyncio.create_task(wait_for_signal())
if keep:
background.add(task)
task.add_done_callback(background.discard)
del task
await asyncio.sleep(0) # let it start and park on the future
gc.collect() # what a busy program does eventually
await asyncio.sleep(0)
alive = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
print(f"keep={keep}: other tasks alive={len(alive)}, finally ran={done}")
for t in alive:
t.cancel()
done.clear()
asyncio.run(main(keep=False))
asyncio.run(main(keep=True))python3.15 -u vanishing_task.py 2>&1Output:
Task was destroyed but it is pending!
task: <Task pending name='Task-2' coro=<wait_for_signal() done, defined at /workspace/lab-1018am-C/py/vanishing_task.py:9> wait_for=<Future pending cb=[Task.task_wakeup()]>>
keep=False: other tasks alive=0, finally ran=['finally ran']
keep=True: other tasks alive=1, finally ran=[]
With keep=False the task parks on a future that only its own frame references. That makes a reference cycle (task → coroutine → frame → future → wakeup callback → task) which nothing outside points into, so gc.collect() reclaims all of it. The only trace is a log line. The finally block did run, because closing the collected coroutine throws GeneratorExit into it. Nobody awaited anything, nothing raised in main, and the work simply stopped. With keep=True the set holds a strong reference and the task survives. In a real program the “future nobody else holds” is often a lock, an event, or a queue local to the task, and the collection happens whenever the cyclic GC next runs. That is why the bug shows up under load and not in tests. 3.14 and 3.15 behave identically here.
Case 6: The exception nobody retrieves
A task that fails and is never awaited does not crash anything:
# lost_exception.py
import asyncio
import gc
async def flaky():
raise ConnectionError("upstream reset")
async def main():
t = asyncio.create_task(flaky())
await asyncio.sleep(0) # t runs and fails; nobody awaits it
print("main carries on; t.done() =", t.done())
del t
gc.collect()
print("main finished")
asyncio.run(main())python3.15 -u lost_exception.py 2>&1Output:
main carries on; t.done() = True
Task exception was never retrieved
future: <Task finished name='Task-2' coro=<flaky() done, defined at /workspace/lab-1018am-C/py/lost_exception.py:6> exception=ConnectionError('upstream reset')>
Traceback (most recent call last):
File "/workspace/lab-1018am-C/py/lost_exception.py", line 7, in flaky
raise ConnectionError("upstream reset")
ConnectionError: upstream reset
main finished
The exception sits inside the task (never propagated, in the paper’s terms) until the task is destroyed. Then the loop’s exception handler logs “Task exception was never retrieved”. main carried on and finished normally. A TaskGroup makes the opposite choice: a failing child cancels its siblings and the group raises an ExceptionGroup from the async with, as the asyncio and TaskGroups chapter shows.
Case 7: Ending a group early with TaskGroup.cancel() (3.15)
Before 3.15, stopping a TaskGroup once its goal was met took a sentinel exception raised from a child and suppressed outside the group. 3.15 adds TaskGroup.cancel() (gh-127214). It cancels every child that is not done, and the parent body, and the async with exits without raising CancelledError. Here are three mirrors, where the first one to answer wins:
# taskgroup_cancel.py (Python 3.15+: TaskGroup.cancel)
import asyncio
import time
T0 = time.perf_counter()
def ms():
return f"{(time.perf_counter() - T0) * 1000:5.0f} ms"
async def mirror(name, delay, tg, winner):
await asyncio.sleep(delay) # pretend to download from this mirror
winner.append(name)
print(f"{ms()} {name} finished first; cancelling the rest")
tg.cancel() # non-exceptional early exit
async def main():
winner = []
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(mirror(n, d, tg, winner))
for n, d in (("eu", 0.30), ("us", 0.05), ("ap", 0.50))]
print(f"{ms()} group exited without CancelledError; winner={winner}")
print(" states:", [("cancelled" if t.cancelled() else "done") for t in tasks])
asyncio.run(main())python3.15 -u taskgroup_cancel.py
python3.14 -u taskgroup_cancel.py 2>&1 | grep ErrorOutput:
50 ms us finished first; cancelling the rest
50 ms group exited without CancelledError; winner=['us']
states: ['cancelled', 'cancelled', 'cancelled']
| AttributeError: 'TaskGroup' object has no attribute 'cancel'
The group exited the moment us answered, at 50 ms instead of 500 ms. Look at the states, though: the winner is cancelled too. cancel() targets every task in the group that is not done, and the task calling it is still running at that moment. So us is cancelled even though it returns right afterwards, and its return value would be lost. That is why the listing records the winner in a list before calling tg.cancel(), instead of reading task.result() afterwards. On 3.14 the method does not exist (the AttributeError arrives wrapped in an ExceptionGroup).
The trap
Code that registers a task after creating it breaks under eager start. A common pattern is “create the task, then record it so duplicate requests can join it, and let the task remove itself when it finishes”. With scheduled tasks the registration always comes first, because the task cannot start until the caller suspends. Turn on the eager factory, perhaps because a framework or a performance guide recommends it, and a cache hit finishes inside create_task, before the registration line has run:
# eager_trap.py
import asyncio
import sys
CACHE = {"/health": b"ok"}
inflight = {} # path -> task, so duplicates can join
async def fetch(path):
try:
if path in CACHE:
return CACHE[path] # fast path: no await at all
await asyncio.sleep(0.01) # slow path: real I/O
return b"<html>"
finally:
del inflight[path] # "the task is registered by now"
async def handle(path):
task = asyncio.create_task(fetch(path))
inflight[path] = task # register after creation
return await task
async def main():
if "--eager" in sys.argv:
asyncio.get_running_loop().set_task_factory(asyncio.eager_task_factory)
for path in ("/index", "/health"):
try:
print(f"{path}: {await handle(path)!r} inflight={list(inflight)}")
except KeyError as e:
print(f"{path}: KeyError {e} inflight={list(inflight)}")
asyncio.run(main())python3.15 eager_trap.py
python3.15 eager_trap.py --eagerOutput:
/index: b'<html>' inflight=[]
/health: b'ok' inflight=[]
/index: b'<html>' inflight=[]
/health: KeyError '/health' inflight=['/health']
The lazy run is clean. The eager run fails on the cache hit: fetch ran its finally before handle had added the task, so del inflight[path] raised KeyError. Then handle registered a task that was already finished, and nothing will ever remove it, so inflight is left holding a dead entry, which is a slow leak. The slow path works in both modes because it suspends before reaching the finally. This is the “semantic change” the documentation warns about: “If the coroutine returns or raises, the task is finished eagerly and is never scheduled to the event loop… the application’s task execution order is likely to change.” 3.14.8 fails the same way.
The fix is to move the cleanup out of the coroutine and into a done callback. add_done_callback is documented to schedule the callback with loop.call_soon() if the task is already done, so the callback always runs after the code that registered the task:
# eager_fixed.py
import asyncio
import sys
CACHE = {"/health": b"ok"}
inflight = {}
async def fetch(path):
if path in CACHE:
return CACHE[path]
await asyncio.sleep(0.01)
return b"<html>"
async def handle(path):
task = asyncio.create_task(fetch(path))
inflight[path] = task
# A done callback is always scheduled with call_soon, even if the task is
# already finished, so it runs after this function has registered it.
task.add_done_callback(lambda t: inflight.pop(path, None))
return await task
async def main():
if "--eager" in sys.argv:
asyncio.get_running_loop().set_task_factory(asyncio.eager_task_factory)
for path in ("/index", "/health"):
body = await handle(path)
await asyncio.sleep(0) # let the done callback run
print(f"{path}: {body!r} inflight={list(inflight)}")
asyncio.run(main())python3.15 eager_fixed.py
python3.15 eager_fixed.py --eagerOutput:
/index: b'<html>' inflight=[]
/health: b'ok' inflight=[]
/index: b'<html>' inflight=[]
/health: b'ok' inflight=[]
Both modes are now correct. The general lesson: the start order of tasks is a policy of the loop’s task factory, not a property of your code. Code that is only correct when a new task “hasn’t started yet” is depending on that policy.
The boring rule
- Never call a coroutine function without awaiting it or wrapping it in a task. Run tests with
-W error::RuntimeWarningso “coroutine … was never awaited” fails the build. await cororuns inline. Usecreate_taskonly when you want the work to run concurrently with the caller.- Default to
TaskGroupfor any task whose result or failure matters. Use a barecreate_taskonly for genuinely background work, and then keep it in a set withadd_done_callback(set.discard). - Anything that must survive shutdown (log flushes, metrics, final writes) must be awaited before
main()returns, becauseasyncio.run()cancels pending tasks. - Write tasks that are correct whether their first step runs now or later. Do cleanup in done callbacks, not in code that assumes the creator has finished its bookkeeping.
- Turn on
eager_task_factoryfor workloads with many small, often-synchronous tasks (cache hits, memoised lookups), measure the gain, and run the test suite under it first. On 3.14+, prefer per-calleager_start=Trueon the hot path you own. - On 3.15, end a group early with
tg.cancel(). Record the winner’s result before calling it, because the calling task is cancelled too.
Try this
- Add
print(asyncio.current_task().get_name())as the first line ofchildinstart_order.py. Under the eager factory, which task doescurrent_task()report while the first step runs insidecreate_task? - Run
eager_bench.pywithn = 1_000and withn = 1_000_000. Does the speedup ratio depend on the number of tasks, or only on the hit ratio? - In
vanishing_task.py, replace the bare future withasyncio.Event()andawait event.wait(). Is the task still collected? Now keep the event in a module-level variable and try again. - Rewrite
taskgroup_cancel.pyfor 3.14 using the old pattern: raise a private exception from the winner and catch it withexcept*outside the group. Compare how the winner’s result is passed out in each version. - Wrap
eager_trap.py’sinflightin aTaskGroup-based request coalescer that is correct in both modes without a done callback. What does the group’s lifetime have to be?
Sources
- Coroutines and Tasks:
create_task,Task(eager_start=...), eager task factory,TaskGroup.cancel(3.15 documentation; the “Important” weak-reference box and the “semantic change” note on eager tasks) - Event loop:
loop.set_task_factoryandloop.create_task(3.14 change: all kwargs passed on;eager_startworks with eager factories) - Futures:
add_done_callback(already-done futures schedule callbacks withcall_soon) - Runners:
asyncio.run,asyncio.Runner - What’s New in Python 3.14: asyncio (gh-128307, keyword pass-through in
create_task) - What’s New in Python 3.15: asyncio (gh-127214,
TaskGroup.cancel) - What’s New in Python 3.12: asyncio (eager task factory, gh-102853 / gh-104140 / gh-104138: “making some use-cases 2x to 5x faster”)
- CPython 3.15.0 source:
Lib/asyncio/tasks.py(Task.__init__,__eager_start,_scheduled_tasksWeakSet,create_eager_task_factory),Lib/asyncio/runners.py(_cancel_all_tasks) - Gavin Gray et al., A Design Space Exploration of Async/Await (Brown CEL, 2026; the fire-and-forget program and the eagerness/extent/destruction dimensions)