How do you schedule a callback onto a running asyncio event loop from another thread?
answer
- The loop is not thread-safe by design
- Only two calls cross the thread boundary
- Appending is not enough; the loop sleeps
- A self-pipe byte wakes the selector
- Handles come back so you can cancel
basics
~10 sUse the loop's call_soon_threadsafe() — one of the few loop methods legal from another thread. It queues the callback and wakes the loop from its selector poll; plain call_soon() and call_later() are thread-affine.
solid answer
~50 sAn asyncio event loop is not thread-safe: nearly every method must be called from its own thread. There, `asyncio.AbstractEventLoop.call_soon(cb, *args)` queues a plain callback for the next iteration and `call_later(delay, cb, *args)` queues it against the loop's monotonic clock, returning a `Handle` and a `TimerHandle` you can `cancel()`. From any other thread the only legal entry points are `call_soon_threadsafe()` for a plain callback and `asyncio.run_coroutine_threadsafe(coro, loop)` for a coroutine, which returns a `concurrent.futures.Future` the calling thread can wait on. Both append the work under a lock and then write to the loop's self-pipe, so a loop sitting in a selector poll with a distant timer deadline wakes immediately instead of finishing its wait. The scheduled callable must be a plain function — a coroutine function passed to `call_soon` is merely called, producing a coroutine object nobody awaits — and it must not block, because it runs on the loop thread.
code
python · 12 linesimport asyncio
async def main():
loop = asyncio.get_running_loop()
loop.call_soon(print, "runs first, on the next loop iteration")
fires = loop.call_later(0.05, print, "runs 50ms later")
doomed = loop.call_later(0.05, print, "never runs")
doomed.cancel()
print("scheduled; deadline =", round(fires.when() - loop.time(), 2), "s away")
await asyncio.sleep(0.1)
asyncio.run(main())go deeper
Know that the event loop is single-threaded and that scheduling plain callbacks on it is a lower-level tool than await; you will normally write coroutines and let asyncio.run drive them.
Explain call_soon versus call_later, the Handle and TimerHandle you get back, that timers use the loop's monotonic clock, and that neither call is safe from another thread.
Be able to justify the threadsafe variants: serialized queue access plus the self-pipe wake-up that stops a callback waiting for a distant poll timeout. Name the failure of scheduling a coroutine function as a callback.
Own the interop boundary in a mixed-threading service: which threads may touch the loop, that call_soon_threadsafe and run_coroutine_threadsafe are the only doorways, and how ownership is documented so nobody caches a loop across threads.
**The loop belongs to one thread, and there is a doorway.** An asyncio event loop keeps mutable state — a ready-callback queue, a timer heap, a selector registration table — with no locking around most of it, on the assumption that only its own thread touches it. That assumption is the design, not an oversight: single-threaded ownership is what lets asyncio skip locks on every internal structure. The consequence is a blunt rule: **call loop methods only from the loop's thread**, with two documented exceptions. **Scheduling on the loop's own thread.** `asyncio.AbstractEventLoop.call_soon(callback, *args)` appends a callback to the ready queue; it runs on the next iteration, in FIFO order relative to other `call_soon` callbacks, never immediately. `call_later(delay, callback, *args)` and `call_at(when, callback, *args)` put it on the timer heap; `when` is measured on the loop's own monotonic clock, which you read with the loop's `time()` — deliberately not wall-clock time, so a system clock adjustment cannot make a timer fire early, late, or twice. `call_soon` returns a `Handle` and the timer variants return a `TimerHandle`; both offer `cancel()`, and `TimerHandle` also reports `when()`. Keep the handle if you might need to cancel, exactly as you would keep a task reference. ```python import asyncio async def main(): loop = asyncio.get_running_loop() loop.call_soon(print, "next iteration") doomed = loop.call_later(0.05, print, "never runs") doomed.cancel() await asyncio.sleep(0.1) asyncio.run(main()) ``` Note what these are *for*: plain, fast, synchronous callbacks. They are the low-level scheduling primitive beneath tasks, not a replacement for them. If the thing you want to run is a coroutine, you want a task, not `call_soon`. And if you are inside async code and want to wait a while, `await asyncio.sleep(delay)` is clearer than `call_later` — you reach for `call_later` when the work is a callback with no one to await it, such as a periodic tick or a delayed cleanup fired from a callback context. **Crossing the thread boundary.** From a thread that is not the loop's, the two legal calls are `call_soon_threadsafe(callback, *args)`, which returns a `Handle`, and `asyncio.run_coroutine_threadsafe(coro, loop)`, which schedules a coroutine as a task on that loop and hands back a `concurrent.futures.Future` — an ordinary blocking future the calling thread may `result(timeout=...)` on. Everything else on the loop object is off limits from outside. Why a special method at all? Two reasons, and interviewers ask for both. First, the queue append must be serialized, so the threadsafe variant takes the lock the plain one does not. Second, and less obvious, the loop is usually *asleep*. Between iterations it sits inside a selector poll whose timeout is the next timer deadline, which might be seconds away; simply appending to the ready queue would leave your callback waiting for that timeout to expire. So the threadsafe call also writes a byte to the loop's internal self-pipe, a file descriptor the loop always has registered with its selector. That write makes the poll return at once, the loop runs an iteration, and the callback fires promptly. This is also why calling the loop's `stop()` from another thread does not work reliably — the loop may not notice until something wakes it. **Mistakes worth naming.** Passing an `async def` function to `call_soon` type-checks fine and does nothing useful: the callback is called, it returns a coroutine object, the object is discarded, and you get `RuntimeWarning: coroutine ... was never awaited` with the body never executed. Blocking inside a scheduled callback stalls the loop exactly as a blocking call inside a coroutine does, because it runs on the same thread. Assuming `call_later(0.05, ...)` means "in exactly 50 ms" is wrong in the same way any timer is: 50 ms is a floor, and a busy or blocked loop delivers it late. And using the loop object captured from another thread — storing it in a global and calling `create_task` on it from a worker — is a data race even when it appears to work; the threadsafe entry points exist precisely so you never have to. **One loop per thread** is the frame that ties this together. A thread that wants asyncio runs its own loop; threads talk to each other's loops only through these doorways.
- What happens if you pass an async def function to the loop's call_soon()?The loop calls it like any callback, so it returns a coroutine object that nobody awaits. The object is dropped, you get `RuntimeWarning: coroutine ... was never awaited`, and the body never runs. Schedule coroutines with a task, or from another thread with `asyncio.run_coroutine_threadsafe()`.
- Why does call_soon_threadsafe need to wake the loop rather than just queue the callback?Because the loop is usually blocked in a selector poll whose timeout is the next timer deadline. Appending alone would leave the callback waiting for that timeout. The threadsafe call also writes to the loop's self-pipe — a descriptor always registered with the selector — so the poll returns immediately and the next iteration runs the callback.
- How do you cancel a callback you scheduled with call_later()?Keep the `TimerHandle` it returned and call `cancel()` on it; the callback is then skipped when its deadline arrives. Cancellation is only meaningful before the callback starts — once it is running there is nothing to cancel, exactly as with `call_soon`'s `Handle`.
- Which clock does call_later measure its delay against?The loop's own monotonic clock, readable via the loop's `time()` method — not wall-clock time. That is why adjusting the system clock cannot make a timer fire early or twice, and why `call_at` takes a value from that same clock rather than a `time.time()` timestamp.
The loop is a workshop with one worker and no locks on the tool racks; the threadsafe call is the letterbox in the door, plus the doorbell that stops the worker dozing until the next scheduled task.
saying these in an interview costs you the question
- Saying loop.call_soon is safe to call from any thread
- Expecting call_later to fire at exactly the requested delay
- Passing a coroutine function to call_soon and expecting it to run
- Believing call_later measures wall-clock rather than monotonic time
- Assuming all threads in a process share one event loop
- Blocking inside a scheduled callback because it is not a coroutine