Scheduling Callbacks with loop.call_soon vs asyncio.create_task¶
Most asyncio code reaches for asyncio.create_task() whenever it wants something to happen "later, on the loop". Often that is right. But a task is a full coroutine driver — it has a result, a cancellation state, a name, a context copy and a slot in the set of live tasks — and a lot of what gets wrapped in a task is a three-line synchronous function that never awaits. For that work loop.call_soon() schedules a plain callback: in a microbenchmark it cost 0.48 µs per scheduled call against 4.6 µs to create, run and gather a no-op task, and the handle it allocates is about half the size. The cost difference is rarely the deciding factor; the semantics are. This guide covers both.
Prerequisites¶
- Python 3.11+, stdlib only. Timings come from Python 3.14 on Linux.
- How the ready queue works — see Task Scheduling & Lifecycle.
- Familiarity with handles, covered in using loop.call_later and timer handles.
1. See that both land on the same queue¶
call_soon(fn, *args) appends a Handle to the loop's ready deque. create_task(coro) creates a Task, and the Task's constructor also calls call_soon — to schedule its own first __step. Both go to the back of the same FIFO, so their relative order is the order you scheduled them in:
import asyncio
async def main() -> None:
loop = asyncio.get_running_loop()
order: list[str] = []
async def coro() -> None:
order.append("task")
loop.call_soon(order.append, "soon-before")
asyncio.create_task(coro())
loop.call_soon(order.append, "soon-after")
await asyncio.sleep(0)
print(order)
asyncio.run(main())
# ['soon-before', 'task', 'soon-after']
The task's body ran between the two callbacks because its first step was queued between them. This only holds for a task's first step: after its first await, each resumption is queued by whatever woke it.
With the eager task factory installed, that changes: the task's first step runs synchronously inside create_task(), before soon-before has a chance to run. Code that relies on create-then-callback ordering breaks when someone turns eager tasks on.
Verify: run the snippet, then add loop.set_task_factory(asyncio.eager_task_factory) and observe task move to the front.
2. Use call_soon for synchronous follow-up work¶
call_soon is the right tool when the work is synchronous, short, and you want it to run after the current step finishes rather than right now. Typical cases: completing a future from inside a protocol callback, notifying listeners after state has been fully updated, or breaking recursion in an event dispatcher.
import asyncio
from collections.abc import Callable
class Emitter:
"""Notify listeners after the current mutation has finished, never mid-update."""
def __init__(self) -> None:
self._listeners: list[Callable[[str], None]] = []
self._loop = asyncio.get_running_loop()
def subscribe(self, fn: Callable[[str], None]) -> None:
self._listeners.append(fn)
def emit(self, event: str) -> None:
for fn in list(self._listeners):
self._loop.call_soon(fn, event) # deferred: listener cannot re-enter emit()
Deferring the listener calls means a listener that triggers another emit() does not recurse into the half-finished loop over listeners; it just appends more callbacks. That is the same reason asyncio itself uses call_soon to run a future's done-callbacks.
Two properties to remember. A callback has no result — there is nothing to await, so if the caller needs to know when it ran, it needs a future. And a callback cannot be awaited for cancellation: handle.cancel() removes it from the queue if it has not run, and does nothing otherwise.
Verify: a listener that calls emit() again should see its events delivered on the next loop iteration, not nested inside the current call.
3. Use create_task when the work awaits or must be supervised¶
Anything that awaits needs a task. So does anything whose outcome someone must observe — a result, an exception, a completion time — or that must be cancellable mid-flight:
import asyncio
background: set[asyncio.Task] = set()
def start_refresh(key: str) -> asyncio.Task:
task = asyncio.create_task(refresh(key), name=f"refresh:{key}")
background.add(task) # strong reference until done
task.add_done_callback(background.discard)
return task
async def refresh(key: str) -> None:
async with asyncio.timeout(5):
data = await fetch(key)
await store(key, data)
The strong reference matters: the loop only keeps a weak reference to tasks, as covered in preventing task garbage collection. A Handle from call_soon has no such problem — the ready queue holds it strongly until it runs.
Verify: asyncio.all_tasks() lists the task by name while it is running, and the background set is empty after it completes.
4. Know where exceptions go¶
The two mechanisms report failures very differently, and this is where most bugs come from.
An exception raised by a call_soon callback is caught by the loop and passed straight to the loop's exception handler, which by default logs it at ERROR as Exception in callback … with a full traceback. Nobody else ever sees it, and the loop keeps running.
An exception raised inside a task is stored on the task. It is only reported if nobody retrieves it — and then only when the task object is garbage collected, as Task exception was never retrieved. With a strong reference held in a long-lived set, that message may never appear at all:
def _log_failure(task: asyncio.Task) -> None:
if task.cancelled():
return
if (exc := task.exception()) is not None:
log.error("background task %s failed", task.get_name(), exc_info=exc)
task = asyncio.create_task(refresh(key))
task.add_done_callback(_log_failure) # report immediately, not at GC time
If you route all loop-level errors to one place by installing a custom exception handler, callback failures arrive there automatically; task failures only arrive if a done-callback or the GC path sends them.
Verify: raise inside a call_soon callback and inside a background task; confirm both failures appear in logs within the same loop iteration.
5. Respect the thread-safety rule¶
Neither call_soon nor create_task is thread-safe. Calling them from another thread can corrupt the ready queue or, more commonly, schedule work that does not run until something else happens to wake the loop — the selector is sleeping and nobody wrote to its self-pipe. From other threads, use the _threadsafe variants:
import asyncio
import threading
def worker(loop: asyncio.AbstractEventLoop, results: asyncio.Queue) -> None:
value = compute() # in a plain thread
loop.call_soon_threadsafe(results.put_nowait, value) # wakes the loop
fut = asyncio.run_coroutine_threadsafe(store(value), loop)
fut.result(timeout=5) # concurrent.futures.Future
async def main() -> None:
loop = asyncio.get_running_loop()
results: asyncio.Queue = asyncio.Queue()
threading.Thread(target=worker, args=(loop, results), daemon=True).start()
print(await results.get())
call_soon_threadsafe is call_soon plus a write to the loop's self-pipe so the selector wakes immediately. run_coroutine_threadsafe is the thread-safe create_task, returning a concurrent.futures.Future the thread can block on. The full pattern is in sending results from threads to an asyncio queue.
Verify: in debug mode, calling call_soon from a non-loop thread raises RuntimeError: Non-thread-safe operation invoked on an event loop other than the current one.
Verification¶
You have the right split when:
- Every
create_taskresult is held somewhere — a set, a TaskGroup, or an attribute — and failures are reported by a done-callback. - Synchronous one-shot work uses
call_soon, and none of it blocks for more than a fraction of a millisecond. asyncio.all_tasks()is not cluttered with thousands of trivial tasks that never awaited.- Debug mode is clean: no
Non-thread-safe operationerrors and noExecuting <Handle …> tookwarnings.
Diagnostic Hook: sample len(asyncio.all_tasks()) alongside the loop's ready-queue length (len(loop._ready) in a debug build or a test) every few seconds. A task count that tracks request rate rather than concurrency usually means trivial work is being wrapped in tasks; a ready queue that keeps growing means callbacks are being scheduled faster than the loop can drain them.
Pitfalls & edge cases¶
- Blocking inside a callback. A
call_sooncallback runs on the loop thread like any task step; a 50 ms callback is a 50 ms stall. - Rescheduling yourself forever. A callback that calls
call_soon(self)every time never lets the selector sleep and pins a core at 100%. Usecall_laterwith a real delay. - Losing context. Both capture the current
contextvarscontext by default; passcontext=explicitly if the callback should run in a different one. - Relying on first-step ordering. It changes under eager tasks and under different loop implementations; if order matters, make it explicit with a future or an event.
- Cancelling a handle that already ran.
handle.cancel()returns silently; checkhandle.cancelled()if you need to know.
Frequently Asked Questions¶
What is the difference between loop.call_soon and asyncio.create_task?
call_soon schedules a plain synchronous callback to run once on the next loop iteration; it has no result and cannot await. create_task wraps a coroutine in a Task that can await, be cancelled mid-flight, and stores its result or exception. Both use the same ready queue.
Is call_soon faster than create_task?
Yes — about 0.48 µs per scheduled callback against 4.6 µs to create and run a no-op task on Python 3.14. The difference only matters in very hot paths; choose by semantics first.
Is loop.call_soon thread-safe?
No. From another thread use loop.call_soon_threadsafe, which also wakes the selector, or asyncio.run_coroutine_threadsafe for coroutines.
What happens if a call_soon callback raises?
The loop catches it and passes it to the loop's exception handler, which logs "Exception in callback" with a traceback by default. The loop keeps running and nothing else is notified.
Does create_task run the coroutine immediately?
Not by default: it schedules the first step on the ready queue, so the coroutine starts at the next loop iteration. With asyncio.eager_task_factory (Python 3.12+) it runs synchronously until its first await.
Related¶
- Task Scheduling & Lifecycle — up to the topic overview.
- Understanding create_task vs ensure_future — the other common scheduling confusion.
- Asyncio Fundamentals & Event Loop Architecture — the section overview.