Skip to content

Calling Sync Code from AnyIO with to_thread and from_thread

AnyIO's thread bridge has two halves. anyio.to_thread.run_sync(fn) runs blocking code in a worker thread and awaits it, like asyncio.to_thread but backend-neutral and with a capacity limiter. anyio.from_thread.run(async_fn) goes the other way: from inside one of those worker threads, it runs a coroutine back on the event loop and blocks the thread until it finishes. Together they let a blocking library — one that takes callbacks, say — call async code in the middle of its synchronous work. The details that matter in production were all visible in a quick test: the default limiter allows 40 concurrent threads, so 100 blocking calls of 100 ms took 0.31 s in three waves; a cancelled run_sync waited the full 0.5 s for its thread by default and returned after 0.1 s with abandon_on_cancel=True; and from_thread.run from a thread AnyIO did not start failed with Not running inside an AnyIO worker thread, and no event loop token was provided.

Prerequisites

1. Offload blocking calls with to_thread.run_sync

import time
import anyio
from anyio import to_thread


def read_config(path: str) -> bytes:
    with open(path, "rb") as f:          # blocking file I/O
        return f.read()


async def main() -> None:
    data = await to_thread.run_sync(read_config, "/etc/app.toml")
    print(len(data))


anyio.run(main)                           # or anyio.run(main, backend="trio")

Unlike asyncio.to_thread, run_sync takes positional arguments only; wrap keyword arguments with functools.partial. It copies the caller's context variables into the thread on both backends, so logging context and request ids survive the hop.

Every call goes through a capacity limiter — by default a global one with 40 tokens. Measured: 100 concurrent calls that each block for 100 ms finished in 0.31 s, three waves of up to 40. Check the size with to_thread.current_default_thread_limiter().total_tokens, and raise it, or pass a dedicated limiter, when the blocking calls are I/O that mostly waits.

Verify: run 100 concurrent run_sync(time.sleep, 0.1) calls; total time is about ceil(100 / tokens) × 0.1 s.

Both directions across the AnyIO thread bridge A flow of 4 stages. Both directions across the AnyIO thread bridge task: to_thread.run_sync takes a limiter token worker thread runs blocking code from_thread.run(coro) back onto the loop results flow back thread, then task from_thread only works from threads that to_thread started, because those carry the loop's token.

2. Size limiters per dependency

One global 40-thread limiter means one slow blocking dependency can occupy every token. Give risky dependencies their own limiter, exactly as you would give them their own thread pool:

from anyio import CapacityLimiter, to_thread

s3_limiter = CapacityLimiter(16)
fs_limiter = CapacityLimiter(8)


async def upload(path: str, key: str) -> None:
    await to_thread.run_sync(s3_client.upload_file, path, BUCKET, key, limiter=s3_limiter)


async def read_small(path: str) -> bytes:
    return await to_thread.run_sync(_read, path, limiter=fs_limiter)


async def main() -> None:
    to_thread.current_default_thread_limiter().total_tokens = 64   # for everything else

CapacityLimiter.statistics() reports borrowed tokens and waiting tasks, which is the queueing signal to export. The sizing arithmetic is the same as for asyncio's executor, covered in sizing the default thread pool executor: concurrent blocking calls ≈ arrival rate × block time, with headroom.

Verify: stall the S3 dependency; fs_limiter and the default limiter keep serving other calls.

3. Call back into the loop with from_thread

Some blocking libraries call your code in their thread — progress callbacks, row handlers, event hooks — and that code needs async services. From inside a thread started by to_thread.run_sync, from_thread.run schedules a coroutine on the event loop and blocks the thread until it returns:

from anyio import from_thread, to_thread


async def record_progress(done: int, total: int) -> None:
    await metrics_client.gauge("upload_progress", done / total)


def upload_with_progress(path: str) -> None:
    def on_chunk(done: int, total: int) -> None:          # called by the SDK, in our thread
        from_thread.run(record_progress, done, total)       # runs on the loop, waits for it
    sdk.upload(path, callback=on_chunk)


async def main() -> None:
    await to_thread.run_sync(upload_with_progress, "big.bin")

Verified: a worker thread calling from_thread.run(async_lookup, 21) received 42. The worker thread knows which loop to use because run_sync gave it a token. From a thread you started yourself, there is no token, and the call fails with Not running inside an AnyIO worker thread, and no event loop token was provided. For long-lived threads you own, use anyio.from_thread.start_blocking_portal() (to run a private loop) or obtain a portal from the running loop and pass it to the thread.

from_thread.run_sync is the synchronous variant: it runs a plain function on the loop thread, which is how a worker thread safely touches loop-owned objects such as an anyio.Event.

