From 4b5176e2c3040491c2b60cc9f321081a4a60e9c9 Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 27 Apr 2026 18:20:10 -0400 Subject: [PATCH] Doc future-subint payoffs for `_subint_forkserver` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Future arch — what subints would buy us" section to the module docstring, complementing the prior commit's current-state rationale. Code is unchanged. Frames the `subint` prefix as family-naming today (no actual subinterp is created yet), then lays out the three concrete wins that land once jcrist/msgspec#1026 unblocks PEP 684 isolated-mode subints: - Cheaper forks — moving the parent's `trio.run()` into a subint shrinks the main-interp COW image the child inherits. The main interp becomes the literal forkserver: an intentionally-empty execution ctx whose only job is to call `os.fork()` cleanly. - True parallelism — per-interp GIL means the forkserver thread on main and the trio thread on subint actually run in parallel. Spawn latency stops stalling the trio loop. - Multi-actor-per-process — the architectural payoff. With per-interp-GIL subints, one process can host main + N subint-resident actor `trio.run()`s, and `os.fork()` reverts to the last-resort spawn (only when OS-level isolation is actually needed). Joins the story with the in-thread `_subint.py` backend: `subint` → in-process spawn, `subint_forkserver` → cross-process when a real OS boundary is required. (this commit msg was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tractor/spawn/_subint_forkserver.py | 67 +++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/tractor/spawn/_subint_forkserver.py b/tractor/spawn/_subint_forkserver.py index 731e16b3..cd83dac0 100644 --- a/tractor/spawn/_subint_forkserver.py +++ b/tractor/spawn/_subint_forkserver.py @@ -113,6 +113,73 @@ threads here are heavier than `trio.to_thread.run_sync` calls — see the "TODO" section further down for the audit plan once those upstream pieces land. +Future arch — what subints would buy us +--------------------------------------- + +The `subint` in this module's name is **family-naming +today** — currently the implementation only uses a regular +worker thread on the main interp; no subinterpreter is +created anywhere in the parent or child. The naming becomes +*literal* once jcrist/msgspec#1026 unblocks isolated-mode +subints (PEP 684 per-interp GIL). Three concrete wins land +at that point: + +**(1) Cheaper forks (smaller main-interp COW image)** + +Today the parent's main interp carries the full tractor +stack: trio runtime, msgspec codecs, IPC layer, every +user module the actor imported. When the forkserver +worker calls `os.fork()` the child inherits ALL of that +as COW memory — even though most gets overwritten when +the child boots its own `trio.run()`. + +Move the parent's `trio.run()` into a subint (its own +`sys.modules` / `__main__` / globals) and the main +interp **stays minimal** — just the forkserver-thread +plumbing + bare CPython. The main interp becomes the +*literal* forkserver: an intentionally-empty execution +context whose only job is to call `os.fork()` cleanly. +Inherited COW image shrinks proportionally. + +**(2) True parallelism between forkserver and trio +(per-interp GIL)** + +Today the forkserver worker and the trio.run() thread +share the main GIL — when one runs the other waits. +Spawn requests briefly stall trio while the worker +takes the GIL to call `os.fork()`. PEP 684 isolated- +mode gives each subint its own GIL: forkserver thread +on main + trio on subint actually run in parallel. +Spawn latency drops, trio loop doesn't notice the +fork happening. + +**(3) Multi-actor-per-process (the architectural prize)** + +The bigger payoff and the reason `_subint.py` (the +in-thread `subint` backend) exists in parallel with +this module. With per-interp-GIL subints, one process +can host: + +- main interp: forkserver thread + bookkeeping +- subint A: actor 1's `trio.run()` +- subint B: actor 2's `trio.run()` +- subint C: ... + +`os.fork()` becomes the **last-resort** spawn — used +only when a new OS process is actually required +(cgroups, namespaces, security boundary, multi-host +distribution). Within a single process, subint-per- +actor is radically cheaper: no fork, no COW, no +inherited-fd cleanup — just `_interpreters.create()` ++ `_interpreters.exec()`. + +The two backends converge on a coherent story: +`subint` → in-process spawn (cheap, GIL-isolated), +`subint_forkserver` → cross-process spawn (when you +truly need OS-level isolation). The forkserver isn't +the default mechanism; it's the bridge to a new +process when subint isolation isn't enough. + Implementation status — what's wired today -----------------------------------------