A library maintainer wants every operation available in both a blocking form and a non-blocking form, implementing one as a thin wrapper over the other. What goes wrong with each wrapping direction, and what would you recommend instead?
answer
- sync-over-async: deadlock + stolen caller thread
- async-over-sync: fake async, fake cancellation
- hidden pool = caller can't size, bound, or see it
- sans-I/O core + two native surfaces
- async composes down; blocking does not compose up
basics
~20 sSync wrapping async ships a hidden blocking wait that can deadlock the caller's scheduler. Async wrapping sync ships a fake — it hides a thread the caller cannot size or bound. Best: implement each natively over a shared core, or pick one and let callers bridge explicitly at their boundary.
solid answer
~60 sBoth thin wrappers push a hidden cost onto callers. **Sync over async**: the blocking form must wait for a continuation that some scheduler must run. Inside a caller's event loop or a bounded pool it can deadlock, and even when it works it consumes a caller thread invisibly. The library cannot know the caller's threading context, so it cannot make this safe. **Async over sync**: the non-blocking form is a lie — real work still occupies a thread somewhere, usually a hidden pool the library chose. Callers cannot size it, bound its queue, isolate it per dependency, or observe it, and cancellation is fake: the future completes early while the thread runs on. Two libraries doing this each ship their own pool and neither knows about the other. **Recommendation**: factor a protocol/state-machine core that performs no I/O, then implement both surfaces natively over it (sans-I/O style). If that is too costly, ship the asynchronous surface only — it is the composable one — and document a single explicit bridge for synchronous callers, with the offload pool supplied by the caller. Never advertise a wrapper as if it were native, and never hide a thread pool.
code
text · 10 linescore (no I/O):
feed_bytes(in) -> events
next_action() -> WANT_READ | WANT_WRITE(bytes) | DONE(result)
blocking surface: async surface:
loop: loop:
a = core.next_action() a = core.next_action()
if WANT_READ: read() if WANT_READ: await read()
if WANT_WRITE: write(a) if WANT_WRITE: await write(a)
if DONE: return if DONE: returngo deeper
Know that the two forms are not interchangeable: making a blocking call "async" still costs a thread somewhere, and blocking on an async result can hang.
Explain both wrapping directions and their concrete failures — deadlock risk one way, hidden pools and fake cancellation the other.
Add the caller-visibility argument: pool sizing, bounding, isolation, context propagation, and injectable executors as the mitigation.
Own the API-strategy call: sans-I/O core with two native surfaces, or a single async surface plus a documented bridge; weigh ecosystem fragmentation and long-term maintenance against reach.
## The temptation Half your users write blocking code; half live inside an async runtime. Shipping both surfaces doubles your addressable audience for what looks like a few lines of adapter. The problem is that the adapter is not local: each direction exports a resource decision to callers who cannot see it. ## Direction 1: sync implemented over async The blocking method starts the asynchronous operation and waits for the result. Two problems follow. **Deadlock exposure.** Completing the operation requires some execution resource to run the continuation. If the caller invokes your blocking method *from* the resource that must run it — a single-threaded event loop or context with resume affinity, or a fixed pool whose members are all doing the same thing — the wait can never be satisfied. Your library cannot detect this; the caller's threading context is invisible to you, and the failure is load-dependent, so it will not appear in your tests or theirs. See the pool self-deadlock shape: N pool threads each blocked awaiting work queued to the same pool. **Invisible thread cost.** Even without deadlock, the caller now holds a thread for the full duration, in a codebase that may have been sized on the assumption that nothing blocks. Thread-pool sizing math is wrong by exactly the amount of your hidden wait. ## Direction 2: async implemented over sync The non-blocking method submits the blocking call to a pool and returns a future. It *looks* asynchronous and is not: the same thread-seconds are consumed, just somewhere the caller cannot see. - **Hidden capacity.** The library picked the pool size. The caller cannot align it with their latency budget, arrival rate, or the dependency's real concurrency limit. - **No isolation.** Callers cannot bulkhead per dependency, because the pool is shared inside the library and possibly across unrelated call sites. - **No backpressure.** If the internal queue is unbounded, overload becomes latency and memory growth rather than rejection; if it is bounded, the caller cannot choose the bound or the shedding policy. - **Fake cancellation.** Cancelling the returned future typically abandons the *waiter*, not the work. The thread continues, the socket stays open, the transaction still commits. Callers reasoning about timeouts get the wrong answer about what actually stopped. - **Pool proliferation.** Five libraries doing this ship five pools, each idle most of the time, together consuming more threads than a single well-sized shared design would — and none of them appears on the service's capacity model. - **Lost context.** Trace context, deadlines, request identity and security principals attached to the caller's execution context often fail to propagate across the hidden hand-off, so observability degrades exactly where debugging is hardest. ## What to do instead **1. Sans-I/O core.** Factor the real logic — protocol framing, state machine, parsing, retry decisions — into a component that performs no I/O at all. It consumes bytes and events and produces bytes and events. Then write two thin, *native* surfaces over it: one that drives it with blocking calls, one that drives it with non-blocking calls. Both are honest, neither wraps the other, and the difficult logic is tested once with no concurrency in the way. This is the design that has aged best in networking and protocol libraries. **2. If you must ship one surface, ship the asynchronous one.** Asynchrony composes downward — a caller can always bridge to blocking at their own boundary, with their own pool, their own bound, and their own timeout — whereas a blocking API cannot be made non-blocking by a caller without exactly the hidden pool you were trying to avoid. Provide one clearly documented bridge helper, and let the caller *supply* the executor rather than choosing it for them. **3. Make hidden resources injectable and observable.** Where you do wrap, never hardcode a pool: accept an executor, expose its metrics, bound its queue, and document the concurrency the library will consume. Injection converts an invisible policy into a caller decision. **4. Be explicit in naming and documentation.** If a method blocks, say so in the name or contract. Do not present a wrapper as native. State clearly what cancellation actually does — whether it stops work or merely abandons the waiter. **5. Handle the ecosystem-fragmentation reality.** Two API colours split the ecosystem: every middleware, interceptor and test double gets written twice, and half the community's utilities do not work with your half. That maintenance cost — not elegance — is usually the deciding argument for a sans-I/O core, which is the only approach that shares the hard logic between the two colours. ## Interview framing Name both directions and their specific failure (deadlock and hidden thread consumption; fake async, fake cancellation, un-sizeable hidden pool). Then give the recommendation with a reason: a sans-I/O core shares the logic and keeps both surfaces honest; if you must pick one, pick async because it is the direction that can be bridged safely by the caller; and whatever you do, never hide a thread pool inside a library.
- Why is shipping only the asynchronous surface a safer one-surface choice than shipping only the blocking one?A caller can bridge async to blocking at their own boundary using an executor they own, size and bound, with a timeout they choose — the cost becomes visible and local. Going the other way forces every caller to invent a hidden pool to make a blocking API non-blocking, which is precisely the anti-pattern, and they will each do it differently. Asynchrony composes downward; blocking does not compose upward.
- What does cancelling a future actually cancel when the library implemented async by submitting to a hidden thread pool?Usually only the waiter: the future transitions to cancelled while the worker thread keeps executing the blocking call to completion, holding its resources and committing its side effects. Callers therefore get a timeout that frees no capacity, which is dangerous during overload since the pool stays saturated by work nobody is waiting for. Honest APIs document this and, where the underlying operation supports interruption or a deadline, plumb it through.
Selling a "self-driving" car that is really a driver in the boot: the ride looks autonomous, but you cannot see, pay for, or dismiss the person doing the work — and pressing stop only silences the intercom.
saying these in an interview costs you the question
- Claiming a thin wrapper makes an API "fully async" when a thread still does the work.
- Hardcoding a thread pool inside a library instead of accepting an injected executor.
- Assuming cancelling a future stops the underlying blocking operation.
- Offering a blocking convenience method that internally waits on async work without documenting deadlock risk.
- Treating dual APIs as free, ignoring that every middleware and test double must be written twice.