Skip to content
← All articles

Eager Tasks and asyncio Performance

The eager task factory runs a coroutine synchronously during create_task and only schedules it if it actually blocks — 2x to 5x on the right workload, and a real ordering change on the wrong one.

There are not many one-line changes that measurably speed up an async service. This is one of them — and it is also one that can quietly change the ordering your code depends on, so it is worth understanding rather than pasting.

What eager tasks do

Normally, asyncio.create_task(coro) schedules the coroutine and returns immediately. Not one line of the coroutine body has run. It starts on the next cycle of the event loop.

With the eager task factory (3.12):

loop = asyncio.get_running_loop()
loop.set_task_factory(asyncio.eager_task_factory)

create_task runs the coroutine body synchronously, during task construction, up to its first genuine suspension. If it never suspends — a cache hit, a validation that fails immediately, a fast path that returns without I/O — it finishes right there, and no task is ever scheduled at all.

The documentation reports “making some use-cases 2x to 5x faster”. The win is not magic; it is the elimination of scheduling overhead for coroutines that did not need scheduling. In a service where a large fraction of async calls hit a cache, that fraction stops paying for task creation, ready-queue insertion, a loop iteration, and task destruction.

There is a per-task opt-in too: TaskGroup.create_task(coro, eager_start=True), which is the safer way in — you get the win exactly where you have reasoned about it.

The behaviour change

The coroutine body now runs before create_task returns.

Everything that follows is a consequence:

log.append("before")
task = asyncio.create_task(worker())      # worker() appends "worker"
log.append("after")

Lazily: ["before", "after", "worker"]. Eagerly: ["before", "worker", "after"] — assuming worker runs to a suspension point or to completion.

That difference is invisible in most code and fatal in a little of it:

  • A task whose body reads state the caller mutates on the next line now reads the old value.
  • A task that acquires a lock the caller is holding now deadlocks immediately instead of after the caller releases it.
  • Tests that assert on ordering start failing — which is the good outcome, because it means the ordering was load-bearing and undocumented.
  • task.cancel() immediately after create_task no longer prevents the body from having run. Eagerly, the first chunk already happened.
💡If eager start is faster and mostly invisible, why is it not the default? click to reveal

Because “mostly invisible” is not a property you can impose on every existing program at once, and because the change is genuinely observable in the two cases the runtime cannot detect for you: ordering and re-entrancy.

Re-entrancy is the sharper of the two. Lazily, create_task is guaranteed not to run user code — so calling it while holding a lock, or half-way through mutating a data structure, is safe. Eagerly, create_task may run arbitrary code, including code that touches the very structure you are in the middle of updating, before it returns. Every create_task call site becomes a potential re-entrancy point.

That is why the opt-in is per-loop or per-task rather than a version bump. It is also why TaskGroup.create_task(..., eager_start=True) is the shape to prefer: it makes the decision local to a call site you have thought about, rather than global to a codebase you have not read all of.

The rest of the 3.12–3.14 performance story

Eager tasks were not the only change worth knowing:

  • 3.12 also removed a copy from socket writes, and gave asyncio.current_task() a C implementation — 4 to 6 times faster. That matters because current_task() is on the hot path of every contextvar-aware logging call.
  • 3.14 rewrote task bookkeeping: tasks live on a per-thread linked list, and the current task is stored on the thread state. Partly a straight speedup, partly preparation for free-threaded builds, where a global task registry would be a contention point. It did not change the ownership contract — the loop still holds only weak references, and gh-91887 is still open.
💡How would you decide whether the eager task factory is worth turning on for your service? click to reveal

Measure the fraction of your tasks that complete without suspending, and treat that as the ceiling on the win.

Concretely: before switching, instrument create_task call sites — or sample with a profiler — and ask how many of those coroutines hit a fast path (cache hit, early validation failure, a value already in memory) versus how many actually await I/O. A service where 5% of tasks are trivially satisfiable will not notice; one where 60% are will.

Then turn it on in a load test, not in production, and look at two things: p50 latency (which should improve) and your ordering-sensitive tests (which should still pass). If any test starts failing, you have found real coupling and you should fix that regardless of whether you keep the factory.

And if you want the win without the global risk, use TaskGroup.create_task(..., eager_start=True) at the specific call sites your measurement identified. That is the version you can defend in review.