ringloop 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ringloop-1.0.0/CHANGELOG.md +40 -0
- ringloop-1.0.0/DESIGN.md +373 -0
- ringloop-1.0.0/LICENSE +21 -0
- ringloop-1.0.0/MANIFEST.in +7 -0
- ringloop-1.0.0/PKG-INFO +235 -0
- ringloop-1.0.0/README.md +207 -0
- ringloop-1.0.0/conformance/cpython_skips.txt +41 -0
- ringloop-1.0.0/conformance/run_cpython.py +154 -0
- ringloop-1.0.0/conformance/smoke.py +24 -0
- ringloop-1.0.0/pyproject.toml +80 -0
- ringloop-1.0.0/setup.cfg +4 -0
- ringloop-1.0.0/setup.py +22 -0
- ringloop-1.0.0/src/ringloop/__init__.py +88 -0
- ringloop-1.0.0/src/ringloop/__main__.py +55 -0
- ringloop-1.0.0/src/ringloop/_core.pyx +3120 -0
- ringloop-1.0.0/src/ringloop/_owner.py +38 -0
- ringloop-1.0.0/src/ringloop/extras.py +1179 -0
- ringloop-1.0.0/src/ringloop/ll_abi.h +149 -0
- ringloop-1.0.0/src/ringloop/ll_sync.h +51 -0
- ringloop-1.0.0/src/ringloop/ll_uring.h +387 -0
- ringloop-1.0.0/src/ringloop/loop.py +1192 -0
- ringloop-1.0.0/src/ringloop/py.typed +0 -0
- ringloop-1.0.0/src/ringloop/pytest_plugin.py +24 -0
- ringloop-1.0.0/src/ringloop/tls.py +505 -0
- ringloop-1.0.0/src/ringloop.egg-info/PKG-INFO +235 -0
- ringloop-1.0.0/src/ringloop.egg-info/SOURCES.txt +38 -0
- ringloop-1.0.0/src/ringloop.egg-info/dependency_links.txt +1 -0
- ringloop-1.0.0/src/ringloop.egg-info/top_level.txt +1 -0
- ringloop-1.0.0/tests/__init__.py +0 -0
- ringloop-1.0.0/tests/abi_check.c +98 -0
- ringloop-1.0.0/tests/fallback_check.py +38 -0
- ringloop-1.0.0/tests/soak/client.py +145 -0
- ringloop-1.0.0/tests/soak/run.sh +19 -0
- ringloop-1.0.0/tests/soak/server.py +111 -0
- ringloop-1.0.0/tests/test_asyncio_compat.py +890 -0
- ringloop-1.0.0/tests/test_frameworks.py +140 -0
- ringloop-1.0.0/tests/test_hardening.py +381 -0
- ringloop-1.0.0/tests/test_loop.py +1006 -0
- ringloop-1.0.0/tests/test_robustness.py +1017 -0
- ringloop-1.0.0/tests/test_threads.py +607 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
The versions follow semantic versioning. The public interface is what
|
|
4
|
+
`README.md` describes: `ringloop.run`, `new_event_loop`, `install`, `Loop`,
|
|
5
|
+
`Server`, `NATIVE`, `loop.backend`, the `RINGLOOP_BACKEND` variable,
|
|
6
|
+
`python -m ringloop` and `ringloop.pytest_plugin`. Everything else,
|
|
7
|
+
`ringloop._core` included, can change in any release.
|
|
8
|
+
|
|
9
|
+
## 1.0.0
|
|
10
|
+
|
|
11
|
+
The first public release.
|
|
12
|
+
|
|
13
|
+
- An asyncio event loop for Linux on io_uring (Linux 6.1 and newer), with an
|
|
14
|
+
epoll backend for older kernels and for containers that do not allow
|
|
15
|
+
io_uring. The loop chooses the backend itself.
|
|
16
|
+
- The whole `asyncio.AbstractEventLoop` interface: TCP and Unix servers and
|
|
17
|
+
clients, TLS, `start_tls`, `sock_*`, UDP, pipes, subprocesses, signals,
|
|
18
|
+
executors, `sendfile`, Happy Eyeballs, debug mode.
|
|
19
|
+
- CPython 3.12 to 3.15, and the free-threaded builds 3.14t and 3.15t with
|
|
20
|
+
one loop per thread. Wheels for manylinux and musllinux, x86-64 and
|
|
21
|
+
aarch64.
|
|
22
|
+
- On systems other than Linux the package installs and hands out asyncio's
|
|
23
|
+
loop.
|
|
24
|
+
- `ringloop.install()` for programs that take no loop factory, and
|
|
25
|
+
`ringloop.pytest_plugin` to run a test suite on the loop.
|
|
26
|
+
|
|
27
|
+
How it was checked before the release:
|
|
28
|
+
|
|
29
|
+
- CPython's own asyncio test suite runs against the loop on both backends
|
|
30
|
+
(`conformance/`), on Python 3.12 to 3.15.
|
|
31
|
+
- This repository's suite runs on both backends, on every supported Python,
|
|
32
|
+
with io_uring on kernels 6.1, 6.6, 6.8 (Ubuntu), 6.12, 6.17 and 7.0.
|
|
33
|
+
- AddressSanitizer and UBSan builds, a soak test with misbehaving clients
|
|
34
|
+
(`tests/soak`), and smoke tests of uvicorn/Starlette, FastAPI, aiohttp
|
|
35
|
+
and httpx on the loop. The test suite of Stario (1,804 tests) passes on
|
|
36
|
+
both backends.
|
|
37
|
+
- Two independent code reviews of the engine. What they found is fixed, and
|
|
38
|
+
`tests/test_hardening.py` holds a test for each defect.
|
|
39
|
+
|
|
40
|
+
`README.md` lists the differences from asyncio and the limits.
|
ringloop-1.0.0/DESIGN.md
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
# Design
|
|
2
|
+
|
|
3
|
+
## What the loop is
|
|
4
|
+
|
|
5
|
+
`ringloop.Loop` implements `asyncio.AbstractEventLoop` on its own engine. It
|
|
6
|
+
does not subclass or patch asyncio's loop classes, so it is a drop-in loop
|
|
7
|
+
for any asyncio program:
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
import ringloop
|
|
11
|
+
ringloop.run(main()) # like asyncio.run
|
|
12
|
+
asyncio.run(main(), loop_factory=ringloop.new_event_loop)
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
| File | Holds |
|
|
16
|
+
| --- | --- |
|
|
17
|
+
| `_core.pyx` (Cython) | `LoopCore`: ready queue, timer heap, `call_soon` / `call_later` / `add_reader`. `Core`: the I/O turn on io_uring or epoll. `Transport`: stream sockets. `Listener`: accept |
|
|
18
|
+
| `ll_uring.h` (C) | io_uring by raw syscalls: ring setup, provided-buffer ring, SQE builders, the spill list for a full submission queue. No liburing, no libuv |
|
|
19
|
+
| `ll_abi.h` (C) | The part of the io_uring ABI the engine uses, written out, so that the extension builds against any kernel headers (`tests/abi_check.c` compares it with the kernel's header) |
|
|
20
|
+
| `ll_sync.h` (C) | Atomic loads, stores and compare-and-swap for the few fields other threads touch |
|
|
21
|
+
| `loop.py` | `Loop`: run / stop / close, tasks, exception handler, executors, DNS, signals, `sock_*`, `create_server`, `create_connection`, Unix sockets, `start_tls`; `Server` |
|
|
22
|
+
| `tls.py` | `SSLTransport`: TLS on `ssl.MemoryBIO`, one object that is both the raw transport's protocol and the application's transport |
|
|
23
|
+
| `extras.py` | Pipes, subprocesses, datagram endpoints (Python on `add_reader` / `add_writer`); `sendfile` |
|
|
24
|
+
| `__init__.py` | `run`, `new_event_loop`; on systems other than Linux these hand out asyncio's loop |
|
|
25
|
+
|
|
26
|
+
### What it still touches that is not plainly public
|
|
27
|
+
|
|
28
|
+
- `asyncio._set_running_loop` / `asyncio._get_running_loop`: underscore
|
|
29
|
+
names, but exported by asyncio for exactly this (every third-party loop
|
|
30
|
+
uses them).
|
|
31
|
+
- `asyncio.subprocess.Process.wait()` calls `transport._wait()`, so
|
|
32
|
+
`ProcessTransport` provides it.
|
|
33
|
+
- `get_extra_info("socket")` returns `asyncio.trsock.TransportSocket`, the
|
|
34
|
+
same restricted view asyncio returns.
|
|
35
|
+
- `ringloop.pytest_plugin` (test tooling only) swaps the default policy's
|
|
36
|
+
loop factory to run other projects' test suites on ringloop.
|
|
37
|
+
- `asyncio.staggered.staggered_race` runs the connection attempts of Happy
|
|
38
|
+
Eyeballs (the module has an `__all__`, but is not in the documentation).
|
|
39
|
+
- `start_tls` on the server side moves bytes a `StreamReader` has already
|
|
40
|
+
buffered (the client's ClientHello) into the TLS layer, through the
|
|
41
|
+
reader's private `_buffer`, exactly as asyncio does (gh-142352). There is
|
|
42
|
+
no public way to take them out.
|
|
43
|
+
|
|
44
|
+
A stream transport is an extension type, and an extension type cannot
|
|
45
|
+
inherit from `asyncio.Transport` (a Python class with its own instance
|
|
46
|
+
layout). Code written for asyncio checks `isinstance(transport,
|
|
47
|
+
asyncio.Transport)`, so the transport answers `__class__` with a subclass of
|
|
48
|
+
`asyncio.Transport`: `isinstance` consults `__class__`. `type(transport)` is
|
|
49
|
+
still the extension type. The transports written in Python inherit from the
|
|
50
|
+
asyncio classes directly.
|
|
51
|
+
|
|
52
|
+
## Two backends
|
|
53
|
+
|
|
54
|
+
- **io_uring** (default when the kernel allows it). One ring per loop with
|
|
55
|
+
`SINGLE_ISSUER` and `DEFER_TASKRUN`, 1024 submission and 4096 completion
|
|
56
|
+
entries. Linux 6.1 is the minimum because `DEFER_TASKRUN` is: an older
|
|
57
|
+
kernel fails ring setup and gets epoll. Receives are one multishot `RECV` per
|
|
58
|
+
connection drawing from a shared ring of 256 x 16 KiB buffers (256:
|
|
59
|
+
the buffer ring then fits in one page; kernels before 6.9 map only the
|
|
60
|
+
first page of a ring the program allocated). Sends are
|
|
61
|
+
`SEND` with `MSG_WAITALL` from a buffer the transport owns until the
|
|
62
|
+
completion arrives. Accept is one request per connection (see "Accept is
|
|
63
|
+
inline").
|
|
64
|
+
- **epoll** (`RINGLOOP_BACKEND=epoll`, or automatically when
|
|
65
|
+
`io_uring_setup` fails). Same transports, readiness-driven: `recv` into a
|
|
66
|
+
loop-wide scratch buffer, `send` at the end of the turn. Container
|
|
67
|
+
runtimes often block io_uring with seccomp, so this is the backend many
|
|
68
|
+
deployments get. `loop.backend` says which one is in use. Timeouts are
|
|
69
|
+
exact (`epoll_pwait2`, Linux 5.11) unless the kernel or the seccomp policy
|
|
70
|
+
refuses that call; then they round up to a millisecond.
|
|
71
|
+
|
|
72
|
+
A `SINGLE_ISSUER` ring belongs to the thread that created it. The ring is
|
|
73
|
+
created on first use; if the loop is later run on a different thread (set up
|
|
74
|
+
with `run_until_complete` on the main thread, then `run_forever` on a
|
|
75
|
+
worker), the core replaces the ring when the loop starts running and arms
|
|
76
|
+
its listeners and open connections again on the new one. The owner is
|
|
77
|
+
tracked by a per-thread number that no other thread of the process ever
|
|
78
|
+
gets (`pthread_self()` values are reused as soon as a thread exits, kernel
|
|
79
|
+
thread ids once the pid space wraps). Before the old ring is dropped,
|
|
80
|
+
completions still sitting in its queue are run, and with `DEFER_TASKRUN` no
|
|
81
|
+
others can exist, because completions only happen inside the loop's own
|
|
82
|
+
`io_uring_enter`. The move is refused, with an error, in two cases, and the
|
|
83
|
+
loop keeps its ring and its thread: a send is still in flight on the old
|
|
84
|
+
thread (how much of it the kernel has done is known only to the old ring),
|
|
85
|
+
or the new ring cannot be created (out of memory or descriptors; it is
|
|
86
|
+
created before the old one is dropped).
|
|
87
|
+
|
|
88
|
+
The backend is chosen, and the ring created, when the loop opens its first
|
|
89
|
+
socket (or is asked for `loop.backend`). Until then the loop waits in
|
|
90
|
+
`epoll_wait`: callbacks, timers, `add_reader` / `add_writer` and the wake-up
|
|
91
|
+
eventfd need nothing else. A program that only runs tasks, timers or
|
|
92
|
+
subprocesses never creates a ring, and a test suite that makes a loop per
|
|
93
|
+
test does not create and destroy a ring (and its 8 MiB of receive buffers)
|
|
94
|
+
per test.
|
|
95
|
+
|
|
96
|
+
Ring memory counts against `RLIMIT_MEMLOCK` (8 MiB by default) unless the
|
|
97
|
+
process has `CAP_IPC_LOCK`, and the kernel gives it back some time after the
|
|
98
|
+
ring is closed. That is why the ring is small: about 140 KiB, so some 50
|
|
99
|
+
loops fit in the default limit (one loop per thread on a large machine). The
|
|
100
|
+
sizes do not limit a turn (see "The submission queue cannot overflow"; the
|
|
101
|
+
kernel keeps completions that do not fit and hands them over on the next
|
|
102
|
+
call). When `io_uring_setup` fails with `ENOMEM` the loop logs one warning
|
|
103
|
+
and uses epoll.
|
|
104
|
+
|
|
105
|
+
`add_reader` / `add_writer` registrations and the wake-up eventfd live in an
|
|
106
|
+
epoll set the core owns, on both backends. Under io_uring that epoll fd is
|
|
107
|
+
watched with a one-shot `POLL_ADD`, re-armed each turn, which keeps those
|
|
108
|
+
registrations level-triggered as asyncio code expects.
|
|
109
|
+
|
|
110
|
+
## Threads
|
|
111
|
+
|
|
112
|
+
A loop belongs to one thread at a time: the thread running it, or, while no
|
|
113
|
+
thread runs it, the thread calling into it. That thread is the loop's
|
|
114
|
+
*owner* (`Core.owner`, claimed with an atomic compare-and-swap).
|
|
115
|
+
|
|
116
|
+
- **Scheduling works from every thread.** `call_soon`,
|
|
117
|
+
`call_soon_threadsafe`, `call_later`, `call_at` and `stop` are all safe
|
|
118
|
+
from any thread (the first two are the same function). The running thread
|
|
119
|
+
appends to its own queue directly. Any other thread posts the handle to a
|
|
120
|
+
small queue under a mutex and wakes the loop; the running thread moves
|
|
121
|
+
posted handles to its queue and timer heap at the start of each turn.
|
|
122
|
+
Nothing is lost, the handles one thread posts run in the order it posted
|
|
123
|
+
them, and a handle posted before the loop thread schedules its own runs
|
|
124
|
+
first (the loop thread takes in what was posted before it adds to its
|
|
125
|
+
queue), as with a single queue. Under the same mutex, `close()` marks the loop closed before
|
|
126
|
+
it closes the wake-up eventfd, so a late `call_soon_threadsafe` raises
|
|
127
|
+
instead of writing to a descriptor number that may belong to someone else
|
|
128
|
+
by then.
|
|
129
|
+
- **Everything else belongs to the owner.** Transports, listeners,
|
|
130
|
+
`add_reader` / `add_writer`, `create_server`'s listening and closing: a call
|
|
131
|
+
from a thread that does not own the loop raises `RuntimeError` (asyncio's
|
|
132
|
+
debug-mode message). While no thread runs the loop, such a call claims it
|
|
133
|
+
for its duration, so setting up on one thread and running on another
|
|
134
|
+
works; two threads using an idle loop at the same moment get the error
|
|
135
|
+
instead of a race. `Server.close()`, `start_serving()`, `loop.close()` and
|
|
136
|
+
the transports written in Python (TLS, pipes, subprocesses, UDP) check
|
|
137
|
+
before they change anything (a TLS record encrypted but never sent would
|
|
138
|
+
break the connection).
|
|
139
|
+
- **`close()` and `abort()` are the exception.** The garbage collector runs
|
|
140
|
+
finalizers on whichever thread triggers it, and finalizers close
|
|
141
|
+
transports (`StreamWriter.__del__`, aiohttp's connections). A refused
|
|
142
|
+
close would leave the connection open for good, with nothing left that
|
|
143
|
+
refers to it. From a thread that does not own the loop, `close()` and
|
|
144
|
+
`abort()` of every transport are handed to the loop's thread
|
|
145
|
+
(`call_soon_threadsafe`).
|
|
146
|
+
- **Questions are answered on every thread.** `get_protocol()`,
|
|
147
|
+
`get_extra_info()`, `is_closing()`, `is_running()`, `is_closed()`,
|
|
148
|
+
`loop.backend`. (`get_write_buffer_size()`, `is_closing()` and
|
|
149
|
+
`is_reading()` do not crash either, but they read plain flags and
|
|
150
|
+
counters: from another thread the answer may be a moment old.) The object
|
|
151
|
+
fields the others read (protocol, socket, extra info) are written under
|
|
152
|
+
the transport's critical section; the socket object behind
|
|
153
|
+
`get_extra_info` is created outside it (creating one makes system calls,
|
|
154
|
+
and a critical section is given up whenever its thread blocks) and
|
|
155
|
+
published with a compare-and-set.
|
|
156
|
+
- **`Handle.cancel()` works from every thread.** The handle's fields are read
|
|
157
|
+
and cleared under its critical section; the cancelled flag is atomic.
|
|
158
|
+
Cancelled timers are counted for the heap clean-up exactly on the loop's
|
|
159
|
+
thread and in a separate atomic counter on other threads.
|
|
160
|
+
- **Free-threaded builds.** The extension declares that it does not need the
|
|
161
|
+
GIL (`freethreading_compatible`), so 3.14t keeps the GIL off. One loop per
|
|
162
|
+
thread is the intended way to use several cores: each loop has its own
|
|
163
|
+
ring, buffers and queues and shares nothing with the others. Give each
|
|
164
|
+
loop's server `reuse_port=True` to let the kernel spread connections. On
|
|
165
|
+
the regular build the critical sections compile to nothing; the owner
|
|
166
|
+
check is a thread-id comparison.
|
|
167
|
+
|
|
168
|
+
## Choices that matter
|
|
169
|
+
|
|
170
|
+
- **The submission queue cannot overflow.** A turn may queue more requests
|
|
171
|
+
than the 1024-entry queue holds (one send per connection that wrote). A
|
|
172
|
+
full queue is handed to the kernel part-way through the turn; if the
|
|
173
|
+
kernel does not take it, further requests go to a side list that is fed
|
|
174
|
+
in, in order, before the loop sleeps again. A queued request is never
|
|
175
|
+
overwritten or dropped: the send buffers are pooled, so a lost or
|
|
176
|
+
repeated send could put one connection's bytes on another.
|
|
177
|
+
- **The end of a connection has its own poll.** A multishot recv can miss
|
|
178
|
+
it. The kernel parks the recv after a receive that emptied the socket and
|
|
179
|
+
wakes it for the next event. But when data and the peer's close (or reset)
|
|
180
|
+
both arrive before the loop takes its completions, the two wake-ups count
|
|
181
|
+
as one: the recv reports the data, finds the socket empty, and waits for a
|
|
182
|
+
wake-up that has already happened. TCP hides this for an orderly close (a
|
|
183
|
+
FIN counts as pending input), not for a reset, and not on Unix sockets
|
|
184
|
+
(seen on Linux 6.17). The connection would stay open for good. So every
|
|
185
|
+
transport also holds one one-shot poll for "peer closed or reset"
|
|
186
|
+
(`POLLRDHUP`; errors and hang-ups are always reported). When it fires, the
|
|
187
|
+
recv is cancelled and armed again, and a new recv reads the socket before
|
|
188
|
+
it parks: what is there arrives, in order, then the end. The cancel waits
|
|
189
|
+
for the end of that run of completions, because after an orderly TCP close
|
|
190
|
+
the recv usually reports the end itself a few completions later and
|
|
191
|
+
nothing needs cancelling. The cost is one request per connection, none
|
|
192
|
+
per message.
|
|
193
|
+
- **Stopping settles.** When `stop()` ends the run, the loop sends what the
|
|
194
|
+
last turn wrote and takes the completions of what it closed, so those
|
|
195
|
+
sockets are closed when `run_until_complete()` returns. Under asyncio a
|
|
196
|
+
write reaches the socket at once and a closed transport's socket is closed
|
|
197
|
+
in `connection_lost`; a program may not run the loop again for a while.
|
|
198
|
+
- **Signals are checked before sleeping.** The loop is not bytecode, so
|
|
199
|
+
Python-level signal handlers run only when it asks: before each wait and
|
|
200
|
+
whenever a wait returns `EINTR`. That is what makes Ctrl-C (and
|
|
201
|
+
`asyncio.Runner`'s SIGINT handling) work on an idle loop.
|
|
202
|
+
- **Scheduling is two lists and a native heap.** `call_soon` appends to a
|
|
203
|
+
list; a turn swaps it for an empty one and runs the batch, so callbacks
|
|
204
|
+
scheduled by callbacks wait for the next turn. Timers are a binary heap of
|
|
205
|
+
the `TimerHandle` objects themselves, ordered by (time, insertion) on C
|
|
206
|
+
fields; both containers are ordinary lists, so the garbage collector still
|
|
207
|
+
sees everything a pending callback keeps alive.
|
|
208
|
+
- **Writes wait for the end of the turn.** `transport.write()` copies into
|
|
209
|
+
an output buffer and marks the transport dirty; the next I/O turn flushes
|
|
210
|
+
every dirty transport before entering the kernel. With io_uring, N
|
|
211
|
+
responses plus the wait for new input are one syscall. If more than the
|
|
212
|
+
high-water mark is buffered, the transport first tries a plain
|
|
213
|
+
non-blocking `send`, so a large response does not pause the protocol for
|
|
214
|
+
nothing.
|
|
215
|
+
- **Output is copied, into pooled buffers.** A transport takes a buffer from
|
|
216
|
+
a per-loop pool on its first write and gives it back when the send
|
|
217
|
+
completes, so idle connections hold none. The copy is ~1 µs for 16 KiB.
|
|
218
|
+
A multi-megabyte `write()` is copied once too; sending large buffers by
|
|
219
|
+
reference is not done.
|
|
220
|
+
- **Accept is inline.** `protocol_factory()` and `connection_made()` run
|
|
221
|
+
from the accept completion: no coroutine, no Task, no socket object per
|
|
222
|
+
connection (one is created only if `get_extra_info("socket")`,
|
|
223
|
+
`"peername"` or `"sockname"` is asked for, and then with the listener's
|
|
224
|
+
family, so the socket module asks the kernel nothing). `TCP_NODELAY` is
|
|
225
|
+
set once, on the listening socket; accepted sockets inherit it.
|
|
226
|
+
- **Accept is not multishot.** A multishot accept keeps its place at the
|
|
227
|
+
head of the listening socket's queue of waiters. Of several processes
|
|
228
|
+
that accept on one socket (`uvicorn --workers`, gunicorn, any pre-fork
|
|
229
|
+
server) the one that armed first would then get every connection, also
|
|
230
|
+
while it is busy and the others idle. A one-shot accept that is queued
|
|
231
|
+
again after each connection goes to the back of that queue, and the
|
|
232
|
+
kernel deals the connections round. It costs one request per connection.
|
|
233
|
+
- **`pause_reading()` is lazy under io_uring.** The multishot recv stays
|
|
234
|
+
armed, so a pause that ends within the same turn costs nothing. If data
|
|
235
|
+
does arrive while paused it is kept, and only then is the recv cancelled
|
|
236
|
+
so TCP backpressure reaches the peer.
|
|
237
|
+
- **Closing cancels, then closes.** An io_uring request holds its file open,
|
|
238
|
+
so `close(fd)` alone would leave the socket open while a recv is armed.
|
|
239
|
+
The transport cancels its outstanding requests when it closes (one
|
|
240
|
+
cancel for everything on the descriptor) and closes the socket when their
|
|
241
|
+
final completions have arrived, usually within the next turn. Not earlier: a request still queued names the socket by its
|
|
242
|
+
descriptor number, and a number freed too early can be given to a new
|
|
243
|
+
connection (or any file another thread opens), which would then receive
|
|
244
|
+
this connection's bytes. A listener closes its socket at once when the
|
|
245
|
+
kernel takes the queued requests at once, else when its accept's final
|
|
246
|
+
completion arrives. Under epoll the socket is removed from the epoll set
|
|
247
|
+
explicitly and closed at once. `loop.close()` cancels every request of
|
|
248
|
+
the ring and lets the kernel finish them before the ring goes, so a
|
|
249
|
+
listening address is free, and peers see the end, when `close()` returns.
|
|
250
|
+
- **Each connection has its own context.** As under asyncio, every accepted
|
|
251
|
+
connection runs its protocol callbacks (the factory included) in its own
|
|
252
|
+
copy of the context the server started listening in, so a context
|
|
253
|
+
variable one connection sets is not seen by the next. A run of
|
|
254
|
+
completions for the same connection costs one Enter/Exit.
|
|
255
|
+
- **Input is delivered from inside the I/O turn.** `data_received` and the
|
|
256
|
+
other protocol callbacks of a socket run while the loop goes through its
|
|
257
|
+
completions, before the callbacks queued with `call_soon` in the previous
|
|
258
|
+
turn. asyncio queues both in arrival order. The one place where programs
|
|
259
|
+
rely on the order is kept: `await loop.create_connection()` (and
|
|
260
|
+
`connect_accepted_socket`, `create_unix_connection`) returns before the
|
|
261
|
+
first `data_received`; for these, reading starts one turn later, behind
|
|
262
|
+
the waiting task's step.
|
|
263
|
+
- **`sendfile` is `sendfile(2)`.** On a plain connection `loop.sendfile`
|
|
264
|
+
takes the transport's write side (a `write()` meanwhile raises, as under
|
|
265
|
+
asyncio), waits for buffered output to leave, and calls `sendfile(2)` on
|
|
266
|
+
the socket, waiting for "writable" in between (a one-shot poll under
|
|
267
|
+
io_uring, `EPOLLOUT` under epoll). Over TLS, and for files that are not
|
|
268
|
+
regular files, it copies through `write()`.
|
|
269
|
+
- **TLS is one object and one hop.** Ciphertext goes from the raw transport
|
|
270
|
+
into OpenSSL's memory BIO; plaintext is read straight into the
|
|
271
|
+
application's buffer (`BufferedProtocol`) or handed over as one `bytes`.
|
|
272
|
+
`writelines` becomes a single TLS record. The read loop stops when neither
|
|
273
|
+
the BIO nor OpenSSL has bytes left instead of reading until
|
|
274
|
+
`SSLWantReadError`; this relies on OpenSSL taking one record at a time
|
|
275
|
+
from the BIO (no read-ahead, which `ssl` never enables).
|
|
276
|
+
|
|
277
|
+
## Known gaps
|
|
278
|
+
|
|
279
|
+
- In a forked child a loop of the parent is closed: create a new one. A
|
|
280
|
+
fork hook (`os.register_at_fork`) does it. The ring's queues and the epoll
|
|
281
|
+
set are shared with the parent, so the child must not queue a request or
|
|
282
|
+
change a registration: the hook drops the child's references without a
|
|
283
|
+
word to the kernel, closes the child's copies of the connections, and
|
|
284
|
+
leaves listening sockets to whoever holds them (a pre-fork server passes
|
|
285
|
+
them to the child's own loop).
|
|
286
|
+
- A loop cannot move to another thread while a send is in flight (see
|
|
287
|
+
above).
|
|
288
|
+
- On kernels before 6.10 a turn in which more than 256 connections have
|
|
289
|
+
input runs out of receive buffers. The recvs that got none end with
|
|
290
|
+
`ENOBUFS` and are armed again: correct, with some extra work. (Newer
|
|
291
|
+
kernels hand out at most 20 completions per call, so it does not happen
|
|
292
|
+
there.) A kernel-allocated buffer ring (6.4) could be larger.
|
|
293
|
+
- Ubuntu's 6.8 kernels (seen: 6.8.0-1070-azure, GitHub's `ubuntu-22.04`
|
|
294
|
+
runners) carry a backport of the buffer ring registration in which the
|
|
295
|
+
test of the reserved bytes is inverted (`!memchr_inv(reg.resv, ...)`): the
|
|
296
|
+
correct call gets `EINVAL`. `ll_ring_init` then repeats the call with one
|
|
297
|
+
reserved byte set, which only such a kernel takes. The same defect is in
|
|
298
|
+
the unregister call, which the engine does not use (closing the ring
|
|
299
|
+
frees the buffer ring).
|
|
300
|
+
- Pipes, subprocesses and UDP are correct but not accelerated.
|
|
301
|
+
- Not done: zero-copy send, registered file descriptors, large writes by
|
|
302
|
+
reference, a Cython build of `tls.py`.
|
|
303
|
+
- The engine is Linux only. Elsewhere `ringloop.run()` and
|
|
304
|
+
`new_event_loop()` use asyncio's loop (`ringloop.NATIVE` is false).
|
|
305
|
+
- `Handle.cancel()` from another thread does not wait for a callback that is
|
|
306
|
+
already running (asyncio's handle for `call_soon_threadsafe` holds a lock
|
|
307
|
+
while it runs).
|
|
308
|
+
|
|
309
|
+
## Tests
|
|
310
|
+
|
|
311
|
+
- `tests/`: this repository's suite, every test on both backends. It also
|
|
312
|
+
smoke-tests uvicorn/Starlette, FastAPI, aiohttp and httpx on the loop.
|
|
313
|
+
- `conformance/run_cpython.py`: CPython's own asyncio test suite
|
|
314
|
+
(`Lib/test/test_asyncio`) on ringloop. Every way that suite makes a loop
|
|
315
|
+
is pointed at `ringloop.Loop`. The tests that examine private parts of
|
|
316
|
+
asyncio's own loop classes are listed in `conformance/cpython_skips.txt`,
|
|
317
|
+
each with its reason; an entry that matches no test is reported. CI runs
|
|
318
|
+
it on both backends.
|
|
319
|
+
- Kernels: CI runs the suite with io_uring on the runners' own kernels
|
|
320
|
+
(7.0, and Ubuntu's 6.8) and, in a virtual machine (virtme-ng), on the
|
|
321
|
+
latest 6.1, 6.6 and 6.12 kernels.
|
|
322
|
+
- `tests/soak`: a server under clients that misbehave, then a check that
|
|
323
|
+
nothing is left behind.
|
|
324
|
+
- The fallback on systems other than Linux: CI installs the package on macOS
|
|
325
|
+
and Windows and runs a program on it.
|
|
326
|
+
- [Stario](https://stario.dev)'s suite, through `ringloop.pytest_plugin`
|
|
327
|
+
(run by hand before a release).
|
|
328
|
+
|
|
329
|
+
## Checking a change
|
|
330
|
+
|
|
331
|
+
```sh
|
|
332
|
+
python setup.py build_ext --inplace && python -m pytest # both backends
|
|
333
|
+
python conformance/run_cpython.py --lib ../cpython/Lib # CPython's asyncio suite
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
`--lib` is the `Lib` directory of a CPython source tree of the version that
|
|
337
|
+
runs the tests (`git clone --depth 1 --branch v3.14.0
|
|
338
|
+
https://github.com/python/cpython`).
|
|
339
|
+
|
|
340
|
+
On a free-threaded build (3.14t), run the suite with
|
|
341
|
+
`-W error::RuntimeWarning`: importing an extension that would turn the GIL
|
|
342
|
+
back on then fails instead of warning.
|
|
343
|
+
|
|
344
|
+
Memory errors in the C and Cython code (AddressSanitizer + UBSan, GCC):
|
|
345
|
+
|
|
346
|
+
```sh
|
|
347
|
+
CFLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer -O1 -g" \
|
|
348
|
+
LDFLAGS="-fsanitize=address,undefined" python setup.py build_ext --inplace --force
|
|
349
|
+
LD_PRELOAD=$(gcc -print-file-name=libasan.so) ASAN_OPTIONS=detect_leaks=0 \
|
|
350
|
+
PYTHONMALLOC=malloc python -m pytest tests/test_loop.py tests/test_robustness.py
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
Performance against another checkout (built in place), interleaved:
|
|
354
|
+
|
|
355
|
+
```sh
|
|
356
|
+
python bench/ab.py --base ../ringloop-main --inproc --instructions # scheduling, timers, TLS: instructions per operation
|
|
357
|
+
python bench/ab.py --base ../ringloop-main --micro # req/s and CPU per request under bench/loadgen
|
|
358
|
+
python bench/ab.py --base ../ringloop-main --churn # server CPU per connection, one request each
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
Against other loops (uvloop, rloop, asyncio), on one thread and with one
|
|
362
|
+
loop per thread on a free-threaded build:
|
|
363
|
+
|
|
364
|
+
```sh
|
|
365
|
+
python bench/compare.py --pipeline 1,16 # one thread
|
|
366
|
+
python bench/compare.py --python python3.14t --threads 1,2,3 --work 300 # one loop per thread
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
`--instructions` counts user-space instructions with `perf_event_open`, so
|
|
370
|
+
it gives the same answer on a busy machine; use it for anything smaller than
|
|
371
|
+
a few percent. `bench/run.py` and `--micro` print the server's instructions
|
|
372
|
+
per request next to the time-based columns for the same reason.
|
|
373
|
+
`bench/README.md` lists every tool.
|
ringloop-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Adam Bobowski
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# The extension is built from the .pyx with Cython; the generated C is not shipped.
|
|
2
|
+
include src/ringloop/*.pyx src/ringloop/*.h
|
|
3
|
+
exclude src/ringloop/_core.c
|
|
4
|
+
include DESIGN.md
|
|
5
|
+
include CHANGELOG.md
|
|
6
|
+
recursive-include tests *.py *.c *.sh
|
|
7
|
+
recursive-include conformance *.py *.txt
|