Verify: the progress gauge updates during the upload, and calling from_thread.run from a threading.Thread you created raises the token error.

A blocking SDK calling async code through from_thread A sequence of 6 messages between 4 participants. A blocking SDK calling async code through from_thread task worker thread blocking SDK event loop to_thread.run_sync(upload) sdk.upload(callback=on_chunk) on_chunk(done, total) from_thread.run(record_progress) coroutine finished upload complete The thread blocks while the coroutine runs on the loop, so the SDK's callback stays synchronous.

4. Decide what cancellation means for the thread

A thread cannot be interrupted. When the task awaiting run_sync is cancelled, AnyIO's default is to wait for the thread to finish and then deliver the cancellation; with abandon_on_cancel=True it delivers the cancellation immediately and lets the thread run on unobserved. Measured with a 0.5 s blocking call inside move_on_after(0.1): 0.5 s by default, 0.1 s abandoned.

with anyio.move_on_after(2):
    await to_thread.run_sync(fetch_report, report_id, abandon_on_cancel=True)

The default is the safe one — no orphaned thread keeps writing to state you think is finished — and it means a cancel scope cannot shorten a blocking call. Abandoning is right when the blocking call is read-only and its result can be discarded, and wrong when it mutates anything. Note that an abandoned thread still holds its limiter token until it finishes. AnyIO's default differs from asyncio.to_thread, whose await is always abandoned on cancellation — a difference worth knowing when porting, and covered with other cancellation contrasts in level vs edge cancellation in AnyIO and asyncio.

Verify: time a cancelled run_sync with and without abandon_on_cancel; the difference is the remaining run time of the thread.

Cancelling a 0.5 s blocking call after 0.1 s 2 horizontal bars comparing default: wait for the thread with the others. Cancelling a 0.5 s blocking call after 0.1 s default: wait for the thread 0.50 s abandon_on_cancel=True 0.10 s move_on_after(0.1) around to_thread.run_sync(time.sleep, 0.5); AnyIO 4.15 on asyncio. The default keeps cancellation honest about the thread; abandoning returns sooner and leaves it running.

5. Bridge from code that has no event loop

The reverse situation — synchronous code at the top, needing to call async code — uses a blocking portal, which runs an event loop in a background thread for the lifetime of a with block:

from anyio.from_thread import start_blocking_portal


def sync_main() -> None:
    with start_blocking_portal(backend="asyncio") as portal:
        client = portal.call(make_async_client)                 # created on the portal's loop
        for user_id in load_ids():
            profile = portal.call(client.get_profile, user_id)  # blocks this thread per call
            write_csv_row(profile)
        portal.call(client.aclose)

The portal is AnyIO's version of the background-loop bridge in running an event loop in a background thread, with the same rule: objects created through the portal belong to its loop and must only be used through it.

Verify: the async client's connection pool is reused across portal.call invocations — one connection, many requests.

Verification

The thread bridge is used correctly when:

  • Limiter sizes match the blocking workload, and risky dependencies have their own limiters.
  • from_thread is only called from AnyIO worker threads or through a portal.
  • Cancellation behaviour is chosen per call: default for anything that mutates, abandon only for discardable reads.
  • Portal-created objects are only used through their portal.

Diagnostic Hook: export limiter.statistics().borrowed_tokens and tasks_waiting for each limiter. Waiting tasks above zero for long stretches means queueing for threads; borrowed tokens pinned at the limit with low throughput means a dependency has stalled inside its threads, which is when abandoned or long-running calls start to matter.

Pitfalls & edge cases

  • Keyword arguments to run_sync. Not supported; use functools.partial.
  • from_thread.run in a plain thread. No loop token; use a portal.
  • Blocking the loop from from_thread.run_sync. The function runs on the loop thread; it must be quick.
  • Assuming cancel scopes interrupt threads. They never do; at best the await is abandoned.

Frequently Asked Questions

How many threads does anyio.to_thread.run_sync use?

By default it is limited by a global CapacityLimiter with 40 tokens, so at most 40 calls run concurrently and the rest wait. Change total_tokens on the default limiter or pass your own limiter per call.

How do I call async code from a thread in AnyIO?

From a thread started by to_thread.run_sync, call anyio.from_thread.run(async_fn, *args); it runs the coroutine on the event loop and blocks the thread until it finishes. From other threads, use a blocking portal.

What happens to the thread when to_thread.run_sync is cancelled?

By default AnyIO waits for the thread to finish before delivering the cancellation. With abandon_on_cancel=True the await is cancelled immediately and the thread keeps running in the background.

Why does from_thread.run say no event loop token was provided?

It was called from a thread that AnyIO did not start, so the thread does not know which event loop to use. Start the thread with to_thread.run_sync, or use a BlockingPortal.