Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@ on:

jobs:
test:
strategy:
matrix:
python-version: ['3.11', '3.12', '3.13', '3.14']
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: actions/setup-python@v7
with:
python-version: '3.12'
python-version: ${{ matrix.python-version }}
- run: pip install -e '.[dev]'
- run: pytest -q

Expand All @@ -28,7 +31,7 @@ jobs:
- uses: actions/checkout@v7
- uses: PyO3/maturin-action@v1
with:
args: --release --out dist -i python3.10 -i python3.11 -i python3.12 -i python3.13
args: --release --out dist -i python3.11 -i python3.12 -i python3.13 -i python3.14
manylinux: auto
- uses: actions/upload-artifact@v7
with:
Expand Down
6 changes: 1 addition & 5 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "loopmini"
version = "0.1.2"
version = "0.2.0"
edition = "2021"
license = "Apache-2.0"
description = "Rust-backed asyncio event loop"
Expand All @@ -13,10 +13,6 @@ crate-type = ["cdylib", "rlib"]
[dependencies]
pyo3 = { version = ">=0.28", features = ["py-clone"] }
polling = "3"
tokio = { version = "1", features = ["rt", "time", "net"] }

[dev-dependencies]
tokio = { version = "1", features = ["rt-multi-thread"] }

[features]
extension-module = ["pyo3/extension-module"]
153 changes: 25 additions & 128 deletions DEV.md

Large diffs are not rendered by default.

7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ pip install loopmini

## Design

The Python side subclasses `asyncio.AbstractEventLoop` and reuses the stock `Task`, `Future`, `Handle`, and `sslproto` machinery. Contextvars, cancellation, and task introspection therefore behave exactly as in the standard loop. CPython releases change little that loopmini must track, because the version-sensitive objects are CPython's own.
The Python side subclasses `asyncio.BaseEventLoop` and reuses its task, future, executor, error-handling, networking, server, sendfile, and subprocess machinery. Reactor-neutral socket, Unix, pipe, and accept implementations come directly from CPython's selector loop. Loopmini supplies the scheduling and fd-readiness hooks plus socket and datagram transports, so version-sensitive asyncio behavior remains CPython's own.

The Rust side owns fd readiness, timers, and cross-thread wakeup. A small level-triggered reactor core (the `polling` crate, kqueue/epoll) is hosted on a Tokio current-thread runtime, which waits on the reactor's own pollable fd. This split preserves the level-triggered `add_reader` contract that asyncio requires and Tokio's edge-triggered driver cannot express. Rust futures spawned on the runtime advance during every blocking poll, with the GIL released, on the same thread and reactor as the Python loop.
The Rust side owns fd readiness, timers, and cross-thread wakeup through a small level-triggered reactor core (`polling`, using kqueue/epoll). The Python driver blocks directly in the reactor with the GIL released. The same PyO3-free core is available to Rust consumers; embedding runtimes can run futures on their own workers and wake the Python loop through its thread-safe scheduling path.

## Compatibility

Expand All @@ -34,5 +34,4 @@ A KeyboardInterrupt injected while the loop runs (the kernel interrupt mechanism

## Performance

Throughput matches the standard loop on real I/O workloads. A 30-second soak serving a fasthtml app under concurrent HTTP and websocket load holds a 2.6ms median response with no fd or memory growth. Microbenchmarks run 5 to 15% slower than the standard loop, and creating a task costs about twice as much, because each schedule crosses the Python/Rust boundary. uvloop is faster where speed is the requirement.

Throughput matches the standard loop on real I/O workloads. A 30-second soak serving a fasthtml app under concurrent HTTP and websocket load holds a 2.6ms median response with no fd or memory growth. There is a small overhead involved in getting the better interrupt semantics, due to having to cross the Rust boundary. It applies only when something is scheduled onto the loop, and is then under 1µs per operation: `create_task`, an `await` that suspends, `asyncio.sleep`, or `call_soon`. Code that stays inside Python, including an `await` that does not suspend, pays nothing. The median overshoot of a 1ms timer is ~0.2ms, similar to Python's standard loop.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ name = "loopmini"
dynamic = ["version"]
description = "Rust-backed asyncio event loop"
license = {text = "Apache-2.0"}
requires-python = ">=3.10"
requires-python = ">=3.11"
readme = "README.md"
authors = [{name = "Jeremy Howard", email = "github@jhoward.fastmail.fm"}]
classifiers = [
Expand Down
Loading
Loading