Re: Threads.... the long story, and a proposal

[email protected] (Leon Timmermans)
Newsgroups perl.perl5.porters
Message-ID <CAHhgV8i4J_QJJb5JpQotCZDMp0XL+bHimZLFHCO2cO2vv5eppQ@mail.gmail.com>
On Fri, Sep 4, 2026 at 5:13 PM Laurent <[email protected]> wrote:

> Hi,
>
> Here is a study on the threads support in Perl and a proposal to improve
> it.
> I updated https://github.com/Perl/perl5/issues/18093 about this as well.
>
> The proposal involves 4 parts:
> * a Perl Core API for stateful green threads ("execstate")
> * a Perl Core API for stateless green threads (savestack "parkapi")
> * a Perl Core endorsement for Perl multicore XS sync OS threads ("bracket")
> * a Perl Core API for Perl multicore XS async OS threads ("offload")
>
> The 3 first ones are derived from existing mature CPAN modules, and
> the last one is a new experimental proposal, complementary to the
> others.
> All those are "just" allowing core to provide robust APIs allowing
> external modules to actually implement the threading (and those APIs
> are independent of those implementations).
>
> Everything discussed in this document has been prototyped (with
> improvements on the official/upstream threading backends, out of scope
> for what Perl core is concerned, but important from an ecosystem
> perspective so all this does not just stay at the theoretical level,
> and works in all main platforms, with a good level of feature parity
> between the thread implementations):
> * execstate+multicore bracket/offload api:
> https://github.com/laurent-aml/Perl-perl5/tree/execstate
> * savestack parkapi:
> https://github.com/laurent-aml/Perl-perl5/tree/savestackAPI
> * multicore  bracket/offload implementation:
> https://github.com/laurent-aml/Perl-Coro-Multicore
> * stateful green thread implementation:
> https://github.com/laurent-aml/Perl-Coro
> * stateless green thread implementation:
> https://github.com/laurent-aml/Perl-Future-AsyncAwait
> * FFI::Platypus adapted for multicore bracket:
> https://github.com/laurent-aml/Perl-FFI-Platypus
> * PDL adapted for multicore offload:
> https://github.com/laurent-aml/Perl-PDL/commits/offload/
> * Future with safe_cancel support:
> https://github.com/laurent-aml/Perl-Future
> * Safe cancel support for FAA (for the sake of completion):
> https://github.com/laurent-aml/Perl-Future-With
>
> Here it is...
>
> https://github.com/laurent-aml/Perl-perl5/blob/green-threads-rfc/Porting/green_threads_and_execstate.md
>
> # Threads, green threads, and a core execution-state API for Perl
>
> ## Purpose of this document
>
> This document studies the state of *threading* in Perl — both senses of the
> word: the several models by which Perl programs run more than one thing at
> a
> time, and the interpreter *execution state* that a thread of Perl
> execution is
> made of. It is written to motivate and frame a specific proposal: that perl
> core grow a small, additive, **public** API describing a thread's execution
> state, so that the green-thread libraries that today reach into private
> interpreter internals can instead stand on a supported contract.
>
> It is the rationale companion to `Porting/execstate_api.md`, which
> specifies the
> API itself: the "execstate" ladder, which publishes the interpreter state a
> green-thread switch has to carry. This document argues *why*; that one
> defines *what*. The reader is assumed to know Perl and C but not the
> internals
> of Coro or of the perl run loop.
>
> The argument runs: define what a thread is → separate concurrency from
> multi-core parallelism → survey how Perl does each today (ithreads, process
> pools, green threads) → observe where performance actually lives (XS) and
> what
> that implies for the model people want → look at how other languages
> resolve the
> same tensions → identify why the current Perl green-thread libraries are
> fragile
> → propose the core API that removes the fragility.
>
> ## What a thread is
>
> Following the ordinary computer-science definition (as summarised on
> Wikipedia):
> a **thread of execution** is the smallest sequence of programmed
> instructions
> that can be managed independently by a scheduler. Multiple threads within a
> single process run *concurrently* and *fully share the process's memory*
> and
> resources (address space, file descriptors, globals), in contrast to
> separate
> processes, which do not share those resources. A thread is therefore
> characterised by three things:
>
> 1. an independent **flow of control** — its own instruction pointer and
> call
>    stack;
> 2. **shared memory** with the other threads of the process; and
> 3. **lightweight lifecycle management** — cheap to create, and cheap for a
>    scheduler to start, suspend, resume, switch between, and join.
>
> Nothing in that definition says *how* the scheduler runs the threads. It
> may
> interleave them on one CPU (time-slicing or cooperative yielding), or it
> may run
> them literally at the same instant on several CPUs. Both are threads.
>
> ## Concurrency is the requirement; multi-core parallelism is a bonus
>
> It is worth stating plainly, because much confusion in the Perl community
> (and
> elsewhere) comes from conflating the two: **simultaneous execution on
> multiple
> cores is not part of the definition of a thread.** A thread requires
> *concurrency* — the ability to make progress on several flows of control
> whose
> lifetimes overlap — which is a *structuring* property. *Parallelism* — two
> instructions genuinely retiring in the same cycle on two cores — is a
> *performance* property, and an additional capability layered on top.
>
> The two are routinely provided separately. A great deal of software is
> threaded
> for concurrency alone and never runs two threads at once: a GUI keeps its
> event
> loop responsive while a "worker thread" waits on I/O; a network server
> keeps ten
> thousand connections in flight though only one is ever computing. Many
> runtimes
> deliberately *withhold* parallelism from their threads (Python's GIL,
> Node's
> single loop, cooperative green threads) precisely because withholding it
> removes
> whole classes of data race and lets threads share mutable memory safely.
>
> Parallelism is nonetheless the strongest possible bonus: it is the only
> way to
> make a CPU-bound program faster on modern hardware. So the interesting
> design
> question for any language is not "threads or not" but "which axis —
> concurrency,
> parallelism — do I get from which mechanism, and at what cost to shared
> state."
> Perl answers that question with several mechanisms, each landing at a
> different
> point on those axes.
>
> ## Current pure-Perl solutions
>
> ### Interpreter threads (`ithreads`)
>
> Perl's built-in `use threads` (interpreter threads, since 5.8) provides
> real OS
> threads. But it deliberately does *not* satisfy point (2) of the thread
> definition — it does not share memory. Creating an ithread **clones the
> entire
> interpreter**: every variable, pad, and package is deep-copied into the new
> thread, and thereafter the two are independent unless a variable is
> explicitly
> declared `:shared` (which routes every access through a lock and a
> serialised
> backing store).
>
> - **Pros.** They are genuine OS threads, so CPU-bound pure-Perl code
> *can* run on
>   multiple cores — the one Perl mechanism that gives shared-nothing
> parallelism
>   without a second process. No GIL, because there is nothing shared to
> lock.
> - **Cons.** The clone is expensive in both time and memory (a fresh
> interpreter
>   per thread), so thread creation is heavy and thread counts stay small.
> The
>   "shared memory" that makes threads pleasant is gone: `:shared` data is
> slow,
>   limited in type, and easy to get wrong; most CPAN modules are not
> thread-safe
>   across a clone; and the model is error-prone enough that `perldoc
> threads`
>   officially discourages its use for new code. It is, in effect,
> "processes that
>   look like threads," with most of the cost of processes and little of the
>   convenience of threads.
>
> Note the contrast with Python, drawn out below: Python threads *share*
> memory but
> *serialise* execution (the GIL); Perl ithreads *do not share* memory but
> *do*
> run in parallel. They are opposite trade-offs, and neither is the
> shared-memory,
> parallel thread people often imagine they are asking for.
>
> ### Process pools with IPC
>
> The pragmatic mainstream answer to *parallelism* in Perl is not threads at
> all
> but **processes**: `fork` a pool of workers and communicate over pipes,
> sockets,
> or shared files. A rich CPAN layer packages this:
>
> - `Parallel::ForkManager` — the classic fork-a-worker-per-job pool.
> - `MCE` (Many-Core Engine) — chunked map/grep-style parallelism over a
> worker
>   pool, with shared queues.
> - `AnyEvent::Fork` / `AnyEvent::Fork::RPC` — fork a *template* process
> once,
>   cheaply, then spawn workers from it and drive them from an event loop.
> - `IO::Async::Function` — a pool of worker processes exposed as an
>   asynchronous function returning a `Future`, integrated with `IO::Async`.
>
> - **Pros.** True parallelism across all cores, with no interpreter-thread
>   fragility. Hard fault isolation: a worker that segfaults or leaks
> does not take
>   the parent down. No shared-mutable-state races — the isolation is
> enforced by
>   the OS. Scales to and beyond the machine (the same pattern extends to
> remote
>   workers).
> - **Cons.** No shared memory: every datum crossing a process boundary must
> be
>   *serialised*, sent, and deserialised, which dominates the cost for
>   fine-grained work or large working sets. `fork` itself is not free,
> copy-on-
>   write notwithstanding, and is problematic on Windows and inside threaded
>   parents. Coordinating many workers, back-pressure, and partial failure
> is real
>   work that the CPAN modules ameliorate but do not erase.
>
> Processes are the right tool for coarse-grained, CPU-bound, embarrassingly
> parallel work. They are a poor tool for keeping ten thousand mostly-idle
> I/O
> flows alive, or for sharing a big in-memory index.
>
> ## What "green" threads bring
>
> **Green threads** are threads scheduled in user space, inside one OS
> thread of
> one process, by a library rather than the kernel. They *do* satisfy all
> three
> points of the definition — independent control flow, fully shared memory
> (they are all the same interpreter), and very lightweight management —
> while
> deliberately declining multi-core parallelism: at most one green thread
> runs at
> any instant, and control passes between them cooperatively.
>
> That combination is exactly what the process-pool approach lacks. Because
> every
> green thread lives in the same interpreter, they share all memory for free
> — no
> serialisation, no marshalling, a coro can hand another a reference to a
> multi-gigabyte structure at no cost. Because switching is a user-space
> register
> swap rather than a kernel context switch or an interpreter clone, they are
> cheap
> enough to have tens of thousands of them. The price is that they are
> *cooperative* and *single-core*: a green thread that neither yields
> nor blocks on
> a scheduler-aware operation starves all the others, and no green thread
> ever
> speeds up a CPU-bound computation, because they never run at the same time.
>
> Perl has two mature green-thread libraries, built on opposite mechanisms:
>
> - **Coro** — *stackful* coroutines. Each coro has its own real C stack and
> its
>   own copy of the interpreter's execution stacks; a switch saves one set of
>   those and installs another, and (when needed) swaps the C stack too.
> Because a
>   coro owns a full stack, it can suspend **anywhere** — deep inside nested
>   function calls, inside a `map` block, even inside an XS call that calls
> back
>   into Perl — simply by yielding. `$coro->cede`, `Coro::Semaphore`,
> channels,
>   etc. are built on this.
> - **Future::AsyncAwait (FAA)** — *stackless* async/await. `async
> sub`/`await`
>   transforms a subroutine into a resumable state machine; suspension is
> only
>   possible at an explicit `await` on a `Future`, and only within an `async
> sub`,
>   not across an ordinary Perl or C frame. It integrates with the `Future` /
>   `IO::Async` ecosystem and reads like modern async code in other
> languages.
>
> ## Stackful vs stackless: the technical difference
>
> The two libraries answer one question differently: *what is a suspended
> thread,
> concretely, and what must be preserved to resume it?*
>
> - **Coro (stackful)** treats a suspended thread as an entire live execution
>   context frozen in place. To resume it you restore the interpreter's
> execution
>   registers — the value/mark/scope/save/temporaries stacks, the current op
> and
>   COP, the pad, the compile-time cursors, the flags — and the C stack the
> coro
>   was running on, and jump back in. Suspension is transparent to the code
> being
>   suspended: it does not know or care that it is a coro. This is powerful
>   (suspend anywhere, including under XS) and it is O(1) in the amount of
> live
>   state — you swap pointers to whole stacks, you do not walk them.
>
> - **FAA (stackless)** treats a suspended thread as a captured
> *continuation of an
>   async sub*: enough saved state to re-enter that sub at its `await`
> point. It
>   does not own a C stack, so it cannot suspend through a frame that is
> not itself
>   an `async sub` — a plain sub, or an XS function, on the call chain
> is a barrier.
>   Capturing the state is proportional to the depth being suspended (it must
>   freeze the relevant save-stack region), i.e. O(n) rather than O(1), but
> the
>   per-thread footprint is much smaller because there is no reserved C
> stack.
>
> Several differences follow from that — some bearing on how much core
> support
> each model needs, some on what the programmer may actually write:
>
> - **Creation.** Coro spawns *explicitly* — `async { ... }` (or a
> constructor)
>   puts a new coro on the scheduler. FAA has no spawn primitive at all:
> calling an
>   `async sub` runs it synchronously to its first `await`, and if the
> awaited
>   Future is not yet ready the frame suspends and hands a *pending
> Future* back to
>   its caller. A concurrent thread therefore exists precisely when such a
> pending
>   Future is left in flight rather than awaited inline — concurrency arises
> from
>   the event loop driving several outstanding Futures, not from any create
> call.
> - **The caller stack.** A stackful coro keeps its real call stack, so the
>   Perl-level `caller()` chain and argument frames are simply *there*
> on resume. A
>   stackless FAA thread has unwound the real stack at each suspension, so
> it must
>   *reconstruct* — simulate — that caller chain when it resumes, so
> that code in a
>   resumed `async sub` still sees a coherent call history. That simulation
> is
>   extra machinery a stackful design does not need at all.
> - **Cancellation.** Cancelling a suspended coro means unwinding a real,
> live
>   dynamic scope, and Coro offers two flavours. *Prompt* cancel (`cancel`)
> tears
>   the coro down at once from the canceller's context; *safe* cancel
>   (`safe_cancel`) unwinds the coro in its **own** context at a
> cancellable point,
>   so its `local` restorations, `DESTROY`s and `finally`/guard blocks run
> in the
>   correct order **and may themselves block** — a guard can cede and wait
> while
>   cleaning up. That cleanup is *allowed to block* is the whole point of
>   `safe_cancel` over prompt cancel (and it is the delicate teardown
> this document
>   keeps returning to).
>
>   A stackless FAA thread has no such stack to unwind. Synchronous cleanup
> — a
>   `defer` block, or a guard object's `DESTROY` — still runs, but it cannot
>   `await`, so *asynchronous* cleanup on cancellation (cleanup that must
> itself
>   `await`) has no shipped mechanism today: it is an open proposal, the
>   `Future::With` approach — not (yet) a CPAN module; see RT #171969
>   (https://rt.cpan.org/Public/Bug/Display.html?id=171969). So the
> contrast is
>   sharper than a difference of syntax: Coro already runs blocking cleanup
> on
>   cancellation, via `safe_cancel`, whereas for FAA asynchronous
> cancellation
>   cleanup is still unsolved.
> - **Dynamic scope and implicit variables.** Because a stackful coro
> carries its
>   *entire* dynamic scope with it, `local`, `$_`, `@_`, `wantarray`, and
>   `await`-ing inside a `map`/`grep`/`sort`/`eval` block behave exactly
> as they do
>   in ordinary code — the coro resumes with the same dynamic state it left.
> A
>   stackless FAA thread does not own that scope, and so carries a set of
>   documented limitations. The sharpest is that a plain `local` does not
> compose
>   with `await` — its effect would remain in force for whatever runs during
> the
>   suspension — which is why the await-aware `dynamically` keyword
>   (`Syntax::Keyword::Dynamically`) had to be introduced as its replacement.
>   `$_` and other implicit/global variables are likewise not reliably
> preserved
>   across an `await`; call context is subtle, because an `async sub` hands a
>   `Future` back in its caller's context and only produces its real value
> later,
>   so `wantarray` inside it does not mean what it does in an ordinary sub;
> and
>   there are further restrictions on where an `await` may appear. The
>   Future::AsyncAwait documentation carries the current list. These
>   are less defects than the standing price of *not* carrying the full
> execution
>   state — precisely the state a stackful coro swaps wholesale, and
> precisely what
>   a core execution-state API would let it swap through a supported surface.
> - **Adoption cost — function "coloring."** For a sub to `await`, it must
> be an
>   `async sub`, and its callers must `await` it (or be `async`
> themselves), so the
>   annotation propagates up the *entire* call graph — the well-known "what
> colour
>   is your function" problem — and, as above, `local` must be rewritten as
>   `dynamically`. Adopting FAA in an existing code base is therefore an
> invasive,
>   transitive source rewrite. Coro is *colorless*: unchanged, ordinary subs
> can
>   cede or block, because suspension need not appear in the signature of
> every
>   function on the stack — the same property Go's goroutines and Java's
> virtual
>   threads are prized for.
> - **Explicit vs implicit suspension — knowing where you can block.** The
> mirror
>   image of coloring, and here the advantage is FAA's — though narrower
> than it
>   first appears. Coro is *cooperative*, not preemptive: a coro yields only
> where
>   it (directly or transitively) calls something that cedes, so the
> programmer
>   still controls where suspension may happen, and between those points
> code runs
>   to completion with nothing else interleaving. That controlled atomicity
> is
>   itself a feature for avoiding concurrency bugs. What Coro lacks is not
> control
>   but *visibility at the call site*: a plain method or sub call does
> not advertise
>   whether it might cede somewhere inside, so in code one did not write
> it is hard
>   to tell which calls are suspension points. FAA makes that explicit —
> every
>   suspending call is an `await` — and, more strongly, *structurally
> forbids*
>   suspension where `await` is not allowed: inside a `DESTROY`, a tie or
> overload
>   method, a signal handler, cleanup and magic are guaranteed synchronous.
> Coro
>   cannot make that guarantee — a `DESTROY` run at an arbitrary refcount
> drop or
>   during global destruction *may* cede and switch coros at an unsafe
> moment, with
>   no parse-time way to detect or forbid it. So the honest gap is call-site
>   visibility, and the un-checkable no-suspend contexts, not any loss of
> control
>   over suspension itself.
>
>   The second half of that gap can be closed from Coro's side, and an
>   experimental (as-yet-unpublished) `Coro::Atomic` does so: an `atomic {
> ... }`
>   section — equivalently a scope guard, or an `:Atomic` sub attribute —
> marks a
>   region the running coro must not yield in. It is enforced
> *deterministically*
>   at Coro's scheduler entry points (so it catches C-level blocking such as
> a
>   semaphore or condvar wait too, not just an explicit `cede`), before any
>   ready-queue state is touched, so a stray suspension inside it — including
>   inside a `DESTROY` — is a fatal error rather than a silent switch. This
> does
>   not make cede-ability *visible* at every call site (that would need
> FAA-style
>   coloring), but it lets cleanup and critical sections *guarantee* no
>   suspension, which is the property that most mattered — the complement of
> "flag
>   what may block" is "forbid blocking here," and it composes with dynamic
>   dispatch where a static scheme cannot.
> - **Scheduling.** Coro is a *scheduler*, not only a suspension mechanism:
> it has
>   a ready queue, thread priorities, explicit `cede` / `schedule`, and
> time-sliced
>   yielding (such as the clock-based `cede_slice` — as-yet-unpublished
> work — which
>   lets a long computation yield cooperatively with no external timer).
> FAA has no
>   scheduler of its own —
>   it yields suspendable tasks and leaves *when* they run to the driving
> event
>   loop, with no built-in cooperative-yield or time-slice primitive. FAA is
> the
>   suspension syntax; Coro is suspension *plus* a scheduler.
> - **Native tooling — and here the debit is Coro's, but a modest one.** It
> is
>   tempting to say C-level debuggers "cannot follow" a coro; that
> overstates it.
>   C-level debugging of a *Perl* program is mostly about one's own XS, not
> about
>   stepping perl's run loop (rarely useful), and that still works: a
> breakpoint in
>   an XS function fires normally under Coro, and while stopped you can
> inspect that
>   frame and walk the current coro's C stack up to where it was
> entered, because a
>   *running* coro's C stack is an ordinary contiguous stack. What the
> private
>   stacks and the hand-written switch actually cost is the *cross-coro*
> picture: a
>   C backtrace does not continue coherently past a coro's entry (the
> frames beneath
>   it are on another stack, and the switch carries no unwind information),
> the C
>   stacks of *suspended* coros are not walkable at all, and sampling
> profilers get
>   confused at switch boundaries. So the loss is whole-program C backtraces
> and
>   clean cross-coro profiling — not the everyday "break in my XS and
> look around."
>   The *Perl* debugger fares well too. Tested (perl 5.40, Coro 6.57):
> it runs Coro
>   programs, breakpoints fire in every coro, and — since a running coro's
> stack
>   *is* the live interpreter stack — variable inspection and `T` backtraces
> are
>   correct for whichever coro is stopped; what it cannot do is confine
> control flow
>   to one coro, since its step/continue state is global, so `c`/`n`/`s`
> follow the
>   interpreter across every `cede` and hop between coros. (Coro ships its
> own
>   `Coro::Debug` — a coro-listing, backtrace and remote-shell introspector
> — for
>   exactly this reason.) FAA, on the ordinary C stack, is friendlier
> still. A real
>   cost of the stackful model, but a narrower one than "debuggers cannot
> follow
>   Coro" would suggest.
>
> For all those differences, Coro and FAA share the one property that
> matters most
> here: each must reach into interpreter state that Perl does not expose as
> API.
> Coro copies the `PL_*` execution registers and manipulates the
> context-stack and
> padlists directly; FAA freezes and thaws a region of the save stack. The
> two
> reach into the same private machinery from opposite ends — Coro swapping
> the
> *whole* state, FAA freezing a *slice* of it.
>
> ### Is FAA the simpler, "solved" answer? An honest reckoning
>
> One reading colours much of this discussion and should be named directly:
> that
> Future::AsyncAwait is the clean, modern successor whose async/await makes
> Coro's
> stackful "hackery" unnecessary, so core need only bless FAA and let Coro
> fade.
> On the axes this document is about, that reading does not hold up, and
> it is more
> honest to say so plainly.
>
> FAA does simplify one real thing: being stackless it never touches the
> C stack or
> the context stack, so it needs no per-platform assembly switch backend, is
> correspondingly more portable to build, has a smaller per-suspension
> footprint,
> and integrates natively with the `Future`/event-loop world. Those are
> genuine
> merits, and they are why many reach for it first.
>
> But "simpler in that dimension" is not "simpler," and it is certainly not
> "internals-free." FAA depends on the *same class* of unpublished
> interpreter
> state as Coro — it freezes and thaws the save stack — so it does not
> escape the
> problem that motivates a core API; it reaches into it from the other end.
> And a
> *complete* FAA needs machinery Coro does not: simulating the `caller()`
> stack
> across suspension, and a dedicated construct for asynchronous
> cancellation. The
> net is that the core surface needed to underwrite FAA is *larger* than the
> one
> needed to underwrite Coro — the opposite of the intuition. Both surfaces
> are
> specified in *The proposal: publish the execution state as a core API*
> below, and
> named throughout: **execstate** swaps a whole execution state, which is
> what a
> stackful library needs, and **parkapi** freezes and thaws a region of the
> save
> stack, which is what a stackless one needs. FAA needs parkapi, plus
> the `caller()`
> simulation and the cancellation construct just described.
>
> Nor does FAA solve any *technical* problem that Coro has. Both are
> single-core and
> cooperative; neither adds parallelism. Both depend on interpreter
> internals;
> neither is inherently stable across releases. And FAA is not even a
> superset: it
> is the suspension *syntax*, not a scheduler, so it has no
> `cede`/`cede_slice`
> equivalent of its own and leans on the event loop for *when* things
> run. What FAA
> changes is the *trade*, not the ledger: it buys visible, explicit
> suspension
> points and portability, at the cost of Coro's transparency. It cannot
> suspend
> through a non-`async` or XS frame; it gives up dynamic-scope fidelity
> (`local`,
> `$_` and friends need `dynamically`, or do not survive an `await`); and it
> *colors* the call graph, so adopting it means an `async`/`await` rewrite
> up the
> whole chain — where Coro is colorless. The trade runs the other way in
> two places.
> One is tooling: Coro's private C stacks cost the cross-coro view —
> whole-program C
> backtraces and clean profiling across coros — though a breakpoint in your
> own XS
> still fires, and the Perl debugger, while usable, is not coro-aware;
> FAA sits on a
> normal C stack. The other is explicitness: FAA's visible `await`
> points make every
> suspension site visible at the call and *structurally* bar suspension where
> `await` is not allowed (a `DESTROY`, say). Coro is still cooperative — the
> programmer controls where yielding happens — but cede-ability is not
> visible at a
> call site, and suspension in an un-checkable context like `DESTROY` cannot
> be
> forbidden at parse time. None of this reads as "one is simpler"; it reads
> as two
> different bargains.
>
> So the two are not "hacky old" versus "clean new." They are
> stackful-transparent
> versus stackless-explicit — each capable, each buying one property
> with another —
> and neither makes the execution-state problem disappear. If anything
> the smaller,
> more self-contained core commitment is the one the stackful model needs,
> and it
> happens to enable the more capable (suspend-anywhere, scope-faithful)
> style. The
> serious answer is to support both styles on one published foundation —
> execstate
> for the stackful half, parkapi for the stackless — not to bet that one
> style's
> syntax dissolves the other's requirements.
>
> ## Where the performance is: "Pure Perl orchestrates, XS executes"
>
> To see what model Perl users actually want, look at where time is spent.
> The
> Perl interpreter is, for tight numeric or byte-processing loops, one to
> several
> orders of magnitude slower than C. The community learned long ago not to
> fight
> this but to *arrange around it*: performance-critical work is pushed into
> **XS**
> (or into C reached through `Inline::C`, `FFI::Platypus`, or an existing XS
> module), while Perl itself does the gluing, control flow, configuration,
> and
> I/O. PDL, `JSON::XS`, `Text::CSV_XS`, DBD drivers, the regex engine, and
> the
> whole "\_XS" tail of CPAN exist for this reason.
>
> The consequence for parallelism is decisive. The place where running on
> multiple
> cores actually pays is the **XS/C** computation, not the pure-Perl
> orchestration.
> Pure Perl spends most of its wall-clock either waiting on I/O (a
> concurrency
> problem, not a parallelism one) or delegating to C. So the model most Perl
> programs would benefit from is:
>
> - **Pure Perl orchestrates** — one interpreter, single-threaded, using
> **green
>   threads** to keep thousands of I/O flows and delegated computations in
> flight
>   concurrently, and using **process pools** where coarse pure-Perl
> parallelism is
>   genuinely needed;
> - **critical-performance XS executes** — and, when a piece of XS is
> CPU-bound or
>   makes a blocking syscall, it does so on a **real OS thread**, off the
> single
>   interpreter thread, so it can either run on another core or simply not
> stall
>   the scheduler.
>
> This is precisely the pattern that works so well elsewhere. In Python, the
> numeric stack (NumPy, and friends) *releases the GIL* around its C
> kernels: Python
> stays single-threaded and safe, while the heavy C runs on OS threads
> and uses all
> the cores. The Perl analogue is: keep one interpreter, let green
> threads schedule
> the Perl side, and let blocking or CPU-bound XS run on OS threads.
> Threading *the
> XS part* — not the Perl part — is what most people are really after.
>
> ### PDL: the pattern already shipped — and where it stops
>
> PDL is the closest thing Perl has to NumPy, and it is worth looking at
> because it
> demonstrates both halves of this split *and* the seam between them.
>
> It demonstrates the orchestration half so thoroughly that it is
> packaged that way:
> alongside its plain 32- and 64-bit editions, Strawberry Perl publishes
> a dedicated
> **PDL edition** — an entire Perl distribution assembled around "Perl
> orchestrates,
> XS executes", with the numeric stack and its supporting libraries
> prebuilt. That
> is about as direct an endorsement of the model as an ecosystem can give.
>
> It also already does the *parallel* half, without any help from perl.
> Built with
> POSIX threads, `set_autopthread_targ` / `set_autopthread_size` split an
> operation's implicit-loop dimensions across a pool of pthreads that
> execute only
> generated C and never touch the interpreter. (Confusingly, PDL
> historically called
> that implicit looping "threading"; it is now called *broadcasting*,
> precisely
> because it is a loop-shape feature and not a CPU-count one. The
> pthread support is
> the separate, genuinely parallel thing.)
>
> What PDL does **not** do is release the interpreter. During a
> pthreaded operation
> the calling perl thread sits inside the XS call holding the interpreter, so
> nothing else in the program advances: a program that is otherwise
> happily running
> thousands of green threads stops dead for the duration of a large PDL
> op. That is
> exactly the gap the two *multicore* modes fill, both specified in *A
> companion: a
> core multicore hook* below. In the **release/acquire bracket** the XS
> module hands
> the interpreter to another OS thread for the duration of a pure-C
> section and takes
> it back at the end; in **offload** the interpreter stays where it is
> and the work
> moves to a worker thread instead. PDL is close to the ideal candidate
> for either:
> for the ops that matter its parallel loop is already known not to
> touch perl data,
> which is the hard precondition both need — a per-op property rather
> than a blanket
> one.
>
> The two mechanisms are orthogonal and compose: PDL's pthreads
> parallelise *within*
> one operation, while multicore overlaps that operation with everything
> else the
> interpreter has to do. Together they are the whole model.
>
> Which of the two multicore modes to use is decided by the platform, and
> for PDL
> the answer on Windows is the interesting one. The release/acquire bracket
> there
> needs the non-ithreads build (see *Windows: the ithreads build is the
> obstacle*),
> which the PDL edition — a Strawberry build — is not. **Offload does not**:
> it
> never migrates the interpreter, so none of that applies. It is
> therefore the route
> that leaves the shipped Windows distribution, and its whole prebuilt
> module set,
> exactly as it is.
>
> The distinction worth being precise about is between ithreads being
> *built* and
> being *used*, and it is worth being careful about which sense of "thread"
> is
> meant. Offload is indifferent to the build flags — an ithreads-enabled
> perl is
> fine — and it is equally indifferent to *OS* threads, because the
> interpreter
> stays on its own thread by construction; that is the whole point of
> the primitive.
> Nothing here asks the program to stay on a particular thread.
>
> What the existing backend cannot serve is a second **live interpreter** — a
> limitation of that implementation rather than of the hook, as *Not
> inherently
> single-interpreter* below sets out. Coro imposes the same restriction
> independently of multicore, for its own reasons: use it from the first
> interpreter
> only.
>
> So the run-time constraint is not "recompile perl without ithreads",
> and not "keep
> off other OS threads" — it is "do not start a second interpreter", i.e.
> leave
> `threads->create` alone. That costs a PDL workload nothing, since its
> parallelism
> comes from pthreads inside the C loop rather than from perl-level threads.
>
> Offload also decouples PDL from Coro specifically. The release/acquire
> bracket
> needs a suspended C frame to return into, so it serves the stackful model
> only;
> offload serves the stackless one as well, and PDL is close to the best
> case for it
> — for exactly the reason that made `done()` cheap above. Where a
> marshalling
> module has to rebuild its result from the caller's argument stack (see the
> FFI::Platypus appendix, where that is fatal under Future::AsyncAwait), a
> PDL
> operation writes into an output ndarray that already exists as an SV
> before the
> computation starts. Nothing has to be *produced* at resolution time,
> so nothing is
> lost when the caller's frame is gone: `done()` needs only the
> transformation and
> the output ndarray, both captured in the job at call time.
>
> Getting there is real work, and of a different shape from the Coro case:
>
> - **Retained lifetimes.** A suspended coro holds its ndarrays alive on its
> own
>   stack; an awaiting FAA caller does not. The job has to retain every
>   participating ndarray and release them in `done()`.
> - **PDL is eager, so this is surgery, not a bracket.**
> `pdl_make_trans_mutual`
>   runs the transformation inline unless dataflow is enabled. An async
> entry point
>   needs a third path — set the transformation up, do *not* run it, return
> the
>   deferred, run on a worker, resolve — and that path must compose with
> the existing
>   dataflow branch, which is already a second deferral mechanism on the
> same object.
> - **A mutation window the stackful path does not have.** While the
> worker reads the
>   input buffers, a suspended coro cannot touch them; an FAA caller has
> not stopped
>   and can modify its own inputs, in the same flow of control. That is
> a data race
>   Coro structurally cannot produce, and it wants a mechanism rather than a
>   documentation note — an in-flight flag on participating ndarrays that
> makes
>   mutation fail loudly, in the spirit of the existing untouchable-data and
>   transient-busy flags.
>
> None of that is exotic: refcount discipline, one restructured decision,
> one new
> guard. The point is that the *hard* part — delivering a result without the
> caller's stack — is already how PDL works, so a well-implemented offload
> gets
> both concurrency models rather than only Coro.
>
> **What the Coro half took.** It has been done, on a PDL branch. Under a
> stackful
> backend the eager path is not an obstacle: PDL takes the synchronous form
> of the
> primitive, which waits for the handle before returning, so the calling
> green thread
> is suspended for the duration and the transformation still returns with
> its data
> computed. No third evaluation path is needed, and the job may stay on the
> frame.
> The whole hook is one site — the forward call in `pdl__ensure_trans`,
> which becomes
> work-plus-done around `readdata` — behind an eligibility check
> (declines an op that
> declares itself unsafe to parallelise, and anything under a size
> threshold, since
> the handshake costs a mutex, a condvar and an event-loop turn).
>
> What took the work was everything the worker is not allowed to do:
>
> - **Auto-pthreading composes rather than competes**, but only once the
> scratch
>   allocation is hoisted out. PDL's fan-out sizes and allocates the
> per-pthread
>   temporaries from inside the loop; doing that on a worker means
> allocating through
>   perl. Sized and made physical before the hand-over, the in-loop pass
> finds them
>   ready and allocates nothing — and then the op gets its cores *and* the
>   interpreter stays free, which is the whole point of the model.
> - **Deferred diagnostics have to travel home.** A pthread buffers its
> warnings for
>   the thread that spawned it to replay; when that thread is the
> worker, the replay
>   itself would call perl. They now travel back with the offload and are
> replayed
>   once the caller is resumed. This path turned out never to have been
> exercised —
>   neither by PDL's own suite nor by any shipping op — and carried three
> defects,
>   including one that made an offloaded op with no fan-out hang.
> - **Cancellation reaches the fan-out.** The backend's advisory flag is
> carried in
>   thread-local state that the cast copies into the pthreads it spawns, so
> every
>   thread of the fan-out polls the same word, and the poll sits in the
> broadcast
>   loop. A stopped transformation reports it as an error and is marked as
> such,
>   because PDL otherwise *re-runs* a pending transformation when it is
> destroyed —
>   which quietly recomputed the whole thing while the exception was
> unwinding.
>
> Measured on a 200-million-element reduction: the interpreter runs other
> green
> threads throughout, four pthreads work under the offload, results
> match the inline
> path exactly, and a cancellation 60ms in stops the work within 50ms.
> The hook costs
> about 9 microseconds a call, of which the thread handshake — a mutex, a
> condvar,
> the wakeup pipe and an event-loop turn — is about 7, and the handle
> the remaining
> 1.4: a hash with a few keys, two or three references, two method calls
> into perl
> and a destructor. Against the smallest op the eligibility check lets
> through, a
> 1-million-element reduction at some 545 microseconds, the handle is a
> quarter of
> one percent, and nothing smaller is offloaded at all.
>
> None of this waits on core, in the sense that the mechanism does not need
> it.
> Offload could reach PDL the way the release/acquire bracket already
> reaches CPAN
> today — a vendored header and a rendezvous in `PL_modglobal` — and because
> the
> header runs `work` then `done` inline when no backend is installed, a PDL
> so
> patched behaves identically for everyone who has no backend loaded. What
> that
> route gives up is the single authority for a contract still under
> change; see the
> core-hook section for what that costs. What a PDL edition actually needs
> is a
> backend and the patch; the core hook is the same neutrality argument
> made for the
> bracket, not a precondition.
>
> Which does place a caveat on the prototype described below: it puts the
> offload
> hook in core proper, so *as prototyped* it does need a patched perl. Note
> which
> fallback that gives and which it does not. A missing *backend* is
> handled for you —
> the call runs inline, blocking but correct — but a missing *hook* is not:
> on an
> unpatched perl the declarations are simply absent, so a consumer has
> to guard its
> offload path at compile time, much as Coro::Multicore already guards
> its own. That
> is an implementation choice about where the rendezvous lives, not a
> limit on what
> is reachable from CPAN.
>
> ### Coro::Multicore
>
> `Coro::Multicore` implements exactly this: it is the release/acquire
> backend for
> the hook, coupling Coro's cooperative scheduler to a pool of real OS
> threads so
> that **a blocking C section and the rest of the program run at the same
> time**.
>
> A multicore-enabled XS module calls `perlinterp_release` before its
> blocking
> section, and Coro::Multicore hands the interpreter to a pool thread,
> which resumes
> the other coros there. The C section itself does not move: it carries on,
> on the
> thread it was already running on, which is now a thread without an
> interpreter.
> When it finishes, the module's matching `perlinterp_acquire` takes the
> interpreter
> back and the coro continues. So the Perl level stays single-threaded and
> cooperative — no shared-interpreter races — while the C level gains both
> I/O
> concurrency and, for CPU-bound C kernels, real multi-core execution.
>
> For this to be useful, individual XS modules opt in — marking the
> points at which
> they may safely let the interpreter go while their C runs. Work
> in this vein includes multicore-aware modes for database drivers (e.g. a
> `DBD::SQLite` option), XML parsing (`XML::LibXML`), and generic foreign
> calls
> (`FFI::Platypus`), each letting its blocking C run off the interpreter
> thread
> under Coro::Multicore. The picture that results — Perl orchestrating, green
> threads scheduling, and OS threads carrying the heavy XS — is the whole
> model in
> one place.
>
> What makes this approach attractive is *how little* it asks of the module.
> Running a blocking XS routine on a separate OS thread is always possible in
> principle, but the general route is invasive: the module has to be
> rewritten to
> dispatch its C work to a thread pool and expose an asynchronous, callback-
> or
> `Future`-returning interface — its synchronous API, and usually its
> internals,
> must change. Coro::Multicore's release/acquire instead hands the
> interpreter to a
> worker for the duration of the module's existing blocking call, so an
> ordinary
> synchronous XS module can be upgraded with only a thin opt-in at its
> blocking
> entry points — its API and its C code stay as they are. That is why
> the multicore
> variants above are small adapters over unchanged modules rather than
> rewrites,
> where offload — multicore's other mechanism, specified below — would
> demand that
> same redesign.
>
> There is a natural generalization worth flagging, in the same spirit as
> the rest
> of this document: that opt-in need not be Coro-specific. What an XS module
> declares — *"I am about to run a blocking or CPU-bound C section that does
> not
> touch the interpreter; offer to run it off the scheduler thread"* — is
> precisely
> Perl's analogue of CPython's `Py_BEGIN_ALLOW_THREADS` /
> `Py_END_ALLOW_THREADS`
> bracket, which every C extension uses to release the interpreter around
> blocking
> work, independent of any one concurrency library. Perl core could
> expose the same
> neutral bracket as a public hook: the module marks its blocking C
> section against
> the *core* API, and whatever **backend** is loaded — Coro::Multicore
> today, a
> future core scheduler, or none — decides what to do: hand the interpreter
> to a
> worker OS thread so the other green threads run there, or, absent any
> backend, run
> the section inline and blocking exactly as today. Core would supply
> the convention
> and the hook point, not the thread pool or the migration machinery,
> which stay in
> the backend. Two constraints are inherent and worth stating: the
> bracketed section
> must be **pure C that does not call into the interpreter** (the same
> discipline
> `Py_BEGIN_ALLOW_THREADS` demands, since the rest of the program makes
> progress
> while it runs); and Perl's backend is intrinsically harder than
> Python's — Python
> releases the GIL and its other threads, which already exist with their
> own stacks,
> proceed while the C carries on where it is; Perl has one interpreter and
> no such
> threads waiting, so the backend has to move the interpreter itself and
> coordinate
> handing it back. Done this way an XS module would be written once to be
> multicore-friendly without depending on Coro at all — the same decoupling
> execstate performs for the stackful context switch, applied to the
> blocking-C
> boundary. It is a distinct, complementary proposal, not part of execstate,
> developed as *A companion: a core multicore hook* below.
>
> ## How other languages resolve the same tensions
>
> Every managed language faces the same three-way tension between shared
> memory,
> parallelism, and safety, and the designs cluster into a few recognisable
> answers. Perl's mechanisms are not exotic; they are points in this shared
> space.
>
> - **JavaScript / Node.js.** A single-threaded event loop with stackless
>   `async`/`await` (like FAA) for concurrency, and *no* shared-memory
> threads in
>   the language: parallelism comes from Web Workers / `worker_threads`,
> which are
>   separate heaps communicating by message passing (like Perl's process
> pools),
>   with only `SharedArrayBuffer` as a narrow shared-memory escape hatch.
>   Concurrency in-heap, parallelism out-of-heap.
>
> - **Python.** The richest menu, and the closest parallel to Perl's
> situation.
>   `threading` gives real OS threads that share memory but are serialised
> by the
>   **GIL**, so they help I/O-bound code and not CPU-bound code — hence the
>   release-the-GIL-in-C pattern described above. `multiprocessing` gives
>   process-pool parallelism (Perl's fork pools). `asyncio` is stackless
>   async/await (FAA/Node). `greenlet`/`gevent` are stackful green threads
> (Coro).
>   And Python is now dismantling the central constraint: per-interpreter
> GILs
>   (3.12) and an experimental free-threading, no-GIL build (3.13+) aim at
> genuine
>   shared-memory parallel threads.
>
> - **Go.** The design many hold up as the goal: **goroutines** are stackful
> green
>   threads with small growable stacks, multiplexed **M:N** onto a pool of OS
>   threads by the runtime's work-stealing scheduler. They give shared
> memory,
>   cheap creation *and* real multi-core parallelism at once, with
> communication by
>   channels (CSP). The cost is that this only works because the whole
> runtime and
>   memory model were co-designed for it — the GC, the scheduler, and the
> stack
>   layout all cooperate.
>
> - **Erlang / BEAM.** Massive numbers of green *processes* that share
> *nothing* and
>   communicate only by message; the scheduler is preemptive and
> multi-core. Safety
>   by isolation rather than by locking — the opposite end from Go.
>
> - **Java.** Long the canonical heavy-OS-thread, shared-memory, locked
> model;
>   Project Loom's **virtual threads** now add Go-style M:N green
> threads on top of
>   the same shared heap, letting old blocking code scale like async code.
>
> Read against this map, Perl has strong instances of the *concurrency*
> answers
> (Coro ≈ gevent/goroutine-shaped stackful; FAA ≈ asyncio/Node stackless)
> and of
> *out-of-heap parallelism* (fork pools ≈ multiprocessing/workers), but its
> one
> *in-heap parallel* mechanism, ithreads, took the shared-nothing clone
> route and
> is discouraged. The realistic near-term target is therefore the Python/Node
> consensus — single safe interpreter, green threads for concurrency, OS
> threads
> under the XS for parallelism — rather than a Go-style rewrite of the
> runtime.
>
> It is worth being explicit about why the *other* Python route — 3.13's
> free-threading, no-GIL build — is not an option for Perl, because it is the
> obvious "why not just remove the lock?" question and the answer is
> instructive.
> Perl has **no GIL to remove**: it never had shared-memory interpreter
> threads in
> the first place (ithreads *clone*), so the task is not to delete a lock
> but to
> *build* a thread-safe shared interpreter from scratch — strictly harder
> than
> CPython's job, not easier. That means making all of Perl's shared mutable
> internals safe for concurrent access: the reference counts on every SV, the
> magic/tie machinery, the pad system, the pervasive `PL_*` globals, and the
> dynamic-scope (`local`/save-stack) model that is
> per-thread-of-execution but acts
> on shared variables. PEP 703 needed exactly this class of work for CPython
> —
> biased and deferred reference counting, per-object locks, thread-safe
> containers,
> immortal objects, a reworked cycle collector — delivered over years by a
> large,
> funded team, with a single-thread performance cost and a multi-release
> migration.
> Perl has a fraction of that engineering capacity, arguably more entangled
> internals, and — decisively — a vast, old, largely unmaintained XS
> ecosystem that
> manipulates SV internals and refcounts with no notion of thread-safety;
> making
> the *core* safe would not make that code safe, and there is no one to
> port it the
> way CPython's top C extensions are being ported. And the payoff would be
> misaligned even if it were free: a free-threaded interpreter parallelises
> *pure-Perl* execution — the slow half one would rather delegate — whereas
> the
> parallelism that actually pays, the XS/C kernels, is already reachable
> by running
> XS on OS threads (the orchestrate/execute model above), the same
> release-the-lock-in-C pattern Python itself leans on. So the cost is far
> higher
> and the benefit far lower for Perl than for Python; the leverage is in
> threading
> the XS, not in un-threading the interpreter.
>
> ## The stability problem with Coro and FAA
>
> Both green-thread libraries work, and work well, but both are
> perennially at risk,
> and for the same underlying reason: **they depend on interpreter state and
> behaviour that core neither exposes as API nor promises to keep stable.**
>
> Coro is the sharper case because it swaps the *whole* execution state. It
> reads
> and writes the private `PL_*` execution registers directly; it overlays its
> saved state on the context stack, which requires exact knowledge of
> `PERL_SI` and
> `PERL_CONTEXT` layout; it clones padlists by hand, which the internal
> padlist
> representation has broken more than once; and it must tear a coroutine
> down in an
> exact order dictated by how perl's scope, context, and pad machinery
> interlock.
> Every one of these is a place where a perl release can — and historically
> does —
> change something private out from under it:
>
> - the save/scope/pad **teardown order** is a hard contract that *tightened*
>   between perl versions: unwinding in the wrong order leaves an outer
> frame's pad
>   slot stale and corrupts refcounts, asserting or segfaulting in
> `leave_scope` on
>   modern perls (a real, debugged corruption when a coro is cancelled while
>   blocked with a `local` in scope);
> - the save-stack `SS_MAXPUSH` accounting moved from header macros into
>   `PL_savestack_max` in 5.24;
> - the padlist representation changed across the `NEWPADAPI` and 5.15.3
> reworks;
> - and the low-level C-stack switch needs a hand-written assembly backend
> on some
>   platforms (it is required on arm64), while the portable
> `setjmp`/`longjmp`
>   backend is fragile enough that `Coro::Multicore` deadlocks on it.
>
> FAA is more robust in scope — stackless, so it never touches the C stack
> or the
> context stack — but it is fragile in kind for the same reason: it freezes
> and
> thaws a region of the **save stack**, and the save stack's per-type layout
> is an
> internal detail. Both libraries are, in effect, privately maintaining a
> fork of
> perl's understanding of its own execution state, and re-validating it
> against
> each release.
>
> The result is a recurring tax: green threading in Perl is powerful but has
> a
> reputation for breaking on new perls, needs per-version patching, and
> leans on a
> single expert's continued attention. The fragility is not in the *idea* of
> green
> threads; it is in the *coupling* to unpublished internals.
>
> ## The proposal: publish the execution state as a core API
>
> The fix is to move the interpreter-internals knowledge out of the
> libraries and
> into perl core, behind a small, additive, **public** API — so that a
> green-thread
> library calls a supported function instead of reaching into `PL_*`,
> and core owns
> (and keeps stable, and maintains next to the machinery it depends on) the
> contract those libraries need. Concretely, two complementary APIs,
> matching the
> two mechanisms:
>
> - **execstate** — the *stackful* half, for Coro. A small ladder of
> operations on
>   a thread's whole execution state, specified in
> `Porting/execstate_api.md`:
>   1. **register snapshot** — a transparent `PerlExecState` plus
> `execstate_save`
>      / `execstate_load` to copy the generic execution registers to and
> from the
>      live interpreter;
>   2. **fresh-stack lifecycle** — `execstate_init` / `execstate_unwind` /
>      `execstate_destroy`, which own the delicate teardown order once
> and for all;
>   3. **pad** — `execstate_derive_padlist` / `execstate_free_padlist` for
>      re-entering a sub on an independent context;
>   4. **transfer registers** — the `JMPENV` exception-handler state that
> must
>      follow a C-stack switch (`execstate_topenv` and friends).
>
>   Because it is a **capability ladder** gated by
> `PERL_EXECSTATE_LEVEL`, core can
>   adopt it incrementally: it advertises how far up it implements, and a
> consumer
>   backfills only the levels core lacks. Deliberately *out* of the API is
> the
>   machine-level C-stack switch itself — that is a pluggable,
> platform-specific
>   mechanism (the assembly/`ucontext`/`setjmp` backends) and rightly stays
> the
>   library's own; only the interpreter *state* that a switch must carry is
>   core's concern.
>
> - **parkapi** — the *stackless* half, for FAA and similar: a supported way
> to
>   freeze and thaw a save-stack region, so that async/await libraries stop
>   depending on the internal per-type save-stack layout. This half is now
>   **prototyped**: the freeze/thaw/frozen-free/run-deferred/foreach-sv entry
>   points cover the value-localization, pad-scope, deferred-action *and*
> magical
>   (`%ENV`/`%SIG`, whole and per-element) save types, and a prototype
> migration of
>   Future::AsyncAwait onto it — replacing its hand-written per-`SAVEt_`
> switch —
>   passes FAA's own test suite, including `local %ENV`/`%SIG` across an
> `await`.
>   See `Porting/savestack_suspend_api.md` for the specification.
>
> An honest note on their relative cost, since it cuts against the usual
> intuition. The stackful model, often dismissed as the hacky one, needs the
> *smaller* core API; the stackless model, usually held up as the clean
> modern
> design, needs the *larger* one. execstate is compact — a fixed register
> list, a
> mechanical save/load, a short lifecycle, all copies and pointer swaps.
> parkapi must understand the save stack *per save type*, freezing and
> thawing each
> `SAVEt_*` correctly, which is inherently more code and more coupling to
> internal
> semantics; and a *complete* stackless implementation needs still more
> that has no
> stackful analogue — it must **simulate the `caller()` stack** across
> suspension
> (a stackful coro keeps it for free), and **asynchronous cancellation
> cleanup** is
> not even solved yet: no shipped FAA mechanism provides it, only the
> proposed
> `Future::With` approach (not a CPAN module; RT #171969), where a stackful
> coro's
> `safe_cancel` gets blocking cleanup for free by unwinding its real
> dynamic scope.
> So underwriting FAA is the heavier commitment of the two — a larger, more
> internals-coupled core piece (parkapi) plus machinery Coro simply does
> not need —
> even though execstate is the one attached to the more contentious library.
>
> Both are designed to be **purely additive and ABI-safe**: `PerlExecState`
> is a
> standalone struct a consumer overlays on its own memory; the operations
> copy or
> walk existing state rather than changing any representation; no existing
> interpreter variable or struct is altered. Adopting them removes nothing
> and
> risks nothing for code that does not use them, while turning the
> green-thread
> libraries' most dangerous dependency — private, drifting internals — into a
> stable surface that core tests and maintains. The historically
> version-sensitive
> pieces (the teardown order, the `SS_MAXPUSH` accounting, the padlist
> handling)
> become core's responsibility, living beside the very code whose changes
> used to
> break Coro, so that the next such change updates the API in lock-step
> instead of
> silently breaking a CPAN module.
>
> ## A companion: a core multicore hook
>
> Separate from execstate, but in the same spirit, is a second, much smaller
> core
> addition: promoting the *Perl Multicore Specification*'s release/acquire
> bracket
> (see the Coro::Multicore discussion above) from a bundled CPAN header into
> core.
> It is included here because it is prototyped alongside execstate and
> rounds out
> the "thread the XS, not the interpreter" model — but it stands entirely on
> its
> own and could land independently.
>
> **The API.** A core `perlmulticore.h` gives an XS module the same three
> tokens
> it uses today, now core-provided:
>
> ```c
> perlinterp_release ();      /* offer to run the following pure-C
> section off the */
> do_the_blocking_C_thing (); /* interpreter thread; touch no perl data
> here       */
> perlinterp_acquire ();
> ```
>
> plus `perlmulticore_active()` and a registration entry point,
> `multicore_register(release, acquire)`, for a backend. With no backend
> installed
> the bracket is a nop and the section runs inline and blocking, exactly as
> today.
>
> Alongside the bracket the API carries its dual, the **offload** primitive.
> Where
> the bracket migrates the interpreter to a worker while the blocking
> call stays on
> the caller's thread, offload keeps the interpreter *pinned* and moves
> the **work**
> to a worker:
>
> ```c
> SV *handle = multicore_offload (work, work_arg,  /* pure C, on a worker
>  */
>                                 done, done_arg); /* holds the interpreter
> */
> ```
>
> `work` is pure C and touches no interpreter; `done` runs holding the
> interpreter
> when the work finishes and marshals the C result into an SV.
>
> What comes back is a **handle** supplied by the backend, and the work
> may still be
> running when it does. One shape, whatever the backend, is the point: a
> backend
> decides how completion is delivered — a stackful one (Coro) suspends the
> green
> thread that asks for the value, a stackless one (Future::AsyncAwait)
> resolves on
> its loop — and if that choice reached the module's return type, then
> offloading an
> existing synchronous method would change what that method returns as soon
> as an
> FAA backend was loaded, invisibly and at a distance. With one shape the
> module
> returns the handle without naming its class, and the caller decides: `await
> $handle` from an `async sub`, or `$handle->get`, which blocks —
> transparently
> under Coro, since it suspends only the calling green thread. The
> handle implements
> the `AWAIT_*` protocol of `Future::AsyncAwait::Awaitable`. That
> protocol asks only
> that the object answer to a set of method names — nothing has to inherit
> from
> anything, and neither core nor a backend has to depend on `Future`,
> which is why a
> plain `Future` and a backend's own class are interchangeable here.
>
> A module that wants the value rather than the handle — which is what a
> synchronous
> method needs — calls `multicore_offload_sync` instead, and the wait
> happens inside
> it. That is the whole of what an offload was before the handle
> existed, and it is
> what PDL uses.
>
> The core primitive is neutral about delivery: it forwards work/done
> and hands back
> the backend's handle, so a consumer needs *no* knowledge of Coro, FAA,
> Coro's
> rouse callbacks, or Future. Registered by a backend with
> `multicore_register_offload(fn)`; with none, `work` then `done` run
> inline and the
> value comes back in an already-resolved handle core supplies
> (`PerlMulticore::Handle`), so the shape holds even with nothing installed.
> A
> module that already has an answer — a cached result, an input too small to
> be
> worth a worker — wraps it with `multicore_offload_ready` for the same
> reason.
>
> **Lifetime.** `work_arg`, and whatever `work` writes into, must stay valid
> until
> the handle *resolves*, which is not the same as until the call returns.
> Since a
> portable consumer cannot tell whether the handle it got is pending,
> the rule is a
> biconditional: keep the job on your frame if and only if you wait on the
> handle
> before returning. `multicore_offload_sync` is that first case. A module
> that
> returns the handle upward heap-allocates its job and lets `done`
> release it — which
> is why `done` runs exactly once for every offload, including when the
> handle is
> dropped mid-flight (`done_ctx.dropped` says so, and the value is
> discarded). That
> last point was not in the design until PDL's asynchronous entry point
> was written
> against it, and leaked its job on precisely that path.
>
> Both callbacks are also handed a small context by the backend, which is
> what
> carries **advisory cancellation**: `work` polls a flag and may return
> early, and
> `done` is told that cancellation was *requested*. Only requested — the
> backend
> cannot see whether the work acted on it, since `work` returns nothing, and
> a
> request that arrives while the last chunk runs produces a whole result. So
> it is
> the module that knows, and the contract asks it to **raise** rather
> than return a
> truncated result its caller could not tell apart from a complete one:
> `multicore_offload_cancelled` builds the exception
> (`PerlMulticore::Cancelled`) so
> that every module raises the same one. Advisory is the most that can be
> offered,
> since C code cannot be interrupted from outside.
>
> The handle offers cancellation in two forms, differing in what happens to
> the
> interpreter while the work stops: a prompt one that blocks until it has,
> so that
> the offload is over when the call returns, and an asynchronous one
> (`safe_cancel`, optional) that hands back an awaitable completing at
> that point —
> which is what a stackless caller needs, since blocking its thread would
> stop the
> loop the completion arrives on.
>
> A backend that abandons a call in flight — an interrupted wait, or a
> pending handle
> dropped by a caller that wants neither the value nor the work — must
> wait for the
> worker before letting the caller's frame go; the flag is what keeps that
> wait
> short. A `work` that never polls is therefore not merely
> uninterruptible: it makes
> an interrupted caller wait out the whole operation.
>
> Offload is a *separate* hook, not an extension of the release/acquire
> struct
> (whose two-pointer layout is a frozen ABI); a pool-owning backend can
> register
> both.
>
> **What that costs, and what it need not have cost.** Because the bracket's
> rendezvous is a `PL_modglobal` struct of two `void (*)(void)` pointers, a
> module
> and a backend find each other there with no help from the interpreter,
> which is
> why the bracket reaches unpatched perls today. Offload as prototyped
> rendezvouses
> *inside* the interpreter instead, so it exists only on a perl built with
> it: a
> consumer must guard its offload path at compile time and keep an
> inline fallback,
> and a backend compiles its offload half out — which is what
> Coro::Multicore and
> PDL both do. A missing *backend* is still handled for the consumer
> (the work runs
> inline), but a missing *hook* is not.
>
> That is a choice about where the rendezvous lives, and it could be
> made the other
> way: a `PL_modglobal` struct with a function pointer, a vendored
> header, an inline
> fallback — the bracket's design exactly, needing nothing from the
> interpreter.
>
> What core supplies, and that route gives up, is one authority for the
> contract.
> The bracket needs no authority. Its contract is two `void (*)(void)`
> pointers,
> frozen, so vendoring the file verbatim is the whole of the agreement,
> and it cannot
> drift. Offload's contract is callback contexts with `size` fields, a handle
> protocol, cancellation rules and an ABI number, and it is still changing.
> Two
> vendored copies that disagree about the layout or the key do not fail to
> link.
> They misread each other's memory.
>
> Two lesser costs come with that route. Each consumer would carry its
> own class for
> the handle a backendless call hands back. That is harmless: the
> contract asks for a
> set of methods, never for a class name, so a caller cannot tell one
> consumer's
> class from another's. The second cost is that the synchronous wrapper's
> most
> delicate part would be copied into every consumer rather than fixed in
> one place —
> it releases the handle through a scope guard, so that the job is given
> up properly
> even when asking the handle for the value raises an exception, or when the
> green
> thread waiting on it is cancelled outright.
>
> One apparent way out does not work: shipping the shared pieces as a CPAN
> distribution. The backendless path exists for the user who has
> installed nothing,
> so it cannot depend on an installation.
>
> *PDL: the pattern already shipped* shows the same trade from a consumer's
> side.
>
> Offload's reach is broader than the bracket's, at a higher cost to the
> module.
> Because the worker never touches the interpreter and the interpreter
> never moves,
> offload serves **both** the stackful (Coro) and stackless
> (Future::AsyncAwait)
> models, and it works on **every platform, Windows included** — none of the
> interpreter-migration constraints that limit the bracket apply (see
> *Portability:
> on Windows* below). The price is **deeper changes to the XS module**, plus
> a
> **function-colouring** boundary the module must own. It restructures
> the blocking
> operation into a pure-C `work()` (inputs marshalled out of SVs beforehand)
> and a
> `done()` that marshals the result back — and, because a *stackless*
> caller cannot
> receive a value synchronously, a module that wants to serve one must
> expose the
> offloaded form as an explicitly **asynchronous** entry point (e.g. a
> `..._async`
> method returning the handle), distinct from the plain synchronous call. The
> synchronous one is not lost: it calls `multicore_offload_sync` and
> keeps returning
> a value, which under Coro is the transparent behaviour. So a generic
> XS module has
> to *add* an async twin for each offloadable method — the price of
> colouring, paid
> once per method rather than by changing what the existing one returns.
> The bracket
> remains the near-transparent upgrade for existing synchronous Unix+Coro
> modules,
> and offload is the portable, model-agnostic path for code written — or
> rewritten —
> for it.
>
> **Not inherently single-interpreter.** Nothing in the offload design
> ties it to one
> interpreter. The worker never touches perl, so it has no interpreter to be
> wrong
> about, and the registration side is already per-interpreter by
> construction: the
> hook struct lives in `PL_modglobal`, which ithreads clones along with
> everything
> else. Several live interpreters could therefore each register a
> backend and drive
> their own pool, or agree to share one.
>
> The existing backend does not, however. Coro::Multicore holds its job pool,
> wake-up pipe, enable flags and cached `Coro::API` pointer in process-wide C
> statics, so a second live interpreter finds either no backend registered
> at all
> or — if the module is loaded there too — one pool and one pipe shared
> between two
> interpreters. For practical purposes this costs nothing: ithreads is
> discouraged
> for the reasons given earlier, and Coro independently requires the first
> interpreter, so this is a corner nobody should be trying to occupy. It is
> worth
> recording only because the limitation belongs to the *backend* rather
> than to the
> hook — which would matter if core ever shipped a default backend of its
> own.
>
> The whole core surface is a short header, two interpreter-global hooks,
> and a
> handful of tiny functions — the hard machinery (the worker-thread pool, the
> handing of the interpreter between threads) stays entirely in the backend.
>
> **Backward compatible by construction.** The prototype keeps the
> *exact wire ABI*
> of the deployed CPAN `perlmulticore.h`: the two hooks live in a struct in
> `PL_modglobal["perl_multicore_api"]` with the field names `pmapi_release` /
> `pmapi_acquire`, and core caches a pointer to that same struct. So
> everyone meets
> at one rendezvous, and nothing downstream needs changing: an
> already-installed
> **Coro::Multicore drives a module built against core's header** (it writes
> the
> struct core reads), and a core-`multicore_register` backend drives existing
> bundled-header modules (it writes the struct they read). In particular
> Coro::Multicore itself needs **no update** — indeed it should keep its
> bundled
> header so it still works on perls that predate the core one. (A test
> installs a
> backend the CPAN way — writing the struct directly, as Coro::Multicore's
> BOOT
> does — and confirms core's bracket drives it.)
>
> **What the gain is — and is not, for the bracket.** Everything in this
> subsection
> is about the release/acquire half; offload is assessed separately
> below, and comes
> out differently. For the bracket, honestly, this is a modest, mostly
> ecosystem-level win, and it is worth being clear about its shape:
>
> - It adds **no new capability, no performance, and no behaviour** — the
> bracket
>   already works via the bundled header, and it still does nothing without a
>   backend installed.
> - The tangible benefit is that a new module uses a **core-provided,
> canonical,
>   neutral header** instead of vendoring schmorp's copy — and that the
> convention
>   becomes a *documented Perl capability* rather than "a CPAN header
> you copy in,"
>   decoupled from Coro. That legitimacy/neutrality is the real point,
> the same one
>   execstate makes.
> - Even the "stop vendoring" benefit is **deferred**: a module supporting
> perls
>   older than the one that first ships the header must keep a bundled
> fallback
>   until that perl is its minimum, so the practical saving arrives years
> later.
> - A genuine *future* upside it unlocks: because the hook is core's,
> core could one
>   day ship a **default backend** (even a minimal thread pool), so the
> bracket does
>   something useful out of the box without any CPAN backend at all.
>
> So for the bracket the stakes are far smaller than execstate's — a
> small vendored
> header versus the fragile, version-sensitive interpreter internals —
> but the move
> is the same: put the neutral convention in core so modules can depend on it
> without depending on Coro, and let perl own the contract.
>
> **For offload the gain is not modest, because there is nothing to
> canonicalise.**
> The bracket existed on CPAN before core had an opinion; offload does not
> exist
> anywhere else. As prototyped the hook *is* the capability: without it
> a module has
> no way to hand a C section to a worker thread and get an answer back in a
> shape
> that works under either concurrency model, and the stackless world has
> no route to
> multi-core blocking XS that does not go through Coro. What is modest here
> is the
> *core surface* — one function pointer, four small functions, an
> extensible context
> struct and a short perl class — not what it buys. The debit is the one
> named
> above: a consumer needs a perl that carries it, where a bracket consumer
> needs
> nothing. If offload were ever moved out to a `PL_modglobal` rendezvous
> like the
> bracket's, this paragraph would come to read like the one before it.
>
> **Portability: on Windows, offload — not the bracket.** The transparent
> release/acquire bracket works by *migrating the interpreter across OS
> threads* —
> the blocking call stays put while a worker thread picks up the interpreter
> and
> resumes other coros. On Unix that is fine. On Windows what stands in the
> way is
> the *standard build* rather than the platform: the obstacles are
> properties of the
> ithreads-enabled perl that everyone ships there, and a non-ithreads
> build removes
> the documented ones — see *Windows: the ithreads build is the obstacle*
> below,
> which reports what is measured there, including Win64 exception unwinding.
>
> The **offload** primitive is the way out that needs none of that, for
> exactly the
> reason given where it is introduced above: the interpreter never migrates,
> so no
> Windows constraint touches it, and the deeper XS restructuring it asks
> for is the
> price paid once for code that then runs on every platform and under both
> models.
> One Windows-specific point to add: since `fork` under a live worker pool is
> hazardous anyway, such a build should make Perl's emulated `fork` fail
> fast while
> multicore is enabled rather than let it clone into the pool. This is
> why the core
> hook is deliberately two-pronged — the transparent bracket where the
> platform and
> build allow interpreter migration, offload where they do not.
>
> ## Windows: the ithreads build is the obstacle
>
> It is tempting to read "Coro::Multicore does not work on Windows" as a
> statement
> about Windows. It is mostly a statement about the perl that Windows ships.
> Worth
> separating, because one of the two obstacles is removable and the other is
> not
> what it is usually taken to be.
>
> **What ithreads costs here.** Every mainstream Windows perl — Strawberry,
> ActivePerl — is built `useithreads=define usemultiplicity=define`, and
> that build
> also enables `PERL_IMPLICIT_SYS`, which routes even ISO-C calls such as
> `malloc`
> and `setjmp` through the `PerlHost` layer and so requires a `dTHX` to
> be in scope
> for them. That is the concrete referent of Marc Lehmann's "win32 perls
> are beyond
> fixing" comment at the top of `Multicore.xs`: not the operating system,
> but the
> implicit-sys indirection that the threaded build turns on. Separately,
> ithreads
> is *also* what supplies Windows' emulated `fork` — an interpreter cloned
> onto
> another thread of the same address space — which collides with a worker
> pool.
> Both are properties of the build.
>
> **The no-ithreads alternative.** `win32/GNUmakefile` exposes `USE_MULTI`,
> `USE_ITHREADS` and `USE_IMP_SYS` as three independent switches (each
> defaulting
> to `undef` when commented out, with `USE_MULTI` auto-enabled by either of
> the
> others). With all three off the picture inverts: `PERL_SET_CONTEXT`
> degenerates
> to assigning `PL_curinterp`, `dTHXa` expands to nothing, and the
> implicit-sys
> objection disappears entirely. The interpreter becomes one set of
> plain globals —
> the *simplest possible* substrate for handing it between OS threads,
> simpler than
> Unix-with-ithreads. The emulated `fork` disappears too, which removes the
> pool
> collision rather than merely mitigating it.
>
> All three off is the cleanest form, but only `USE_IMP_SYS` has to go for
> the
> implicit-sys objection specifically: the build actually measured below
> keeps
> `USE_MULTI=define` and turns off just `USE_ITHREADS` and `USE_IMP_SYS`,
> which is
> enough to drop `PERL_IMPLICIT_SYS` from `ccflags` and the emulated
> `fork` with it,
> while retaining `MULTIPLICITY`. Worth stating because it is the weaker and
> hence
> easier variant to package.
>
> The consequence is a packaging one, not a technical one. Dropping the flags
> changes `archname` — with all three off the `-multi-thread` suffix goes
> away
> entirely; in the `USE_MULTI`-retaining variant above it becomes
> `MSWin32-x64-multi`, losing only `-thread` — so such a perl cannot
> load XS modules compiled for the threaded one. That sounds like a wall
> only if one
> imagines each user rebuilding CPAN by hand. The natural answer is the one
> Strawberry already applies to every other archname-affecting flag: ship it
> as an
> **edition**. Strawberry's 32-bit, 64-bit, 64-bit-integer and
> long-double builds are
> already mutually binary-incompatible, each with its own prebuilt module
> set, and
> the PDL edition shows the project is willing to assemble a distribution
> around a
> particular use case. A non-ithreads edition is the same kind of variant,
> and the
> rebuild is done once by whoever assembles it rather than by anyone who
> installs
> it.
>
> That reframes the cost honestly. It is not "you lose the binary
> ecosystem"; it is
> "somebody has to build a second one, and users then pick the edition
> matching what
> they need" — a real but bounded, one-off distribution effort, of
> exactly the kind
> this ecosystem already absorbs routinely.
>
> **What still has to hold.** With the build objection gone, the remaining
> requirements are not about perl at all:
>
> - **The coroutine backend must survive thread migration.** The bracket
> parks a
>   worker thread's machine context in the released coro's saved-context
> slot and
>   resumes it later from whichever thread next runs the scheduler. Coro
> switches
>   stacks through libcoro, which offers a backend per mechanism; the two
> that
>   matter on Windows are `CORO_ASM`, handcoded assembler, and `CORO_FIBER`,
> built
>   on the Win32 fiber API. A `CORO_ASM` context is a bare stack pointer, so
> the
>   slot can be reused that way, and its Windows path maintains the TIB
> stack bounds
>   (`StackBase`/`StackLimit`) on every switch — on whichever thread
> performs the
>   switch, which is exactly the property needed. `CORO_FIBER` cannot:
> its transfer
>   records the current fiber only when the slot is empty, so a worker's
> identity is
>   never stored, and a later switch would target a fiber another thread is
> still
>   executing, which Windows forbids outright. Built with `CORO_FIBER`,
>   `Coro::Multicore` faults inside `SwitchToFiber` → `TlsGetValue` the
> first time a
>   pool thread tries to resume a coro. (That crash establishes the outcome;
> the
>   slot-empty mechanism above is not separately verified.) Since
> Windows' *default*
>   libcoro backend is not `CORO_ASM`, this is a deliberate build choice
> rather than
>   something obtained for free.
> - **Small portability gaps in the backend.** `Coro::Multicore` calls
>   `pthread_atfork` directly and unguarded (`Multicore.xs`), which is
> meaningless on
>   a fork-less perl; on mingw-w64 winpthreads *provides* it, so it
> links and runs.
>   Its `pthread_sigmask` calls compile to nothing on Win32 (`xthread.h`
> defines them
>   away) — silently dropping the "worker threads never handle perl signals"
>   discipline the surrounding code relies on.
> - **Win64 exception handling.** A croak raised while a worker runs
> perl on a coro
>   stack has to unwind to that worker's own `JMPENV`, and on Win64 `longjmp`
>   participates in SEH-based unwinding. Unwinding to a `jmp_buf` living on a
>   different stack from the one executing is not something the ABI
> contemplates.
>   Measured behaviour is under *Status* below.
>
> **Status.** Non-ithreads plus `CORO_ASM` is the only combination in which
> the
> transparent bracket is possible on Windows; `CORO_FIBER` cannot support it.
>
> The `CORO_ASM` amd64 path needs work before it runs on Windows at all —
> both to
> compile under perl's win32 `ccflags` and to survive the first transfer
> into a
> freshly created context. Two further repairs were needed in
> `Coro::Multicore`
> itself. All of them are Coro-side and carried in that branch; the detail
> belongs
> with it rather than here.
>
> Measured on the **non-ithreads** build — perl from this branch with
> `USE_ITHREADS=undef` `USE_IMP_SYS=undef` (`archname` `MSWin32-x64-multi`),
> built
> with Strawberry's winlibs GCC 13.2.0 / mingw-w64 UCRT toolchain — Coro's
> suite
> under `CORO_ASM` runs 238 tests across 24 files with no subtest
> failures, matching
> the `CORO_FIBER` baseline. The single failing file is `t/22_preempt.t`,
> which
> cannot pass on Win32 for an unrelated reason (`Time::HiRes::alarm` is
> unimplemented
> there). `Coro::Multicore` loads and passes its shipped tests. The
> ithreads build is
> unverified; nothing here speaks to it.
>
> The bracket's effect is measurable. Four coros each holding a 0.40s
> released sleep
> complete in 0.49s where serial execution would take 1.60s; with
> release suppressed
> the same work takes 1.66s. That 3.38× ratio isolates the transparent
> bracket as the
> source of the concurrency.
>
> Win64 SEH unwinding holds. `Win32::GetCurrentThreadId` shows the scenario
> is
> genuinely reachable: coros run *perl* on distinct pool threads (main
> 7732; coros on
> 264, 1684, 10256), each staying on its thread across the release. Raising
> exceptions there — a die caught on the coro stack, an `eval` straddling the
> release, three of six concurrent coros throwing, `local()` correctly
> restored after
> a die across a release, and nested `eval`s both unwinding — passes six
> for six with
> no crash.
>
> One behaviour of the offload backend, not Windows-specific:
>
> - **`Coro::Multicore` runs `done` wherever the completion is noticed**,
> which is
>   neither a fixed OS thread nor the green thread that issued the
> offload. There are
>   three call sites: the event-loop callback that drains the wakeup pipe,
> on the
>   normal path; the handle's destructor, when a pending handle is dropped;
> and
>   `cancel`, when a caller stops the work promptly. The issuing green
> thread is not
>   one of them — it may not be waiting, or may not exist any more.
>
>   Which OS thread that turns out to be depends on the *other* mechanism.
> Driving
>   `ext/XS-APItest`'s work/done pair through this backend,
> `work_ran_off_thread`
>   is 1 either way, while `done_ran_on_main` is 1 with offload alone — the
>   interpreter never leaves the calling thread, so the loop runs there
> — and 0 once
>   a release/acquire bracket is in flight, because then another OS
> thread holds the
>   interpreter and runs the loop. Core's own test backend gives 1,
> resolving before
>   it returns.
>
>   `perlmulticore.h` states the guarantee accordingly: `done` runs *holding
> the
>   interpreter*, on whichever OS thread owns it at the time and in
> whichever green
>   thread noticed. A module may therefore use the perl API freely in
> `done`, but
>   neither thread nor green-thread identity - no thread-local state
> established
>   before the call, no OS handle only the calling thread may touch, and
> nothing that
>   assumes the caller's coroutine is still there.
>
> Windows ARM64 is half-answered. The backend side is ready — libcoro
> has the arm64
> Windows path, written but unexercised — while the toolchain side is not:
> `win32/GNUmakefile` has no arm64 case, so ARM64 means MSVC, which has
> no GCC-style
> inline assembly, which forces the fiber backend. A Strawberry ARM64
> edition would
> change that, since it implies both a mingw-style arm64 toolchain and
> the makefile
> support to use it.
>
> So the recommendation, with a better reason than "Windows is hopeless":
> **offload for portability**, because it asks nothing of the coroutine
> backend and
> nothing of the packaging; **non-ithreads plus `CORO_ASM`** if the
> transparent
> bracket on Windows is worth a second edition, because it is the only road
> there.
> The bracket is measured, so the cost of that second road is packaging.
>
> One qualification: offload asks *almost* nothing of the build. It did ask
> one
> thing. Core's own offload tests were compiled out on Windows, because
> `win32/config_H.gc` left `I_PTHREAD` undefined and `ext/XS-APItest` gates
> its
> thread-pool test backend on that symbol. `<pthread.h>` is present on
> mingw-w64 and
> links without help, and every other core use of `I_PTHREAD` is gated on
> `USE_ITHREADS`, so the win32 config now defines it: inert for a
> non-ithreads
> build, and enough to make the offload path testable there.
>
> ## An honest assessment: the social obstacle
>
> The hardest part of this proposal is not technical. A clean, additive API
> is
> straightforward to specify and, as the accompanying implementation shows,
> to
> build. The real obstacle is social and historical, and it would be
> dishonest to
> present the plan without saying so.
>
> Green threading in Perl has long sat inside a documented, years-long
> friction
> between the author of Coro (and of EV, AnyEvent, and much of the
> surrounding
> high-performance stack) and perl5-porters. The recurring substance was
> backward
> compatibility: internal changes in core repeatedly broke downstream
> modules that
> relied on them, and the two sides drew opposite lessons — that core should
> treat
> such breakage as a regression to be avoided, versus that modules reaching
> into
> unpublished internals had taken on a risk core never promised to
> underwrite.
> The tone of the exchanges, on both sides, hardened the disagreement into
> lasting
> distrust. The practical residue is a de facto position that core would not
> go
> out of its way to accommodate Coro, and a matching reluctance on the
> Coro side to
> route its needs through the core process at all.
>
> The honest reading of the incentives *today* is that **neither party is
> actually
> asking for this integration**:
>
> - The Coro author, by his own public statements, does not see a core API
> as the
>   remedy: his position is that core should simply stop breaking things,
> and he
>   maintains an independent stack (his own coroutine backend, event loop,
> and
>   glue) precisely so as not to depend on core's follow-through. He has also
>   largely stepped back from tracking new perl releases. A plan whose
> success
>   depends on his sustained engagement with p5p is unlikely to find it.
> - perl5-porters, for their part, have little appetite to add APIs that
> expose
>   execution-state internals in order to support a design a number of
> porters
>   consider unsupportable in principle. There is no standing champion for
> it, and
>   its association with the old conflict makes it easy to decline.
>
> Both positions are understandable, and neither is wholly wrong. The
> porters'
> concern is legitimate: unpublished internals *are* a real maintenance
> hazard, and
> blessing one consumer's needs as API carries genuine long-term cost. The
> Coro
> author's frustration is also legitimate: he shipped working, widely
> used software
> and watched it broken by changes he did not initiate. The deadlock is as
> much
> about trust and misaligned incentives as about any line of code —
> which is why it
> has outlasted every purely technical rebuttal from either direction.
>
> What follows from this is not that the proposal is hopeless, but that it
> cannot
> lean on either party's advocacy and must be framed and carried accordingly:
>
> - **As a general capability, not "Coro support."** The API is worth
> having on its
>   own terms: it is not Coro-specific code but the *stackful*-switch
> primitive
>   itself — what constitutes a switchable execution state, and how to tear
> one
>   down correctly — which any stackful green-thread or continuation runtime
> could
>   consume, Coro being merely the one that exists today. It encodes a piece
> of
>   interpreter knowledge core arguably ought to own regardless of who
> consumes it.
>   (Its stackless counterpart is parkapi, which makes the same case on the
>   async/await side: the two are siblings, not one facility. FAA does
> *not* switch
>   stacks and does not use execstate — so the honest general claim here is
>   "any stackful runtime," not "async/await too.") Presented as "a stable
>   execution-state API" it stands on its merits; presented as "make core
> support
>   Coro" it almost certainly does not.
> - **At zero cost to the uninvolved.** Being purely additive and ABI-safe
> (see
>   above) is not only good engineering but the political precondition: it
> lets
>   porters say yes without taking on any obligation to a consumer, any
> change to
>   existing behaviour, or any new breakage surface.
> - **Carried by a neutral party.** The core-side work, and its upkeep across
>   releases, needs an owner who is neither re-litigating the old dispute nor
>   waiting on the other side to change posture. The knowledge encoded is
> Marc
>   Lehmann's and is credited as such; *carrying* it into core and keeping it
>   correct has to be someone else's standing commitment.
>
> In short: the technical case is strong and the code is small, but the
> proposal
> should be advanced with clear eyes — as a modest, self-justifying core
> facility
> that happens to rescue an important use case, owned by someone prepared to
> maintain it without waiting for a reconciliation that may never come.
>
> ## Coexisting with Futures and IO::Async
>
> Publishing the state does not pick a winner between the stackful and
> stackless
> styles; it strengthens both, and they interoperate. A stackful coro and a
> `Future`-based, event-loop world are complementary, not rival:
>
> - Coro integrates with event loops through `Coro::AnyEvent`, so a coro that
>   "blocks" (on a semaphore, a channel, a socket) actually cedes to the
> underlying
>   loop and lets other work proceed; through AnyEvent this reaches any
> supported
>   loop, `IO::Async` included, and there is also direct Coro↔`IO::Async`
> bridging
>   for programs that want the loop without the AnyEvent layer.
> - Because a coro can suspend anywhere, it can **await a `Future`** by
> ceding until
>   the future is ready and then returning its value — giving
> synchronous-looking,
>   suspend-anywhere Perl code on top of an asynchronous, `Future`-returning
> API.
>   Conversely FAA *is* `Future`-native: `await` consumes a `Future`
> directly.
>
> So the same program can use `IO::Async` (or any AnyEvent loop) as its
> I/O engine,
> `Future`s as the currency of pending results, FAA's `async`/`await` where a
> stackless state machine is the natural shape, and Coro where
> suspend-anywhere
> stackful threads (or unmodified blocking-style code, or `Coro::Multicore`'s
> OS-thread offload) are wanted — all in one interpreter, all sharing
> memory. A
> stable core execution-state API is what lets the stackful side of that
> picture
> stop being a maintenance hazard.
>
> ### Worked example: blocking XS as a Future, off the interpreter thread
>
> The three pieces — FAA, Coro::Multicore, and the multicore hook — compose
> into
> something genuinely useful: an `async sub` can consume a blocking XS call
> *asynchronously and off the interpreter thread*, without that module
> ever growing
> a Future-based API. A generic wrapper does it:
>
> ```perl
> sub on_worker (&) {
>     my ($code) = @_;
>     my $f = $loop->new_future;          # a Future tied to the shared loop
>     Coro::async {
>         my @r = eval { $code->() };     # blocking, multicore-enabled
> XS runs here
>         $@ ? $f->fail ($@) : $f->done (@r);
>     };
>     return $f;
> }
>
> # in an async sub:
> my $rows = await on_worker { $sth->execute (@args);
> $sth->fetchall_arrayref };
> ```
>
> The spawned coro runs the blocking call; because a multicore-enabled module
> brackets its blocking section with
> `perlinterp_release`/`perlinterp_acquire`,
> Coro::Multicore hands the interpreter to a worker OS thread for the
> duration, so
> the loop and the awaiting `async sub` keep running there while the
> blocking call
> carries on where it was. When it returns, the module takes the interpreter
> back,
> the coro resolves the Future and the loop resumes the awaiter.
>
> Why this is attractive, and the one real constraint:
>
> - It avoids the *large* change (rewriting the XS to a callback/Future API)
> and
>   needs only the *small* one (the release/acquire bracket), which the
>   multicore-enabled modules already carry — so the whole set of them
> becomes
>   async-consumable from FAA through this single wrapper. - Unlike the
> process-pool
>   route (`IO::Async::Function`), the hand-off is *shared-memory*:
> large arguments
>   and results pass by reference, not serialised across a process boundary.
> - The
>   constraint: the code run this way must be genuinely thread-safe (the
> multicore
>   hard rule), and the program pulls in the whole Coro + Coro::Multicore
> stack —
>   the stackless world leaning on the stackful one to get thread offload.
> It is,
>   notably, the *safe* way to mix the two: the coro is only a thread
> that lets the
>   interpreter go while it runs a plain blocking call, and FAA meets it at
> the
>   Future — no deep
>   interleaving of the two suspension models.
>
> ### Offload to a Future without Coro
>
> That wrapper uses a coro only as something Coro::Multicore can release the
> interpreter around.
> The clean end state removes even that, and the core hook already provides
> the
> primitive it needs: `multicore_offload(work, work_arg, done, done_arg)`
> (above)
> runs a pure-C `work` on a worker thread and, when it finishes, runs
> `done` holding
> the interpreter to marshal the result into an SV — **with no caller
> suspension**. A thin FAA backend registers a hook that creates its handle,
> resolves it from `done`'s SV, and hands it back for the caller to `await`:
>
> ```c
> /* an FAA offload backend, in outline */
> static SV *faa_offload (work, work_arg, done, done_arg) {
>     SV *f = new_future ();       /* a Future is already an awaitable
>     */
>     /* enqueue work on the pool; on completion, on the interp thread:
>      *     SV *r = done (aTHX_ done_arg, &ctx);   $f->done(r);
>    */
>     return f;                    /* the handle multicore_offload returns
>     */
> }
> ```
>
> That the handle it returns is a `Future` and Coro::Multicore's is a class
> of its
> own does not reach the module: the contract fixes the `AWAIT_*` protocol,
> and a
> `Future` satisfies it already.
>
> With an offload backend installed (a thread pool), FAA runs blocking,
> multicore-enabled XS off the interpreter thread with **no coroutine
> library in
> sight**: the program stays pure stackless `async`/`await` on its event
> loop, the
> hook carries the work to a worker and the completion resolves the Future.
> The
> price is the one offload always asks: the C section has to be exposed as
> the
> `work`/`done` pair the primitive takes, not merely bracketed. (With no
> backend,
> `multicore_offload` runs `work` and `done` inline and hands back a
> handle that is
> already resolved — correct, blocking — just like the release/acquire
> bracket.)
>
> That is what the neutral multicore hook ultimately points at: the
> *stackless*
> world getting multi-core blocking-XS on its own terms, with a stackful
> runtime
> being an implementation detail of *one possible* backend rather than a
> required
> dependency. The core side of this exists: `multicore_offload` /
> `multicore_register_offload` alongside the bracket, with two test backends
> in
> `ext/XS-APItest` — one that joins its worker before returning, confirming
> `work`
> runs on another OS thread and `done` holding the interpreter, and one
> that defers,
> which is the shape a stackless backend has and which exercises the pending
> path.
> No FAA adapter is needed: the handle contract is the `AWAIT_*` protocol,
> and a
> plain `Future` satisfies it as it stands, which that second backend
> demonstrates by
> handing back `Future` instances and resolving them. What remains
> outside core is a
> real stackless backend — a thread pool that resolves on the loop.
> Coro::Multicore
> is the stackful one, and works today; the offload primitive is what
> would let FAA
> use multicore without Coro at all.
>
> Not every consumer can take this path, though. See the appendix on
> FFI::Platypus
> for a concrete module where offload → Future dead-ends — its result
> delivery is
> welded to the synchronous XSUB stack — even as offload still earns its
> keep for
> that module by giving it Windows multicore under Coro.
>
> ## Summary
>
> - A thread is an independently scheduled flow of control that shares
> its process's
>   memory and is cheap to manage; concurrency is intrinsic to the idea,
> multi-core
>   parallelism is a separate, valuable bonus.
> - Perl gets parallelism today mainly from **processes** (fork pools, and
> CPAN
>   frameworks over them) and, awkwardly, from **ithreads**
> (clone-per-thread, no
>   real sharing, discouraged); it gets cheap in-heap **concurrency** from
> green
>   threads — **Coro** (stackful, suspend-anywhere) and **FAA** (stackless
>   async/await).
> - FAA is not the "simpler" answer that retires Coro: it depends on the
> same class
>   of internals, needs a *larger* core surface (parkapi + `caller()`
> simulation),
>   still has no async cancellation (only the proposed `Future::With`, RT
> #171969,
>   which Coro's `safe_cancel` already covers), is not itself a scheduler (no
>   `cede`/`cede_slice`), forces function "coloring" (`async`/`await`
> propagates up
>   the whole call graph, and `local` becomes `dynamically`), and gives up
> Coro's
>   suspend-anywhere transparency and dynamic-scope fidelity (`local`, `$_`,
>   `wantarray`). Coro's honest debits in return are tooling (no cross-coro C
>   backtraces or clean C profiling — though XS breakpoints still fire,
> and the Perl
>   debugger is usable if not coro-aware) and call-site visibility (Coro is
>   cooperative — the programmer controls where yielding happens — but a
> call does
>   not advertise whether it may `cede`, and suspension cannot be forbidden
> in an
>   un-checkable context such as `DESTROY`). The two are a trade —
>   stackful-transparent vs stackless-explicit — not an upgrade; core
> should support
>   both on one foundation, not bet that async/await syntax dissolves the
> problem.
> - Because performance-critical Perl work lives in **XS**, the model
> most programs
>   want is *Pure Perl orchestrates, critical XS executes*: one interpreter,
> green
>   threads for I/O concurrency, process pools for coarse parallelism, and OS
>   threads carrying the heavy XS — which is exactly what `Coro::Multicore`
> and
>   opt-in multicore XS modules provide, and exactly the
> release-the-lock-in-C
>   pattern other languages already rely on.
> - The green-thread libraries are fragile only because they depend on
> private,
>   drifting interpreter internals. The proposal is to publish those
> internals as a
>   small, additive, ABI-safe core API — **execstate** (stackful) and
> **parkapi**
>   (stackless) — so that the fragility becomes core's well-tested
> responsibility
>   and green threading in Perl becomes something one can rely on.
> - The chief obstacle is social, not technical: a long history of
> friction has left
>   neither the Coro author nor perl5-porters advocating for this. The plan
> must
>   therefore stand as a general, self-justifying, zero-cost core
> facility — not as
>   "Coro support" — and be carried by a neutral owner willing to maintain it
>   without waiting on a reconciliation.
>
> See `Porting/execstate_api.md` for the execstate API specification and its
> implementation plan, and `Porting/savestack_suspend_api.md` for the parkapi
> specification.
>
> ## Appendix: the FFI::Platypus + FAA dead end (and why offload still pays
> off)
>
> The *Offload to a Future without Coro* section above sketches the clean end
> state: a generic blocking-XS module hands its C work to
> `multicore_offload`, an
> FAA backend resolves a Future from `done`'s SV, and the stackless world
> gets
> multi-core blocking-XS with no coroutine in sight. Trying to realise this
> for
> **FFI::Platypus** — the obvious canonical consumer, since it turns *any* C
> function into a Perl sub — mapped out exactly where that path holds and
> where it
> dead-ends. The short version: it works for purpose-written async XS, but a
> generic *marshalling* module like FFI cannot get *transparent* FAA support
> from
> it — while the same offload hook still buys FFI a real win under Coro,
> notably on
> Windows.
>
> ### Where it breaks: result delivery is bound to the synchronous XSUB frame
>
> FFI::Platypus does not so much *return* a value as *push* one. Its result
> marshalling is a large return-type switch built on `XSRETURN_UV` /
> `XSRETURN_IV`
> / `XSRETURN_NV` / `XSRETURN_EMPTY` and direct `ST(0)` assignment — macros
> that
> set the XSUB's mortal `TARG`, write the argument stack, and `return`
> out of the C
> function. All three ingredients — the argument stack (`ST(n)`), the XSUB's
> `TARG`, and the C frame you return from — exist only for the duration of
> the
> synchronous call.
>
> That is fatal to the offload → Future model, whose whole point is that
> `done()`
> runs **later**, on the event loop, after the XSUB has already returned a
> Future.
> By then there is no stack to push onto and no frame to return from:
> `XSRETURN`
> cannot be used from `done()` at all. The result has to be *produced* as a
> plain
> `SV *` that `done()` returns, not *returned* off the stack.
>
> The scalar/void return is, on its own, fixable: factor the switch into
> a producer
> `SV *ffi_pl_result_to_sv(pTHX_ self, result)` that `newSV`s the value
> instead of
> pushing it, and have `done()` return that SV. Simple by-value-in,
> scalar-out
> functions would then work under FAA.
>
> ### Why the general case does not recover
>
> The return value is only half of FFI's delivery. It also **writes back
> output
> arguments** — `int *`, arrays, in-out records — into the *caller's* `@_`
> SVs
> after the call, and **re-enters Perl** for custom types (type coderefs
> run during
> conversion). Both are bound to the caller's synchronous frame. Under Coro
> that
> frame is merely suspended and still present, so the write-back and
> re-entry just
> work when the coro resumes. Under FAA the caller's `@_` is *gone* by the
> time
> `done()` runs. Making it work means marshalling every caller-side SV out
> of the
> transient stack into a heap-allocated job with retained refcounts,
> performing the
> write-back and re-entry in `done()` against those retained SVs, and
> freeing them
> there — a deep rewrite of the call path that must get lifetime and
> refcounting
> exactly right, for a payoff that is *still* a **coloured** API (a distinct
> `call_async` returning a Future; `await` required).
>
> So the dead end is specific. It is not "offload cannot serve FAA"; it
> is that **a
> generic marshalling module whose result delivery is entangled with the
> synchronous XSUB stack cannot get *transparent* FAA support from
> offload.** The
> transparency the clean-end-state sketch implies never materialises for
> FFI::Platypus. What is achievable is a scoped, explicitly-async subset
> (scalar/void return, no out-params) — useful, but not the drop-in the
> sketch
> suggested.
>
> ### What still pays off: FFI + offload under Coro, including Windows
>
> The offload hook is not wasted on FFI. Under **Coro** the same primitive
> gives
> FFI the *transparent* form for free, through the synchronous wrapper:
> `multicore_offload_sync` queues the work, suspends the calling coro until
> it is
> over, marshals the result inline (on the still-present frame — no
> `XSRETURN`
> problem, and the frame is still there precisely because the wrapper
> waited), and
> returns the value. `$sub->(...)` stays an ordinary synchronous call while
> other
> coros keep running.
>
> Crucially, offload does this **where the bracket does not reach: Windows as
> shipped.** The release/acquire bracket works by *migrating the
> interpreter* between
> OS threads, which Coro's default Windows backend cannot do and which
> the standard
> ithreads build obstructs besides (see *Windows: the ithreads build is the
> obstacle*); offload needs none of that and keeps the interpreter
> pinned and moves only the C work, so a multicore FFI call runs off the
> interpreter thread on Windows too. For FFI, that portability — not the FAA
> story
> — is the concrete win of the offload hook.
>
> ### The general lesson
>
> Offload → Future is clean for XS modules that already produce their
> *entire*
> result inside a `done`-style callback: pure C work in, one SV out, no
> dependence
> on the caller's stack (the `scramble_async` worked example is exactly this
> shape). It breaks for modules whose result delivery is welded to the
> synchronous
> XSUB stack — `XSRETURN`, `TARG`, output-argument write-back — of which
> FFI::Platypus is the archetype. The offload primitive therefore delivers
> "FAA
> multi-core without Coro" for the well-behaved case and for purpose-written
> async
> XS, but not universally: stack-bound result delivery, layered on top of
> function
> colouring, is the boundary.
>
> There is a second qualifying shape, and for numeric work it matters
> more than the
> first: modules that deliver their result **in place**, into a buffer
> that already
> exists before the computation starts. Then there is nothing to produce at
> resolution time at all, and independence from the caller's stack comes by
> construction rather than by careful factoring. PDL is the archetype — see
> *PDL:
> the pattern already shipped — and where it stops* — which is why the
> Future::AsyncAwait story there is considerably better than this appendix's
> own
> subject would suggest. The boundary is stack-bound *delivery*, not
> offload, and
> not FAA.
>

Sorry but I don't think people are going to read a 16k word proposal from a
stranger.

Maybe you have some good ideas here, but we're not going to find out if
it's presented like this.

Leon
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.