How do you expose one Python library to both blocking and asyncio callers?
answer
- One core, one generated surface
- Decide which side is real first
- The facade owns a loop or a pool
- Cancellation and context stop at the bridge
- An I/O-free core pays no bridge tax
basics
~20 sPick one core and wrap it once. Async core plus a sync facade that owns a loop on its own thread, sync core plus an async facade built on asyncio.to_thread(), or an I/O-free core with two thin shells. Shipping two hand-written cores is the option that always rots.
solid answer
~50 sThe decision is which side is real. **Async core, sync facade**: the facade runs a loop on a dedicated thread and submits with `asyncio.run_coroutine_threadsafe(...).result(timeout)`, so blocking callers never re-enter a loop; you pay a thread, and cancellation and timeouts stop crossing the boundary. **Sync core, async facade**: wrap each call in `asyncio.to_thread()` - cheap to write, but concurrency is capped by the thread pool and CPU-heavy work still holds the GIL. **I/O-free core**: a pure protocol or state machine with two thin I/O shells; the most work up front, the only one with no bridge tax. Third-party helpers (`anyio` portals, `asgiref`'s `sync_to_async`/`async_to_sync`) package the first two patterns, including thread-affinity modes for callers whose state is thread-local. Whatever you choose, document it, test both surfaces, and forbid the sync facade being called from inside a running loop.
code
python · 20 linesimport asyncio
import threading
async def core(x): # the library's real implementation
await asyncio.sleep(0.05)
return x + 1
class Portal:
def __init__(self):
self._loop = asyncio.new_event_loop()
threading.Thread(target=self._loop.run_forever, daemon=True).start()
def call(self, coro, timeout=10):
return asyncio.run_coroutine_threadsafe(coro, self._loop).result(timeout)
portal = Portal()
print(portal.call(core(1)))go deeper
Understand that a function is either blocking or awaitable and that mixing the two is what these designs are working around. Knowing the two directions exist is enough here.
Be able to build either facade: a blocking wrapper over an async core using a loop on its own thread, or an async wrapper over a sync core using asyncio.to_thread(), and name what each one costs.
Show the failure modes you would design against - the facade called from inside a loop, an unbounded async surface over a limited pool, thread-local state scattered across pool threads - and how you would make each one loud rather than silent.
Own the choice and its consequences: which core is real, whether an I/O-free core is worth the design effort, what the bridge drops, the doubled test matrix, and the dependency decision that adopting a bridge library represents.
## The real question "Support both" is not one decision, it is three: which core holds the logic, how the other surface is generated, and what the bridge silently drops. Getting the first right makes the rest mechanical; getting it wrong produces two implementations that drift, and the drift always lands in error handling and timeouts, where it is least visible. ## Option A: async core, sync facade The library is written with `async def` and awaits. The blocking surface is a thin object that owns an event loop running `run_forever()` on a dedicated daemon thread; each public method submits a coroutine with `asyncio.run_coroutine_threadsafe(coro, loop)` and blocks on `.result(timeout)`. Strengths: one implementation of the logic, full concurrency available to async callers, and the sync surface is generated rather than written. Costs, all of which must be documented: one extra thread and a loop the process now owns; a `KeyboardInterrupt` story that is worse, because the signal arrives on the main thread while the work is elsewhere; cancellation that does not cross - cancelling the blocking wait does not cancel the coroutine unless you propagate it explicitly; and a hard rule that the sync facade must never be called from inside a running loop, since that would park the caller's loop thread on a future. ## Option B: sync core, async facade The library stays blocking, and the async surface is `await asyncio.to_thread(self._do, ...)`. This is the cheapest to build and the easiest to get subtly wrong at scale. Concurrency is capped by the executor, not by the loop, so an async caller who expects thousands of in-flight operations gets tens; the shared default pool means your library can starve unrelated parts of the caller's process, including its name resolution; and any CPU-heavy step still serializes on the GIL. If you take this route, accept a dedicated bounded executor as part of the public API surface, so callers can size it and keep it off the shared pool. ## Option C: no I/O in the core The core is a state machine: it takes bytes and events, returns bytes and events, and performs no I/O at all. Two shells - one blocking, one async - do the socket work. Nothing bridges, nothing is coloured, and both surfaces are genuinely native. It costs the most design effort, forces protocol logic and transport apart, and is the pattern worth arguing for when the library is long-lived infrastructure rather than an application-level convenience. ## What the bridge always drops Whichever direction you bridge, four things need explicit answers. **Cancellation**: threads cannot be interrupted, so an async caller's cancellation cannot stop blocking work already started; document that timeouts must be pushed into the underlying call. **Context**: `contextvars` copy into a worker thread, but changes made there do not propagate back, and thread-local state does not follow work between pool threads at all - which is why bridge helpers offer a thread-sensitive mode that pins a caller's sync work to one thread so thread-local resources such as a per-thread connection stay valid. **Backpressure**: an async facade over a sync core needs a bound, or callers will queue unbounded work with no signal. **Exceptions and tracebacks**: they cross intact, but the frames of the bridge appear in every traceback, so keep the bridge shallow enough that support engineers can still read them. ## Ecosystem helpers, and their price `anyio` provides both directions - running blocking functions in worker threads and calling back into the loop from a thread through a portal - with the bonus that the async core stays runnable on more than one async backend. `asgiref` provides `sync_to_async` and `async_to_sync` with the thread-sensitivity switch mentioned above. Both are the two primitives already discussed with the ergonomics filled in; neither removes the bridge tax, and adopting one is a dependency decision that belongs in the same conversation as the core choice. ## Testing and organizational cost Two public surfaces double the test matrix in the places that matter least to write and most to get right: timeout behaviour, cancellation, error types, and shutdown. Budget for a shared behavioural suite driven twice rather than two hand-written suites. Budget also for the support burden of the deadlock class - the sync facade called from an async context - and prevent it structurally: detect a running loop in the facade and raise a clear error naming the async method to use instead. A loud, specific error at the boundary is worth more than any amount of documentation. ## How to decide If the library's value is I/O concurrency, the core is async. If it is computation with incidental I/O, the core is sync and the async surface is a courtesy with a documented pool. If it will be depended on for a decade, spend the effort on an I/O-free core. State the choice in the README, because the first surprised caller will otherwise discover it as a hang in production.
- How would you stop callers from invoking the blocking facade inside a running event loop?Detect it and refuse. The facade checks for a running loop on entry and raises an error naming the async method to call instead. That converts a silent deadlock - the caller's loop thread parked on a future only that loop can complete - into an immediate, self-explanatory failure at the exact call site, which is worth more than any warning in the documentation.
- What does a bridge cost you in cancellation semantics?Cancellation stops at the boundary in both directions. An async caller cancelling a call that has entered a worker thread unblocks only the await; the thread runs to completion. A sync caller abandoning a blocking wait does not cancel the coroutine unless the facade explicitly cancels the future it holds. The practical rule is to push real deadlines into the underlying I/O call, where they can actually be enforced.
- Why do bridge helpers offer a thread-sensitive mode, and when does it matter?Because thread-local state - a per-thread connection, a transaction bound to a thread, a legacy context object - is only valid on the thread that created it. A thread-sensitive mode pins a caller's sync work to a single thread so that state stays coherent across successive calls. It matters exactly when the wrapped code keeps per-thread resources, and it costs you the concurrency a free pool would have given.
- Does the free-threaded build change this design decision on 3.14?Not yet as a public API decision. Free-threading is officially supported in 3.14 under PEP 779, which weakens the GIL objection to a sync core fronted by threads, but it is a separate build with a 5-10% single-threaded penalty and uneven native-extension support. A library cannot assume its callers run it, so the design must still hold on the default build.
saying these in an interview costs you the question
- Maintaining two hand-written implementations of the same logic
- A blocking facade that calls asyncio.run() wherever it is invoked
- Assuming cancellation and timeouts propagate across the bridge
- An async facade over a sync core with no bound on in-flight work
- Ignoring thread-local state when pooling wrapped sync calls
- Treating the free-threaded build as a public design assumption