SEDA bus · Python · 0.2.0

Does the SEDA model finally pay off without the GIL?

seda-bus-python exists specifically to test free-threaded CPython (PEP 703). Under the GIL, sharing a thread pool across CPU-bound stages is close to pointless — threads don't run bytecode in parallel. This port is benchmarked in both a free-threaded build and a standard GIL build, so the difference is measured, not assumed.

Worker pool
concurrent.futures.ThreadPoolExecutor
True stage parallelism
Only under free-threaded CPython (PEP 703); GIL builds serialize
Dependencies
ra-common only
Envelope
ra_common.Envelope (rewired at 0.2.0)
Routing slip
DynamicRoutingSlip — LIFO
Concurrency permits
threading.BoundedSemaphore per channel
Guaranteed delivery
No — in-memory only
Batch size
16 envelopes per drain
Lifecycle
Context manager (__enter__/__exit__) or explicit start()
Race/sanitizer verified
Not run

How it implements the design

The Enum-and-Protocol version of the shared design.

The queue is a collections.deque guarded by a threading.Condition; consumers are a Consumer Protocol (runtime-checkable) or any callable, wrapped internally. Delivery and Backpressure are Enums; ergonomics lean into keyword arguments — bus.channel(name, capacity=..., concurrency=..., delivery=..., backpressure=..., max_attempts=...).

As of the 0.2.0 rewire, it carries ra_common.Envelope rather than its own struct; seda_bus.envelope re-exports it and adds make_envelope/ target_service ergonomic helpers over the richer type's LIFO routing slip. Per-hop attempts moved off the envelope onto the channel, keyed by id, since ra_common.Envelope carries none.

The port's own README documents the real payoff this benchmark exists to test: on genuinely CPU-bound work (a hashcash/fib workload, not this benchmark's near-zero-cost consumer), 24 envelopes through one stage scale roughly 2.6× across a 12-core pool on free-threaded CPython (python3.14t, PYTHON_GIL=0), after a roughly 1.3× single-thread tax — while under the GIL, adding workers to the same workload makes it slower.

Results

Free-threading wins on this benchmark too — just not for the reason you'd guess.

Capacity curve — sustained throughput under real, bounded back-pressure
BuildConfigSustained eps0.5×1.0×1.5×
3.14t (free-threaded)cap1100,8680.920.940.67
cap825,3660.950.950.79
3.13 (GIL)cap141,1260.950.950.68
cap816,6480.940.940.89
Firehose throughput (envelopes/sec, unthrottled — diagnostic, not capacity planning)
BuildConfigepsvs. seq
3.14t (free-threaded)seq104,602
par45,2630.43×
chan270,9042.59×
3.13 (GIL)seq51,649
par28,4170.55×
chan35,2850.68× (collapse)

Reading the numbers

This benchmark's consumer barely uses the CPU — which is itself the finding.

This benchmark's consumer does almost no CPU work, so these numbers are mostly about lock/GIL contention under a shared channel, not the CPU-bound benefit the port's own README measures. Under this corrected, construction-excluded methodology, free-threaded's par is a real 0.43× collapse rather than the roughly flat number an earlier, less careful pass measured — removing the shared lock (chan) still shows the real signal, a 2.59× gain with no contention point left to hide behind.

The GIL build's collapse is real in both configurations (par 0.55×, chan 0.68×) — the GIL serializes bytecode execution regardless of channel topology. Both Python variants' cap8 holds up better than cap1 at 1.5× overload, but for an unglamorous reason: cap8's absolute sustained ceiling is 4–6× lower than cap1's to begin with, so 150% of a small number is easier to approach than 150% of a large one.

The full cross-language results, including the GIL-vs-free-threaded discussion

Source

Read the code, or the rest of the family.

seda-bus-python on GitHub — source, pytest suite, and the raw-interpreter microbenchmarks in bench/.