microio

A tiny asyncio-first runtime helper library for services that own event loops, sockets, background threads, and request/reply waiters.

View the Project on GitHub AnswerDotAI/microio

microio

Small, dependency-free tools for programs that mix threads and asyncio — where data, cancellation, and failure have to cross the thread/event-loop boundary without races, hangs, or silent loss.

The problem

Real programs are rarely purely async. A typical shape: one thread owns an event loop doing the interesting work, while other threads — a socket reader, a control channel, the main thread, sometimes a signal handler — need to:

The stdlib gives you the raw ingredients — call_soon_threadsafe, run_coroutine_threadsafe, Thread, Queue — and leaves all of the above as an exercise. That exercise is where deadlocks, dropped messages, zombie threads, and “it stopped responding but the process looks fine” bugs live.

Trio and AnyIO solve concurrency beautifully, but inside one async world: they assume the code in control is itself async. When the thing doing the cancelling is another thread — or a SIGINT handler that must not take any locks — you’re back on your own.

microio is that missing layer: ~800 lines, stdlib only, asyncio only, Python 3.11+.

What’s in the box

Move data across the boundary

Move control across the boundary

Wait across the boundary

Own your threads properly

Structured async (the in-loop part)

Examples

A thread feeding an event loop

import asyncio, threading
from microio import create_channel

send, recv = create_channel()

def producer():                          # any thread, no loop required
    for i in range(5): send.send_nowait(i)
    send.close()                         # wakes the receiver; the async-for ends

async def main():
    threading.Thread(target=producer).start()
    async for item in recv: print(item)

asyncio.run(main())

A background thread that owns a loop — with checked startup and shutdown

from microio import LoopServiceThread, sleep

class Service(LoopServiceThread):
    async def run_async(self):
        self.db = await connect()              # resources live on the loop thread
        self.started()                         # parent's wait_started() returns now
        while not self.scope.closed: await sleep(0.1)

svc = Service(name="db-service")
svc.start()
svc.wait_started(timeout=5)                    # raises the real traceback if connect() failed
fut = svc.submit(svc.db.query("..."))          # run a coroutine on the service loop, from any thread
rows = fut.result(timeout=5)
svc.stop()
svc.join_or_log(timeout=2)                     # a join timeout is logged, never swallowed

Half of debugging multithreaded programs is finding the thread that died quietly at startup, or never exited at shutdown. ServiceThread makes both loud.

Cancelling async work from another thread (or a signal handler)

from microio import ScopeGroup, sleep

scopes = ScopeGroup()

async def job():
    with scopes.scope() as scope:              # registers a cancellable region
        await do_work()
    if scope.cancelled_caught: print("interrupted; cleaning up")

# meanwhile, from ANY other thread — or a SIGINT handler (no locks taken):
scopes.cancel("user interrupt", latch=True)    # latch also catches a job that is *just* starting

The cancellation lands inside the with block and is caught at its exit — the task survives, follow-up code (sending an error reply, releasing resources) still runs, and nothing leaks to other tasks.

Serialized message handling, with an escape hatch

from microio import ActorCore

async def handle(msg): await process(msg)      # one at a time, in arrival order

actor = ActorCore(handle)
actor.submit(msg)                              # thread-safe, from anywhere
await actor.run()                              # in the loop that owns the actor

When a handler needs to let the queue keep moving while it waits on something slow, concurrent=True hands each handler a release baton:

async def handle(msg, release):
    prepare(msg)            # this part stays strictly ordered
    release()               # from here on, the next message may start...
    await slow_io(msg)      # ...it actually runs whenever this one suspends

actor = ActorCore(handle, concurrent=True)

Handlers that never call release() behave exactly like the serialized actor — ordering is opt-out per message, not a global mode.

Request/reply that can’t hang

from microio import RequestRegistry

reg = RequestRegistry()

# requesting thread: register, send, block for the answer
reply = reg.request(msg_id, send=lambda h: sock.send(payload), timeout=10)

# reader thread, when the response arrives:
reg.resolve(msg_id, response)

# reader thread, when the connection dies:
reg.fail_all(ConnectionError("reader died"))   # every blocked requester raises instead of hanging

Everything together

examples/counter_server.py is a complete ~90-line in-process server combining LoopServiceThread, channels, RequestRegistry, and CloseScope:

python examples/counter_server.py

Design rules

What microio is not

microio was extracted from a Jupyter kernel, where all of these problems show up at once: a protocol thread feeding an execution loop, Ctrl-C arriving as a signal that must cancel a coroutine on another thread, and clients that disconnect while something is blocked waiting on them. The primitives are general; that’s just the crucible they were forged in.

Development

pip install -e .[dev]
pytest -q

Version lives in microio/__init__.py as __version__.