Using the Eager Task Factory in Python 3.12+¶
A request fans out into 10,000 lookups with TaskGroup, and 95% of them are answered from an in-process cache without ever awaiting anything. Yet each lookup still pays the full price of a task: allocation, a trip through the loop's ready queue, a context switch into the coroutine, and a completion callback — only to return a value that was sitting in a dictionary the whole time. Python 3.12 added asyncio.eager_task_factory for exactly this shape of workload. An eager task starts running its coroutine synchronously inside create_task(); if the coroutine finishes without suspending, the task comes back already done and never touches the scheduler. This guide measures whether that helps your code, installs the factory safely, and audits the ordering assumptions that eager execution silently changes.
Prerequisites¶
- Python 3.12+.
asyncio.eager_task_factoryandasyncio.create_eager_task_factory()do not exist in 3.11. - Task lifecycle knowledge from Task Scheduling & Lifecycle, in particular what create_task does compared with ensure_future.
- A fan-out with a synchronous fast path — cache hits, memoised results, validation that rejects early. Without one, eager tasks offer little.
- Standard library only; every snippet runs as-is under
asyncio.run().
1. Measure the scheduling overhead you are paying¶
Before changing how every task in the process starts, confirm that task scheduling is a measurable share of the work. Build a benchmark that mirrors your fan-out and varies the fraction of calls that return without awaiting.
import asyncio
import time
CACHE: dict[int, bytes] = {}
async def load(key: int) -> bytes:
if (hit := CACHE.get(key)) is not None:
return hit # no await on the hot path
await asyncio.sleep(0) # stand-in for a real fetch
CACHE[key] = value = str(key).encode()
return value
async def fan_out(keys: range) -> int:
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(load(k)) for k in keys]
return sum(len(t.result()) for t in tasks)
async def bench(label: str, rounds: int = 20, width: int = 10_000,
hit_ratio: float = 0.95) -> float:
CACHE.clear()
for k in range(int(width * hit_ratio)):
CACHE[k] = str(k).encode()
start = time.perf_counter()
for _ in range(rounds):
await fan_out(range(width))
for k in range(int(width * hit_ratio), width):
CACHE.pop(k, None) # keep the miss share constant per round
elapsed = time.perf_counter() - start
print(f"{label:>6}: {elapsed / rounds * 1000:7.2f} ms per 10k-task fan-out")
return elapsed
Verify: run bench("lazy") at hit ratios of 0%, 50% and 95%. The per-fan-out time should fall as the hit ratio rises, but not by as much as you might expect — the part that does not fall is the per-task scheduling cost that eager execution can remove.
2. Install the factory for the whole loop¶
The factory is a loop-level setting: every create_task() call — including those made inside TaskGroup and gather — goes through it. Install it once, as early as possible, so the whole process runs under one set of semantics.
import asyncio
async def main() -> None:
loop = asyncio.get_running_loop()
loop.set_task_factory(asyncio.eager_task_factory)
await serve()
def loop_with_eager_tasks() -> asyncio.AbstractEventLoop:
loop = asyncio.new_event_loop()
loop.set_task_factory(asyncio.eager_task_factory)
return loop
# Either install inside main() ...
# asyncio.run(main())
# ... or bake it into the runner so no code runs before it is active.
# with asyncio.Runner(loop_factory=loop_with_eager_tasks) as runner:
# runner.run(serve())
Setting the factory in the runner's loop_factory is preferable in services: the main task itself then starts eagerly, and there is no window in which early startup tasks run under the old behaviour.
Verify: re-run the benchmark with the factory installed and compare against the lazy numbers from step 1.
The chart comes from one run of this exact benchmark on Python 3.14; the absolute milliseconds depend on hardware, but the shape is consistent — the more calls that complete without awaiting, the larger the saving. Even with zero cache hits there is a small gain, because every task runs its first segment without an extra loop iteration.
3. Confirm that fast paths complete synchronously¶
Eager execution is only a win if the tasks you care about really do finish inside create_task(). Check it directly rather than inferring it from timings.
import asyncio
async def main() -> None:
asyncio.get_running_loop().set_task_factory(asyncio.eager_task_factory)
CACHE[1] = b"cached"
hit = asyncio.create_task(load(1))
miss = asyncio.create_task(load(2))
print("hit done immediately:", hit.done()) # True — never scheduled
print("miss done immediately:", miss.done()) # False — suspended at its await
print("hit result:", hit.result())
await miss
asyncio.run(main())
A coroutine that suspends even once — an await asyncio.sleep(0), a lock acquisition that has to wait, a logging handler that awaits — becomes an ordinary scheduled task from that point on. Eager tasks do not make awaiting faster; they skip the scheduler only for the part before the first real suspension.
Verify: hit.done() is True straight after create_task(). If it is False, something on the "fast" path awaits; find it with a breakpoint or by temporarily raising inside the awaited call.
4. Audit code that depends on scheduling order¶
Lazy tasks give a guarantee most code relies on without realising: the coroutine does not start until the creator yields. Eager tasks remove it. The code after create_task() now runs after the coroutine's first segment, which changes three things.
import asyncio
events: list[str] = []
async def worker(name: str) -> None:
events.append(f"{name} started") # runs inside create_task() when eager
await asyncio.sleep(0)
events.append(f"{name} finished")
async def creator() -> None:
task = asyncio.create_task(worker("w1"))
events.append("creator after create_task")
await task
async def compare() -> None:
loop = asyncio.get_running_loop()
for factory in (None, asyncio.eager_task_factory):
events.clear()
loop.set_task_factory(factory)
await creator()
print("eager" if factory else "lazy ", events)
asyncio.run(compare())
# lazy ['creator after create_task', 'w1 started', 'w1 finished']
# eager ['w1 started', 'creator after create_task', 'w1 finished']
Search for these patterns before enabling the factory:
- Registration after creation.
task = create_task(...)followed byregistry[task_id] = task— if the coroutine looks itself up in the registry during its first segment, it no longer finds itself. - Side effects the creator expects to precede the task. Setting a flag, opening a span, or putting an item on a queue after creating the consumer task means the consumer's first segment runs before that state exists.
- Exceptions raised synchronously. A coroutine that fails before its first await produces a task that is already done with an exception; the error is still delivered on
await task, but code that attachesadd_done_callbackexpecting a pending task sees the callback scheduled immediately.
Verify: run your test suite with the factory installed. Tests that assert on event ordering, log order, or mock call order are the ones that will change; each failure is either a test that over-specifies ordering or production code that depends on it.
5. Scope eager execution when a global switch is too broad¶
If some subsystem cannot tolerate the ordering change, you do not have to choose between everything and nothing. asyncio.create_eager_task_factory() wraps a custom task class, and you can construct eager tasks explicitly at the call sites that benefit.
import asyncio
async def eager_fan_out(keys: list[int]) -> list[bytes]:
loop = asyncio.get_running_loop()
tasks = [asyncio.Task(load(k), loop=loop, eager_start=True) for k in keys]
return await asyncio.gather(*tasks)
# A factory that keeps a custom Task subclass (for tracing, naming, metrics):
class TracedTask(asyncio.Task):
pass
traced_eager_factory = asyncio.create_eager_task_factory(TracedTask)
The eager_start keyword on the Task constructor was added in 3.12 together with the factory. Scoping it to one hot fan-out keeps the rest of the program on lazy semantics.
Verify: the scoped fan-out shows the same speed-up as the global factory in the benchmark, while the subsystem that failed the ordering audit keeps passing its tests.
Verification¶
Eager tasks are working as intended when:
- The benchmark improves in proportion to the share of calls that complete without suspending, and does not regress at a 0% hit ratio.
- Fast paths finish inside
create_task():task.done()isTrueimmediately for cache hits. - The test suite passes under the factory, with any ordering-sensitive tests either fixed or deliberately scoped out.
- Loop lag does not rise. A long synchronous first segment now runs inside the creator's step; watch the event loop lag metric after rollout to confirm no single step got longer.
- Task tracking still works:
asyncio.all_tasks()and any task-naming conventions behave the same in your diagnostics tooling.
Pitfalls & edge cases¶
- Long synchronous first segments. An eager task that does 50 ms of CPU work before its first await now blocks the creator for 50 ms as well. Eager execution moves work earlier; it does not make CPU work cheaper.
- Deep eager recursion. A task that eagerly creates a task that eagerly creates another nests those first segments on the same C stack. Very deep recursive fan-outs can hit the recursion limit where lazy tasks would not.
- Reference-holding patterns still apply. A task that suspends is scheduled normally from then on, and the loop still keeps only a weak reference. Keep strong references to background tasks exactly as before.
- contextvars set by the creator after creation. The task copies the current context when it is created. Values the creator sets afterwards were never visible to lazy tasks either, but eager tasks now also run before those values exist, which exposes latent bugs.
- Third-party libraries that assume lazy start. Some frameworks create a task and then configure it. Enable the factory in staging with the full dependency set before production.
Frequently Asked Questions¶
What does asyncio.eager_task_factory do?
It makes every task created on the loop start executing its coroutine immediately inside create_task, instead of scheduling it to run on a later loop iteration. If the coroutine returns or raises before its first suspension, the task is returned already finished and never enters the event loop's ready queue, which removes scheduling overhead for synchronous fast paths.
When is the eager task factory faster?
It helps most when many tasks complete without awaiting anything, such as cache hits, memoised lookups or early validation failures in a large fan-out. It gives a small gain even when every task awaits, because the first segment runs without an extra loop iteration, but it cannot speed up time spent actually waiting on I/O.
Can eager tasks break existing asyncio code?
Yes, when code depends on a new task not starting until the creator yields. With eager tasks, the coroutine's first segment runs before the statement after create_task, so registrations, flags or queue items set after creating the task are not yet visible to it. Run the test suite with the factory installed and audit ordering-sensitive code first.
How do I enable eager tasks only for specific call sites?
Construct the task directly with asyncio.Task(coro, loop=loop, eager_start=True), available since Python 3.12, and gather or await those tasks as usual. The rest of the program keeps the default lazy scheduling. For a custom Task subclass used loop-wide, wrap it with asyncio.create_eager_task_factory.
Related¶
- Task Scheduling & Lifecycle — up to the topic overview for task states, the ready queue and cancellation.
- Preventing task garbage collection with strong references — the reference rules that still apply once an eager task suspends.
- Asyncio Fundamentals & Event Loop Architecture — the section overview explaining how the loop runs callbacks and tasks.