Quick start

Installation

Install the package into a free-threaded Python 3.14.5 or newer environment:

pip install uniteio

UniteIO warns when the interpreter’s GIL is enabled. The library can still provide separate event loops in that configuration, but parallel execution of Python code between their threads is the capability free-threaded CPython adds.

Check whether the interpreter supports free threading and whether the GIL is currently enabled:

$ python -c "import sys, sysconfig; print('free-threaded build:', bool(sysconfig.get_config_var('Py_GIL_DISABLED'))); print('GIL enabled:', sys._is_gil_enabled())"
free-threaded build: True
GIL enabled: False

A free-threaded build can optionally run with the GIL enabled, and importing an incompatible C extension may enable it automatically. Python documents the runtime controls and compatibility considerations in its free-threading guide.

One runtime, two independent loops

Consider a market-data process that consumes two Binance websocket streams. Trade bursts and ticker updates have different processing characteristics, but the consumers need access to the same in-process market state.

Model each long-running consumer as a different uniteio.UniteIO subclass. The stream below is a self-contained stand-in for an async iterator provided by a Binance websocket client:

import asyncio
from threading import Lock

from uniteio import UniteIO


class MarketState:
    """State deliberately shared by both event-loop threads."""

    def __init__(self):
        self._lock = Lock()
        self.values = {}

    def update(self, key, value):
        with self._lock:
            self.values[key] = value


async def demo_stream(event_type):
    """Replace this with the async iterator from a websocket client."""
    sequence = 0
    while True:
        await asyncio.sleep(0.25)
        sequence += 1
        yield {"type": event_type, "sequence": sequence}


class TradeStream(UniteIO, prefix="TRD"):
    def __init__(self, state):
        super().__init__(state=state)

    async def __call__(self) -> None:
        async for trade in demo_stream("trade"):
            self.state.update("last_trade", trade)


class TickerStream(UniteIO, prefix="TCK"):
    def __init__(self, state):
        super().__init__(state=state)

    async def __call__(self) -> None:
        async for ticker in demo_stream("ticker"):
            self.state.update("last_ticker", ticker)


state = MarketState()
trades = TradeStream(state=state)
ticker = TickerStream(state=state)

trades and ticker now run concurrently on different event loops and different threads. On free-threaded CPython, eligible Python callbacks on those threads may also run in parallel. They share state by reference, so its mutable dictionary is protected by threading.Lock.

Each concrete subclass has one active instance. Constructing TradeStream a second time returns the same running trade application; the different TickerStream subclass receives its own application, thread, and loop.

Submitting work

Submit coroutine work to a particular application’s loop from synchronous code:

ready = trades.submit(
    asyncio.sleep,
    0,
    result="trade loop ready",
    name="warmup",
)
assert ready.result() == "trade loop ready"

The target application determines the destination loop. This makes it possible to keep task groups, timeouts, and loop-local resources isolated by concern.

Synchronous callables submitted through submit run on the shared uniteio.UIOPool instead:

total = ticker.submit(sum, [1, 2, 3])
assert total.result() == 6

The shared executor is useful for ordinary blocking or synchronous work. It is separate from the dedicated threads that drive the application event loops.

What independence means

  • A slow callback blocks its own loop thread, not the other application’s loop.

  • Loop-local tasks, async generators, and cancellation are owned by one app.

  • Free-threaded CPython permits Python work on different loop threads to run in parallel; actual speedups still depend on the workload and libraries used.

  • Globals and passed objects remain genuinely shared. Mutable access must be synchronized, and cross-loop asyncio primitives should not be shared.

Shutdown

Stop one stream without affecting the other loop or shared executor:

trades.stop(timeout=2)

# The ticker application remains active.
ticker.stop(timeout=2)

Or stop every registered application before shutting down the executor:

from uniteio import UIOPool

UIOPool().shutdown(cancel_futures=True)